diff --git a/.eslintignore b/.eslintignore
index 57bc5417d..04dda1c10 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -2,6 +2,7 @@ lint-staged.config.js
jest.config.js
jest.config.react18.js
babel.config.js
+codemods/
plopfile.mjs
release.config.js
coverage/
diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md
new file mode 100644
index 000000000..f7953ce22
--- /dev/null
+++ b/.superpowers/sdd/task-3-report.md
@@ -0,0 +1,61 @@
+# Task 3: Display
+
+## Implementation
+
+- Added `Display` with required `display-1` through `display-5` variants.
+- Uses `Role.div`, shared typography classes, forwarded div refs, and obfuscated class names.
+- Exported from `src/display` and the root package entry.
+
+## TDD evidence
+
+- RED: `npm test -- src/display/display.test.tsx --runInBand` failed because `./display` was absent.
+- GREEN: focused suite passes 11 tests after implementation.
+
+## Verification
+
+- `npm test -- src/display/display.test.tsx --runInBand` — pass, 11 tests.
+- `npm run type-check` — pass.
+- `npx @doist/react-compiler-tracker --check-files --show-errors src/display/display.tsx` — no new errors.
+
+## Files
+
+- `src/display/display.tsx`
+- `src/display/display.module.css`
+- `src/display/display.test.tsx`
+- `src/display/index.ts`
+- `src/index.ts`
+
+## Self-review
+
+- Checked exact typography values, Role render behavior, type exports, root export, ref forwarding, a11y, responsive typography classes, and no manual memoization.
+- `git diff --check` passes.
+
+## Concerns
+
+None.
+
+## Review fix
+
+- Display now opts into the shared SF for Web font-family class (`'SF Pro Display', sans-serif`); Text and Heading retain the existing default font-family class.
+- Replaced Display test-ID queries with text/role queries and retained the variant and behavior assertions.
+- Moved Display metrics to component-scoped custom properties and centralized its common weight and line-height rule.
+
+## Fix verification
+
+- `npm test -- src/display/display.test.tsx --runInBand` — RED: 5 variant assertions failed after temporarily removing the SF/display classes; GREEN: passes after restoration.
+- `npm test -- src/display/display.test.tsx src/text/text.test.tsx src/heading/heading.test.tsx --runInBand` — pass, 3 suites, 60 tests.
+- `npm run type-check` — pass.
+- `npx @doist/react-compiler-tracker --check-files --show-errors src/display/display.tsx` — no new errors.
+- `git diff --check` — pass.
+
+## Mapping fix
+
+- Display now obtains `font-family-sf-for-web` from `typography.module.css`, where the class is defined, rather than from its local CSS module.
+- Added a source-mapping regression test because the global CSS mock returns every property name and cannot expose a wrong CSS-module lookup.
+
+## Mapping verification
+
+- `npm test -- src/display/display.test.tsx --runInBand` — RED: shared CSS-module mapping assertion failed before the import fix; GREEN: pass, 1 suite, 12 tests.
+- `npm run type-check` — pass.
+- `npx @doist/react-compiler-tracker --check-files --show-errors src/display/display.tsx` — no new errors.
+- `git diff --check` — pass.
diff --git a/codemods/README.md b/codemods/README.md
new file mode 100644
index 000000000..c8060b4f8
--- /dev/null
+++ b/codemods/README.md
@@ -0,0 +1,35 @@
+# Reactist typography codemod
+
+Migrates the breaking Text and Heading typography APIs to named SF variants.
+
+## Run
+
+```bash
+npx jscodeshift@17.3.0 \
+ --transform ./node_modules/@doist/reactist/codemods/typography-variants.js \
+ --extensions js,jsx,ts,tsx \
+ --parser tsx \
+ src
+```
+
+Run Prettier and the application's type-check after the transform.
+
+## Exact mappings
+
+| Legacy Text size | Regular or omitted | Semibold | Bold |
+| ---------------- | ------------------ | ----------- | --------- |
+| subtitle | subheader-2 | subheader-1 | Manual |
+| body or omitted | body-3 | body-2 | body-1 |
+| copy | callout-2 | callout-1 | Manual |
+| caption | caption-3 | caption-2 | caption-1 |
+
+Heading maps only exact 32px/700 uses to heading-1 and exact 20px/700 uses to heading-3.
+
+## Manual migrations
+
+The transform changes only exact static mappings. Ambiguous size/weight combinations, dynamic
+expressions, and prop spreads remain unchanged. Each unresolved element receives a
+TODO(reactist-codemod) comment, and the command prints its file and line.
+
+Resolve every TODO before upgrading to the new Reactist major version. Do not mechanically choose
+the nearest variant: confirm the intended visual hierarchy with design.
diff --git a/codemods/__testfixtures__/typography-variants-heading.input.tsx b/codemods/__testfixtures__/typography-variants-heading.input.tsx
new file mode 100644
index 000000000..8d502ef23
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-heading.input.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react'
+
+import { Heading, Heading as Title } from '@doist/reactist'
+
+export function ExactHeadings() {
+ return (
+ <>
+
+ Large
+
+ Current default
+
+ Visual 20
+
+
+ Visual 20
+
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-heading.output.tsx b/codemods/__testfixtures__/typography-variants-heading.output.tsx
new file mode 100644
index 000000000..d8f6d5ed1
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-heading.output.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react'
+
+import { Heading, Heading as Title } from '@doist/reactist'
+
+export function ExactHeadings() {
+ return (
+ <>
+
+ Large
+
+
+ Current default
+
+
+ Visual 20
+
+
+ Visual 20
+
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-idempotence.input.tsx b/codemods/__testfixtures__/typography-variants-idempotence.input.tsx
new file mode 100644
index 000000000..8282cc753
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-idempotence.input.tsx
@@ -0,0 +1,14 @@
+import { Heading } from '@doist/reactist'
+
+export function HeadingVariants() {
+ return (
+ <>
+
+ Page title
+
+
+ Prominent subsection
+
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-idempotence.output.tsx b/codemods/__testfixtures__/typography-variants-idempotence.output.tsx
new file mode 100644
index 000000000..8282cc753
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-idempotence.output.tsx
@@ -0,0 +1,14 @@
+import { Heading } from '@doist/reactist'
+
+export function HeadingVariants() {
+ return (
+ <>
+
+ Page title
+
+
+ Prominent subsection
+
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-manual.input.tsx b/codemods/__testfixtures__/typography-variants-manual.input.tsx
new file mode 100644
index 000000000..d9d429300
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-manual.input.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react'
+
+import { Heading, Text } from '@doist/reactist'
+
+export function ManualCases({ size, weight, useLabel, level, props }) {
+ return (
+ <>
+
+ No exact match
+
+ Dynamic
+ Dynamic weight
+ Dynamic element
+ Unsupported element
+ }>
+ Existing render
+
+
+ Duplicate size
+
+
+ Duplicate weight
+
+
+ Duplicate element
+
+ Spread
+
+ 24px
+
+ 16px
+ 14px
+
+ 12px
+
+
+ Medium
+
+
+ Light
+
+ Dynamic level
+
+ Dynamic size
+
+
+ Dynamic weight
+
+ Spread
+
+ Spread and dynamic props
+
+ >
+ )
+}
+
+export function Shadowed() {
+ const Text = (props: React.ComponentProps<'span'>) =>
+
+ return Shadowed Text
+}
diff --git a/codemods/__testfixtures__/typography-variants-manual.output.tsx b/codemods/__testfixtures__/typography-variants-manual.output.tsx
new file mode 100644
index 000000000..c09291b74
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-manual.output.tsx
@@ -0,0 +1,100 @@
+import * as React from 'react'
+
+import { Heading, Text } from '@doist/reactist'
+
+export function ManualCases({ size, weight, useLabel, level, props }) {
+ return (
+ <>
+
+ {/* TODO(reactist-codemod): Text size and weight have no exact variant */}
+ No exact match
+
+
+ {/* TODO(reactist-codemod): dynamic Text size */}
+ Dynamic
+
+
+ {/* TODO(reactist-codemod): dynamic Text weight */}
+ Dynamic weight
+
+
+ {/* TODO(reactist-codemod): dynamic Text as target */}
+ Dynamic element
+
+
+ {/* TODO(reactist-codemod): dynamic Text as target */}
+ Unsupported element
+
+ }>
+ {/* TODO(reactist-codemod): Text already has render prop */}
+ Existing render
+
+
+ {/* TODO(reactist-codemod): duplicate Text size props */}
+ Duplicate size
+
+
+ {/* TODO(reactist-codemod): duplicate Text weight props */}
+ Duplicate weight
+
+
+ {/* TODO(reactist-codemod): duplicate Text as props */}
+ Duplicate element
+
+
+ {/* TODO(reactist-codemod): spread props may supply or override typography props */}
+ Spread
+
+
+ {/* TODO(reactist-codemod): Heading metrics have no exact variant */}
+ 24px
+
+
+ {/* TODO(reactist-codemod): Heading metrics have no exact variant */}
+ 16px
+
+
+ {/* TODO(reactist-codemod): Heading metrics have no exact variant */}
+ 14px
+
+
+ {/* TODO(reactist-codemod): Heading metrics have no exact variant */}
+ 12px
+
+
+ {/* TODO(reactist-codemod): Heading metrics have no exact variant */}
+ Medium
+
+
+ {/* TODO(reactist-codemod): Heading metrics have no exact variant */}
+ Light
+
+
+ {/* TODO(reactist-codemod): dynamic Heading level */}
+ Dynamic level
+
+
+ {/* TODO(reactist-codemod): dynamic Heading size */}
+ Dynamic size
+
+
+ {/* TODO(reactist-codemod): dynamic Heading weight */}
+ Dynamic weight
+
+
+ {/* TODO(reactist-codemod): spread props may supply or override typography props; dynamic Heading level */}
+ Spread
+
+
+ {/* TODO(reactist-codemod): spread props may supply or override typography props; dynamic Heading level; dynamic Heading size; dynamic Heading weight */}
+ Spread and dynamic props
+
+ >
+ )
+}
+
+export function Shadowed() {
+ const Text = (props: React.ComponentProps<'span'>) =>
+
+ return Shadowed Text
+}
diff --git a/codemods/__testfixtures__/typography-variants-safety.input.tsx b/codemods/__testfixtures__/typography-variants-safety.input.tsx
new file mode 100644
index 000000000..6e4769369
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-safety.input.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react'
+
+import { Heading, Text } from '@doist/reactist'
+
+const anchorRef = React.createRef()
+
+function RequiredLink({ targetId }: { targetId: string }) {
+ return
+}
+
+function span() {
+ return
+}
+
+const UI = { Link: RequiredLink }
+
+export function SafetyCases() {
+ return (
+ <>
+ Safe span
+
+ Anchor with props
+
+
+ Custom component with required props
+
+ Lowercase variable component
+ Static member component
+
+ Mixed Text props
+
+
+ Mixed Heading props
+
+ } level={1}>
+ Mixed Heading render props
+
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-safety.output.tsx b/codemods/__testfixtures__/typography-variants-safety.output.tsx
new file mode 100644
index 000000000..6d6124446
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-safety.output.tsx
@@ -0,0 +1,48 @@
+import * as React from 'react'
+
+import { Heading, Text } from '@doist/reactist'
+
+const anchorRef = React.createRef()
+
+function RequiredLink({ targetId }: { targetId: string }) {
+ return
+}
+
+function span() {
+ return
+}
+
+const UI = { Link: RequiredLink }
+
+export function SafetyCases() {
+ return (
+ <>
+ }>Safe span
+
+ {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */}
+ Anchor with props
+
+
+ {/* TODO(reactist-codemod): Text as migration requires no props besides size or weight */}
+ Custom component with required props
+
+
+ {/* TODO(reactist-codemod): dynamic Text as target */}
+ Lowercase variable component
+
+ }>Static member component
+
+ {/* TODO(reactist-codemod): Text mixes variant with legacy size or weight props */}
+ Mixed Text props
+
+
+ {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */}
+ Mixed Heading props
+
+ } level={1}>
+ {/* TODO(reactist-codemod): Heading mixes variant or render with legacy level, size, or weight props */}
+ Mixed Heading render props
+
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-text.input.tsx b/codemods/__testfixtures__/typography-variants-text.input.tsx
new file mode 100644
index 000000000..38e44921e
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-text.input.tsx
@@ -0,0 +1,38 @@
+import * as React from 'react'
+
+import { Text, Text as Copy } from '@doist/reactist'
+
+function Link(props: React.ComponentProps<'a'>) {
+ return
+}
+
+export function Example() {
+ return (
+ <>
+ Subheader regular
+
+ Subheader semibold
+
+ Body regular
+
+ Body semibold
+
+
+ Body bold
+
+ Callout regular
+
+ Callout semibold
+
+ Caption regular
+
+ Caption semibold
+
+
+ Caption bold
+
+ Default body
+ Default link
+ >
+ )
+}
diff --git a/codemods/__testfixtures__/typography-variants-text.output.tsx b/codemods/__testfixtures__/typography-variants-text.output.tsx
new file mode 100644
index 000000000..bd8f7b935
--- /dev/null
+++ b/codemods/__testfixtures__/typography-variants-text.output.tsx
@@ -0,0 +1,28 @@
+import * as React from 'react'
+
+import { Text, Text as Copy } from '@doist/reactist'
+
+function Link(props: React.ComponentProps<'a'>) {
+ return
+}
+
+export function Example() {
+ return (
+ <>
+ Subheader regular
+ Subheader semibold
+ Body regular
+ Body semibold
+ }>
+ Body bold
+
+ Callout regular
+ Callout semibold
+ Caption regular
+ Caption semibold
+ Caption bold
+ Default body
+ }>Default link
+ >
+ )
+}
diff --git a/codemods/typography-variants.js b/codemods/typography-variants.js
new file mode 100644
index 000000000..6cad44424
--- /dev/null
+++ b/codemods/typography-variants.js
@@ -0,0 +1,364 @@
+const TEXT_VARIANTS = {
+ subtitle: { regular: 'subheader-2', semibold: 'subheader-1' },
+ body: { regular: 'body-3', semibold: 'body-2', bold: 'body-1' },
+ copy: { regular: 'callout-2', semibold: 'callout-1' },
+ caption: { regular: 'caption-3', semibold: 'caption-2', bold: 'caption-1' },
+}
+
+const HEADING_SIZES = {
+ 1: { default: 20, smaller: 16, larger: 24, largest: 32 },
+ 2: { default: 16, smaller: 14, larger: 20, largest: 24 },
+ 3: { default: 14, smaller: 12, larger: 16, largest: 20 },
+ 4: { default: 14, smaller: 14, larger: 16, largest: 20 },
+ 5: { default: 14, smaller: 14, larger: 16, largest: 20 },
+ 6: { default: 14, smaller: 14, larger: 16, largest: 20 },
+}
+
+const HEADING_WEIGHTS = {
+ regular: 700,
+ medium: 600,
+ light: 400,
+}
+
+const HEADING_VARIANTS = {
+ '32:700': 'heading-1',
+ '20:700': 'heading-3',
+}
+
+const DYNAMIC = Symbol('dynamic')
+
+function getImportedNames(root, j, importedName) {
+ const names = new Set()
+ root.find(j.ImportDeclaration, { source: { value: '@doist/reactist' } }).forEach((path) => {
+ for (const specifier of path.node.specifiers ?? []) {
+ if (
+ specifier.type === 'ImportSpecifier' &&
+ specifier.imported.type === 'Identifier' &&
+ specifier.imported.name === importedName
+ ) {
+ names.add(specifier.local?.name ?? importedName)
+ }
+ }
+ })
+ return names
+}
+
+function isImportedBinding(path, name, importedName) {
+ const bindings = path.scope.lookup(name)?.getBindings()[name] ?? []
+
+ return bindings.some(
+ (binding) =>
+ binding.parent?.node.type === 'ImportSpecifier' &&
+ binding.parent.parent?.node.type === 'ImportDeclaration' &&
+ binding.parent.parent.node.source.value === '@doist/reactist' &&
+ binding.parent.node.imported.type === 'Identifier' &&
+ binding.parent.node.imported.name === importedName,
+ )
+}
+
+function getAttribute(openingElement, name) {
+ return openingElement.attributes.find(
+ (attribute) =>
+ attribute.type === 'JSXAttribute' &&
+ attribute.name.type === 'JSXIdentifier' &&
+ attribute.name.name === name,
+ )
+}
+
+function getDuplicateAttributes(openingElement, names) {
+ return names.filter(
+ (name) =>
+ openingElement.attributes.filter(
+ (attribute) =>
+ attribute.type === 'JSXAttribute' &&
+ attribute.name.type === 'JSXIdentifier' &&
+ attribute.name.name === name,
+ ).length > 1,
+ )
+}
+
+function hasSpread(openingElement) {
+ return openingElement.attributes.some((attribute) => attribute.type === 'JSXSpreadAttribute')
+}
+
+function readStaticString(attribute, fallback) {
+ if (!attribute) return fallback
+ if (!attribute.value) return DYNAMIC
+ if (attribute.value.type === 'StringLiteral' || attribute.value.type === 'Literal') {
+ return String(attribute.value.value)
+ }
+ if (attribute.value.type !== 'JSXExpressionContainer') return DYNAMIC
+
+ const expression = attribute.value.expression
+ if (
+ expression.type === 'StringLiteral' ||
+ (expression.type === 'Literal' && typeof expression.value === 'string')
+ ) {
+ return String(expression.value)
+ }
+ return DYNAMIC
+}
+
+function readStaticLevel(attribute) {
+ const value = readStaticString(attribute, DYNAMIC)
+ if (value !== DYNAMIC && /^[1-6]$/.test(value)) return Number(value)
+
+ if (attribute?.value?.type !== 'JSXExpressionContainer') return DYNAMIC
+ const expression = attribute.value.expression
+ if (
+ (expression.type === 'NumericLiteral' || expression.type === 'Literal') &&
+ Number.isInteger(expression.value) &&
+ expression.value >= 1 &&
+ expression.value <= 6
+ ) {
+ return Number(expression.value)
+ }
+
+ return DYNAMIC
+}
+
+function removeAttributes(openingElement, names) {
+ openingElement.attributes = openingElement.attributes.filter(
+ (attribute) =>
+ attribute.type !== 'JSXAttribute' ||
+ attribute.name.type !== 'JSXIdentifier' ||
+ !names.includes(attribute.name.name),
+ )
+}
+
+function addVariant(j, openingElement, variant) {
+ removeAttributes(openingElement, ['size', 'weight'])
+ const levelIndex = openingElement.attributes.findIndex(
+ (attribute) =>
+ attribute.type === 'JSXAttribute' &&
+ attribute.name.type === 'JSXIdentifier' &&
+ attribute.name.name === 'level',
+ )
+ const insertionIndex = levelIndex >= 0 ? levelIndex + 1 : 0
+ openingElement.attributes.splice(
+ insertionIndex,
+ 0,
+ j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(variant)),
+ )
+}
+
+function toJSXName(j, expression) {
+ if (expression.type === 'Identifier') return j.jsxIdentifier(expression.name)
+ if (expression.type === 'MemberExpression' && !expression.computed) {
+ const object = toJSXName(j, expression.object)
+ const property = toJSXName(j, expression.property)
+ return object && property ? j.jsxMemberExpression(object, property) : null
+ }
+ return null
+}
+
+function getStaticRenderName(j, attribute) {
+ if (!attribute?.value) return null
+ if (attribute.value.type === 'StringLiteral' || attribute.value.type === 'Literal') {
+ return j.jsxIdentifier(String(attribute.value.value))
+ }
+ if (attribute.value.type !== 'JSXExpressionContainer') return null
+
+ const expression = attribute.value.expression
+ if (
+ expression.type === 'StringLiteral' ||
+ (expression.type === 'Literal' && typeof expression.value === 'string')
+ ) {
+ return j.jsxIdentifier(String(expression.value))
+ }
+ if (expression.type === 'Identifier' && /^[A-Z]/.test(expression.name)) {
+ return j.jsxIdentifier(expression.name)
+ }
+ if (expression.type === 'MemberExpression' && !expression.computed) {
+ return toJSXName(j, expression)
+ }
+ return null
+}
+
+function hasAttributesOtherThan(openingElement, names) {
+ return openingElement.attributes.some(
+ (attribute) =>
+ attribute.type === 'JSXSpreadAttribute' ||
+ (attribute.type === 'JSXAttribute' &&
+ attribute.name.type === 'JSXIdentifier' &&
+ !names.includes(attribute.name.name)),
+ )
+}
+
+function replaceAsWithRender(j, openingElement, asAttribute, renderName) {
+ const renderElement = j.jsxElement(j.jsxOpeningElement(renderName, [], true), null, [], true)
+ asAttribute.name = j.jsxIdentifier('render')
+ asAttribute.value = j.jsxExpressionContainer(renderElement)
+}
+
+function markManual(j, api, file, path, reasons) {
+ const message = ' TODO(reactist-codemod): ' + reasons.join('; ') + ' '
+ const alreadyMarked = path.node.children.some(
+ (child) =>
+ child.type === 'JSXExpressionContainer' &&
+ child.expression.type === 'JSXEmptyExpression' &&
+ child.expression.comments?.some((comment) =>
+ comment.value.includes('TODO(reactist-codemod)'),
+ ),
+ )
+
+ if (!alreadyMarked) {
+ const emptyExpression = j.jsxEmptyExpression()
+ emptyExpression.comments = [j.commentBlock(message)]
+ path.node.children.unshift(
+ j.jsxText('\n'),
+ j.jsxExpressionContainer(emptyExpression),
+ j.jsxText('\n'),
+ )
+
+ if (path.node.openingElement.selfClosing) {
+ const name = path.node.openingElement.name
+ path.node.openingElement.selfClosing = false
+ path.node.closingElement = j.jsxClosingElement(j.jsxIdentifier(name.name))
+ }
+ }
+
+ const line = path.node.loc?.start.line ?? 1
+ api.report?.(file.path + ':' + line + ' ' + reasons.join('; '))
+}
+
+function transformTextElement(j, api, file, path) {
+ const openingElement = path.node.openingElement
+ const hasVariant = Boolean(getAttribute(openingElement, 'variant'))
+ const legacySizeAttribute = getAttribute(openingElement, 'size')
+ const legacyWeightAttribute = getAttribute(openingElement, 'weight')
+ const reasons = []
+ if (hasSpread(openingElement))
+ reasons.push('spread props may supply or override typography props')
+ for (const name of getDuplicateAttributes(openingElement, ['size', 'weight', 'as'])) {
+ reasons.push('duplicate Text ' + name + ' props')
+ }
+
+ if (hasVariant && (legacySizeAttribute || legacyWeightAttribute)) {
+ reasons.push('Text mixes variant with legacy size or weight props')
+ }
+
+ const sizeAttribute = hasVariant ? undefined : legacySizeAttribute
+ const weightAttribute = hasVariant ? undefined : legacyWeightAttribute
+ const asAttribute = getAttribute(openingElement, 'as')
+ const renderAttribute = getAttribute(openingElement, 'render')
+ const size = readStaticString(sizeAttribute, 'body')
+ const weight = readStaticString(weightAttribute, 'regular')
+
+ if (size === DYNAMIC) reasons.push('dynamic Text size')
+ if (weight === DYNAMIC) reasons.push('dynamic Text weight')
+
+ const variant =
+ size === DYNAMIC || weight === DYNAMIC ? undefined : TEXT_VARIANTS[size]?.[weight]
+ if (!hasVariant && (sizeAttribute || weightAttribute) && !variant && reasons.length === 0) {
+ reasons.push('Text size and weight have no exact variant')
+ }
+
+ const renderName = asAttribute ? getStaticRenderName(j, asAttribute) : undefined
+ if (asAttribute && !renderName) {
+ reasons.push('dynamic Text as target')
+ } else if (
+ asAttribute &&
+ !renderAttribute &&
+ hasAttributesOtherThan(openingElement, ['as', 'size', 'weight'])
+ ) {
+ reasons.push('Text as migration requires no props besides size or weight')
+ }
+ if (asAttribute && renderAttribute) reasons.push('Text already has render prop')
+
+ if (reasons.length > 0) {
+ markManual(j, api, file, path, reasons)
+ return true
+ }
+ if (!hasVariant && (sizeAttribute || weightAttribute)) {
+ addVariant(j, openingElement, variant)
+ }
+ if (asAttribute && renderName) {
+ replaceAsWithRender(j, openingElement, asAttribute, renderName)
+ }
+ return (
+ Boolean(!hasVariant && (sizeAttribute || weightAttribute)) ||
+ Boolean(asAttribute && renderName)
+ )
+}
+
+function transformHeadingElement(j, api, file, path) {
+ const openingElement = path.node.openingElement
+ const hasVariant = Boolean(getAttribute(openingElement, 'variant'))
+ const hasRender = Boolean(getAttribute(openingElement, 'render'))
+ const hasLegacyVariantProps = ['size', 'weight'].some((name) =>
+ Boolean(getAttribute(openingElement, name)),
+ )
+ const hasLegacyRenderProps = ['level', 'size', 'weight'].some((name) =>
+ Boolean(getAttribute(openingElement, name)),
+ )
+
+ if ((hasVariant && hasLegacyVariantProps) || (hasRender && hasLegacyRenderProps)) {
+ markManual(j, api, file, path, [
+ 'Heading mixes variant or render with legacy level, size, or weight props',
+ ])
+ return true
+ }
+ if (hasVariant || hasRender) {
+ return false
+ }
+
+ const reasons = []
+ if (hasSpread(openingElement)) {
+ reasons.push('spread props may supply or override typography props')
+ }
+ for (const name of getDuplicateAttributes(openingElement, ['level', 'size', 'weight'])) {
+ reasons.push('duplicate Heading ' + name + ' props')
+ }
+
+ const levelAttribute = getAttribute(openingElement, 'level')
+ const sizeAttribute = getAttribute(openingElement, 'size')
+ const weightAttribute = getAttribute(openingElement, 'weight')
+ const level = readStaticLevel(levelAttribute)
+ const size = readStaticString(sizeAttribute, 'default')
+ const weight = readStaticString(weightAttribute, 'regular')
+
+ if (level === DYNAMIC) reasons.push('dynamic Heading level')
+ if (size === DYNAMIC) reasons.push('dynamic Heading size')
+ if (weight === DYNAMIC) reasons.push('dynamic Heading weight')
+
+ let variant
+ if (reasons.length === 0) {
+ const fontSize = HEADING_SIZES[level]?.[size]
+ const fontWeight = HEADING_WEIGHTS[weight]
+ variant = HEADING_VARIANTS[fontSize + ':' + fontWeight]
+ if (!variant) reasons.push('Heading metrics have no exact variant')
+ }
+
+ if (reasons.length > 0) {
+ markManual(j, api, file, path, reasons)
+ return true
+ }
+
+ addVariant(j, openingElement, variant)
+ return true
+}
+
+module.exports = function transform(file, api) {
+ const j = api.jscodeshift
+ const root = j(file.source)
+ const textNames = getImportedNames(root, j, 'Text')
+ const headingNames = getImportedNames(root, j, 'Heading')
+ let changed = false
+
+ root.find(j.JSXElement).forEach((path) => {
+ const openingElement = path.node.openingElement
+ if (openingElement.name.type !== 'JSXIdentifier') return
+
+ const { name } = openingElement.name
+ if (textNames.has(name) && isImportedBinding(path, name, 'Text')) {
+ changed = transformTextElement(j, api, file, path) || changed
+ } else if (headingNames.has(name) && isImportedBinding(path, name, 'Heading')) {
+ changed = transformHeadingElement(j, api, file, path) || changed
+ }
+ })
+
+ return changed ? root.toSource({ quote: 'single' }) : null
+}
+
+module.exports.parser = 'tsx'
diff --git a/codemods/typography-variants.test.js b/codemods/typography-variants.test.js
new file mode 100644
index 000000000..570531681
--- /dev/null
+++ b/codemods/typography-variants.test.js
@@ -0,0 +1,114 @@
+const path = require('path')
+const fs = require('fs')
+
+const { applyTransform } = require('jscodeshift/dist/testUtils')
+const j = require('jscodeshift').withParser('tsx')
+const prettier = require('prettier/standalone')
+const estree = require('prettier/plugins/estree')
+const typescript = require('prettier/plugins/typescript')
+
+const transform = require('./typography-variants')
+const fixturesDirectory = path.join(__dirname, '__testfixtures__')
+
+function format(source) {
+ return prettier.format(source, {
+ parser: 'typescript',
+ plugins: [typescript, estree],
+ arrowParens: 'always',
+ printWidth: 100,
+ semi: false,
+ singleQuote: true,
+ tabWidth: 4,
+ trailingComma: 'all',
+ })
+}
+
+async function transformFixture(name) {
+ const inputPath = path.join(fixturesDirectory, name + '.input.tsx')
+ const source = fs.readFileSync(inputPath, 'utf8')
+ const output = await applyTransform(transform, null, { path: inputPath, source })
+ const expected = fs.readFileSync(path.join(fixturesDirectory, name + '.output.tsx'), 'utf8')
+
+ expect(await format(output)).toBe(await format(expected))
+ return output
+}
+
+test('transforms exact Text typography variants', async () => {
+ await transformFixture('typography-variants-text')
+})
+
+test('transforms exact Heading typography variants', async () => {
+ await transformFixture('typography-variants-heading')
+})
+
+test('marks ambiguous Text typography uses for manual migration', async () => {
+ const output = await transformFixture('typography-variants-manual')
+
+ expect(() => j(output)).not.toThrow()
+})
+
+test('retains unsafe legacy element and mixed typography props for manual migration', async () => {
+ const output = await transformFixture('typography-variants-safety')
+
+ expect(() => j(output)).not.toThrow()
+})
+
+test('reports unsafe legacy element and mixed typography props', () => {
+ const source = fs.readFileSync(
+ path.join(fixturesDirectory, 'typography-variants-safety.input.tsx'),
+ 'utf8',
+ )
+ const report = jest.fn()
+
+ transform({ path: 'src/safety.tsx', source }, { jscodeshift: j, report, stats: jest.fn() }, {})
+
+ expect(report.mock.calls.map(([message]) => message)).toEqual([
+ expect.stringMatching(/Text as migration requires no props besides size or weight$/),
+ expect.stringMatching(/Text as migration requires no props besides size or weight$/),
+ expect.stringMatching(/dynamic Text as target$/),
+ expect.stringMatching(/Text mixes variant with legacy size or weight props$/),
+ expect.stringMatching(
+ /Heading mixes variant or render with legacy level, size, or weight props$/,
+ ),
+ expect.stringMatching(
+ /Heading mixes variant or render with legacy level, size, or weight props$/,
+ ),
+ ])
+})
+
+test('leaves valid Heading level and variant props unchanged on a second transform', () => {
+ const source = fs.readFileSync(
+ path.join(fixturesDirectory, 'typography-variants-idempotence.input.tsx'),
+ 'utf8',
+ )
+ const report = jest.fn()
+
+ const output = transform(
+ { path: 'src/idempotence.tsx', source },
+ { jscodeshift: j, report, stats: jest.fn() },
+ {},
+ )
+
+ expect(output).toBeNull()
+ expect(report).not.toHaveBeenCalled()
+})
+
+test('reports every manual migration with file and line', () => {
+ const source = fs.readFileSync(
+ path.join(fixturesDirectory, 'typography-variants-manual.input.tsx'),
+ 'utf8',
+ )
+ const report = jest.fn()
+
+ const output = transform(
+ { path: 'src/manual.tsx', source },
+ { jscodeshift: j, report, stats: jest.fn() },
+ {},
+ )
+
+ expect(report).toHaveBeenCalledTimes(21)
+ for (const [message] of report.mock.calls) {
+ expect(message).toMatch(/^src\/manual\.tsx:\d+ /)
+ }
+ expect(() => j(output)).not.toThrow()
+})
diff --git a/package-lock.json b/package-lock.json
index 408affc37..d700be3b3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -77,6 +77,7 @@
"jest-axe": "^5.0.1",
"jest-environment-jsdom": "^28.1.0",
"jest-watch-typeahead": "^1.1.0",
+ "jscodeshift": "17.3.0",
"less": "^3.8.1",
"lint-staged": "^10.4.0",
"marked": "^4.2.12",
@@ -471,9 +472,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
- "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -551,9 +552,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -868,6 +869,22 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-syntax-flow": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz",
+ "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-syntax-import-assertions": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
@@ -1373,6 +1390,23 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-flow-strip-types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz",
+ "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-syntax-flow": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-for-of": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz",
@@ -2183,6 +2217,24 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/@babel/preset-flow": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.29.7.tgz",
+ "integrity": "sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-transform-flow-strip-types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/preset-modules": {
"version": "0.1.6-no-external-plugins",
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
@@ -12506,6 +12558,29 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/flow-estree": {
+ "version": "0.323.0",
+ "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.323.0.tgz",
+ "integrity": "sha512-VuCvqb38ueB7u9Xnfuj/uV9xWaqRM1XX8fUbch3Q1perZp1BZHAcGKcSkc57RdVXaDrjsfgNOiSEmlvx8Yhk4Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/flow-parser": {
+ "version": "0.323.0",
+ "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.323.0.tgz",
+ "integrity": "sha512-uTw5ffLYxQDnSe4Ch5mo1Sp7kFOrhOglz3nqgpQRrYpt4ucQTxrQRCLdC0F1hyhcIK7j5GjdxzV1d4DN68eDzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flow-estree": "0.323.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/focus-lock": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz",
@@ -17299,6 +17374,57 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jscodeshift": {
+ "version": "17.3.0",
+ "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.3.0.tgz",
+ "integrity": "sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.7",
+ "@babel/parser": "^7.24.7",
+ "@babel/plugin-transform-class-properties": "^7.24.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.24.7",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
+ "@babel/plugin-transform-optional-chaining": "^7.24.7",
+ "@babel/plugin-transform-private-methods": "^7.24.7",
+ "@babel/preset-flow": "^7.24.7",
+ "@babel/preset-typescript": "^7.24.7",
+ "@babel/register": "^7.24.6",
+ "flow-parser": "0.*",
+ "graceful-fs": "^4.2.4",
+ "micromatch": "^4.0.7",
+ "neo-async": "^2.5.0",
+ "picocolors": "^1.0.1",
+ "recast": "^0.23.11",
+ "tmp": "^0.2.3",
+ "write-file-atomic": "^5.0.1"
+ },
+ "bin": {
+ "jscodeshift": "bin/jscodeshift.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "@babel/preset-env": "^7.1.6"
+ },
+ "peerDependenciesMeta": {
+ "@babel/preset-env": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jscodeshift/node_modules/tmp": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
"node_modules/jsdom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz",
@@ -26833,6 +26959,20 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/write-file-atomic": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
+ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
diff --git a/package.json b/package.json
index e9c72c12c..8c5fcf1b8 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"CONTRIBUTING.md",
"LICENSE",
"README.md",
+ "codemods/**",
"dist/**",
"es/**",
"lib/**",
@@ -129,6 +130,7 @@
"jest-axe": "^5.0.1",
"jest-environment-jsdom": "^28.1.0",
"jest-watch-typeahead": "^1.1.0",
+ "jscodeshift": "17.3.0",
"less": "^3.8.1",
"lint-staged": "^10.4.0",
"marked": "^4.2.12",
diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx
index cd88227a7..4561d4787 100644
--- a/src/avatar/avatar.stories.tsx
+++ b/src/avatar/avatar.stories.tsx
@@ -135,9 +135,9 @@ function StorySection({
return (
- {title}
+ {title}
{description ? (
-
+
{description}
) : null}
@@ -152,7 +152,7 @@ function AvatarExample({ label, children }: { label: string; children: React.Rea
{children}
-
+
{label}
diff --git a/src/base-field/base-field.tsx b/src/base-field/base-field.tsx
index 08c253d63..27c66986a 100644
--- a/src/base-field/base-field.tsx
+++ b/src/base-field/base-field.tsx
@@ -30,7 +30,7 @@ function fieldToneToTextTone(tone: FieldTone) {
function FieldMessage({ id, children, tone }: FieldMessageProps) {
return (
-
+ } tone={fieldToneToTextTone(tone)} variant="callout-2" id={id}>
{tone === 'loading' ? (
+
{children}
)
@@ -334,9 +334,8 @@ function BaseField({
alignItems="flexEnd"
>
}
>
{label ? (
{label}
diff --git a/src/box/box.stories.tsx b/src/box/box.stories.tsx
index 8e8399d35..9fe1ca420 100644
--- a/src/box/box.stories.tsx
+++ b/src/box/box.stories.tsx
@@ -280,7 +280,7 @@ export function OverlayScrollStory() {
{Array.from({ length: 20 }, (_, i) => (
Content item {i + 1}
-
+
This is some additional content to make the item taller and ensure
scrolling is needed.
diff --git a/src/display/display.mdx b/src/display/display.mdx
new file mode 100644
index 000000000..07dedd371
--- /dev/null
+++ b/src/display/display.mdx
@@ -0,0 +1,25 @@
+import { Meta, ArgTypes, Description } from '@storybook/addon-docs/blocks'
+import { Display } from './display'
+import * as DisplayStories from './display.stories'
+
+
+
+# Display
+
+
+
+## Usage
+
+Use `Display` for large, prominent text and always choose a display variant. It renders a `div` by
+default, so it has no heading semantics unless `render` supplies a heading element.
+
+```tsx
+42 completed tasks
+}>Annual review
+```
+
+Choose semantic heading levels from the page structure, not from the display variant number.
+
+## Props
+
+
diff --git a/src/display/display.module.css b/src/display/display.module.css
new file mode 100644
index 000000000..f0f221163
--- /dev/null
+++ b/src/display/display.module.css
@@ -0,0 +1,44 @@
+:root {
+ --reactist-display-font-size-1: 96px;
+ --reactist-display-font-size-2: 88px;
+ --reactist-display-font-size-3: 72px;
+ --reactist-display-font-size-4: 56px;
+ --reactist-display-font-size-5: 42px;
+ --reactist-display-font-weight: var(--reactist-typography-font-weight-medium);
+ --reactist-display-line-height: normal;
+ --reactist-display-letter-spacing-1: 0;
+ --reactist-display-letter-spacing-2: 0;
+ --reactist-display-letter-spacing-3: 0.14px;
+ --reactist-display-letter-spacing-4: 0.3px;
+ --reactist-display-letter-spacing-5: 0.37px;
+}
+
+.display {
+ font-weight: var(--reactist-display-font-weight);
+ line-height: var(--reactist-display-line-height);
+}
+
+.variant-display-1 {
+ font-size: var(--reactist-display-font-size-1);
+ letter-spacing: var(--reactist-display-letter-spacing-1);
+}
+
+.variant-display-2 {
+ font-size: var(--reactist-display-font-size-2);
+ letter-spacing: var(--reactist-display-letter-spacing-2);
+}
+
+.variant-display-3 {
+ font-size: var(--reactist-display-font-size-3);
+ letter-spacing: var(--reactist-display-letter-spacing-3);
+}
+
+.variant-display-4 {
+ font-size: var(--reactist-display-font-size-4);
+ letter-spacing: var(--reactist-display-letter-spacing-4);
+}
+
+.variant-display-5 {
+ font-size: var(--reactist-display-font-size-5);
+ letter-spacing: var(--reactist-display-letter-spacing-5);
+}
diff --git a/src/display/display.stories.tsx b/src/display/display.stories.tsx
new file mode 100644
index 000000000..8a08936a7
--- /dev/null
+++ b/src/display/display.stories.tsx
@@ -0,0 +1,58 @@
+import * as React from 'react'
+
+import { Stack } from '../stack'
+import { select, selectWithNone } from '../utils/storybook-helper'
+
+import { Display } from './display'
+
+export default {
+ title: 'Typography/Display',
+ component: Display,
+ parameters: {
+ badges: ['accessible'],
+ figma: {
+ path: 'Global > Text Styles > SF *FOR WEB* > Display 1',
+ url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316',
+ },
+ },
+}
+
+const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const
+
+export function DisplayStory() {
+ return (
+
+
+ {displayVariants.map((variant) => (
+
+ {variant}
+
+ ))}
+
+
+ )
+}
+
+DisplayStory.parameters = { chromatic: { disableSnapshot: false } }
+
+export function DisplayPlaygroundStory(props: React.ComponentProps) {
+ return (
+
+ )
+}
+
+DisplayPlaygroundStory.args = {
+ variant: 'display-3',
+ tone: 'normal',
+ children: 'Display text',
+}
+
+DisplayPlaygroundStory.argTypes = {
+ variant: select(displayVariants),
+ lineClamp: selectWithNone([1, 2, 3, 4, 5]),
+ tone: select(['normal', 'secondary', 'danger', 'positive']),
+ align: selectWithNone(['start', 'center', 'end', 'justify']),
+ children: { control: { type: 'text' } },
+}
diff --git a/src/display/display.test.tsx b/src/display/display.test.tsx
new file mode 100644
index 000000000..1e04e785c
--- /dev/null
+++ b/src/display/display.test.tsx
@@ -0,0 +1,105 @@
+import * as React from 'react'
+
+import { render, screen } from '@testing-library/react'
+import { axe } from 'jest-axe'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { Display } from './display'
+
+const displayVariants = ['display-1', 'display-2', 'display-3', 'display-4', 'display-5'] as const
+
+describe('Display', () => {
+ it.each(displayVariants)('applies the %s variant', (variant) => {
+ render(Display)
+ expect(screen.getByText('Display')).toHaveClass(
+ 'variant-' + variant,
+ 'font-family-sf-for-web',
+ 'display',
+ )
+ })
+
+ it('uses the shared SF for Web CSS-module class', () => {
+ const source = readFileSync(join(__dirname, 'display.tsx'), 'utf8')
+
+ expect(source).toContain(
+ "import typographyStyles from '../typography/typography.module.css'",
+ )
+ expect(source).toContain("fontFamilyClassName: typographyStyles['font-family-sf-for-web']!")
+ })
+
+ it('renders a div by default', () => {
+ render(Display)
+ expect(screen.getByText('Display').tagName).toBe('DIV')
+ })
+
+ it('renders through Ariakit Role', () => {
+ render(
+ }>
+ Page title
+ ,
+ )
+ const element = screen.getByRole('heading', { level: 1, name: 'Page title' })
+ expect(element).toHaveAttribute('id', 'page-title')
+ expect(element).toHaveClass('variant-display-2')
+ })
+
+ it('does not acknowledge className but accepts exceptionallySetClassName', () => {
+ render(
+
+ Display
+ ,
+ )
+ expect(screen.getByText('Display')).toHaveClass('right')
+ expect(screen.getByText('Display')).not.toHaveClass('wrong')
+ })
+
+ it('supports tone, responsive alignment, and line clamping', () => {
+ render(
+
+ Display
+ ,
+ )
+ expect(screen.getByText('Display')).toHaveClass(
+ 'tone-secondary',
+ 'textAlign-start',
+ 'tablet-textAlign-center',
+ 'desktop-textAlign-end',
+ 'lineClamp-2',
+ 'lineClampMultipleLines',
+ 'paddingRight-xsmall',
+ )
+ })
+
+ it('forwards its ref', () => {
+ const ref = React.createRef()
+ render(
+
+ Display
+ ,
+ )
+ expect(ref.current?.tagName).toBe('DIV')
+ })
+
+ it('has no accessibility violations', async () => {
+ const { container } = render(
+ <>
+ Display
+ }>
+ Page title
+
+ >,
+ )
+ expect(await axe(container)).toHaveNoViolations()
+ })
+})
diff --git a/src/display/display.tsx b/src/display/display.tsx
new file mode 100644
index 000000000..325979448
--- /dev/null
+++ b/src/display/display.tsx
@@ -0,0 +1,63 @@
+import * as React from 'react'
+
+import { Role } from '@ariakit/react'
+
+import { getTypographyClassName } from '../typography/typography'
+
+import typographyStyles from '../typography/typography.module.css'
+import styles from './display.module.css'
+
+import type { RoleProps } from '@ariakit/react'
+import type { TypographyStyleProps } from '../typography/typography'
+
+type DisplayVariant = 'display-1' | 'display-2' | 'display-3' | 'display-4' | 'display-5'
+
+/** Renders prominent display text with a required display typography variant. */
+type DisplayProps = Omit, 'children' | 'className'> &
+ TypographyStyleProps & {
+ /** Large display text content. */
+ children: React.ReactNode
+ /** Visual display style. */
+ variant: DisplayVariant
+ /** Custom element rendered with display typography; defaults to a div. */
+ render?: RoleProps['render']
+ }
+
+/** Renders prominent display text with a required display typography variant. */
+const Display = React.forwardRef(function Display(
+ {
+ variant,
+ tone = 'normal',
+ align,
+ lineClamp,
+ exceptionallySetClassName,
+ render,
+ children,
+ ...props
+ },
+ ref,
+) {
+ return (
+
+ {children}
+
+ )
+})
+
+Display.displayName = 'Display'
+
+export type { DisplayProps, DisplayVariant }
+export { Display }
diff --git a/src/display/index.ts b/src/display/index.ts
new file mode 100644
index 000000000..41d109997
--- /dev/null
+++ b/src/display/index.ts
@@ -0,0 +1 @@
+export * from './display'
diff --git a/src/expansion-panel/expansion-panel.stories.tsx b/src/expansion-panel/expansion-panel.stories.tsx
index a42df5c2e..d3df904e9 100644
--- a/src/expansion-panel/expansion-panel.stories.tsx
+++ b/src/expansion-panel/expansion-panel.stories.tsx
@@ -26,7 +26,7 @@ export const IconToggle = {
alignItems="center"
justifyContent="spaceBetween"
>
-
+
Fruit
diff --git a/src/heading/heading.mdx b/src/heading/heading.mdx
new file mode 100644
index 000000000..586123737
--- /dev/null
+++ b/src/heading/heading.mdx
@@ -0,0 +1,33 @@
+import { Meta, ArgTypes, Description } from '@storybook/addon-docs/blocks'
+import { Heading } from './heading'
+import * as HeadingStories from './heading.stories'
+
+
+
+# Heading
+
+
+
+## Usage
+
+Use `level` according to the document outline. The matching visual variant is inferred for levels
+1-3; levels 4-6 use `heading-4`. Override `variant` only when semantics and visual hierarchy differ.
+
+```tsx
+Page title
+Section title
+Prominent section title
+```
+
+Use the custom render path only to apply heading typography to a non-heading control. It does not
+add heading semantics.
+
+```tsx
+}>
+ Edit title
+
+```
+
+## Props
+
+
diff --git a/src/heading/heading.module.css b/src/heading/heading.module.css
index aee2b4605..79a4f0994 100644
--- a/src/heading/heading.module.css
+++ b/src/heading/heading.module.css
@@ -1,114 +1,27 @@
-.heading {
- color: var(--reactist-content-primary);
+.variant-heading-1 {
+ font-size: 32px;
font-weight: var(--reactist-font-weight-strong);
- font-family: var(--reactist-font-family);
+ letter-spacing: 0.41px;
+ line-height: normal;
}
-.weight-medium {
- font-weight: var(--reactist-font-weight-medium);
-}
-
-.weight-light {
- font-weight: var(--reactist-font-weight-regular);
-}
-
-/* tone */
-
-.tone-secondary {
- color: var(--reactist-content-secondary);
-}
-.tone-danger {
- color: rgb(209, 69, 59);
-}
-
-/* font size */
-
-h1.heading {
- font-size: var(--reactist-font-size-header);
-}
-h1.size-largest {
- font-size: var(--reactist-font-size-header-xlarge);
-}
-h1.size-larger {
- font-size: var(--reactist-font-size-header-large);
-}
-h1.size-smaller {
- font-size: var(--reactist-font-size-subtitle);
-}
-
-h2.heading {
- font-size: var(--reactist-font-size-subtitle);
-}
-h2.size-largest {
- font-size: var(--reactist-font-size-header-large);
-}
-h2.size-larger {
- font-size: var(--reactist-font-size-header);
-}
-h2.size-smaller {
- font-size: var(--reactist-font-size-body);
-}
-
-h3.heading {
- font-size: var(--reactist-font-size-body);
-}
-h3.size-largest {
- font-size: var(--reactist-font-size-header);
-}
-h3.size-larger {
- font-size: var(--reactist-font-size-subtitle);
-}
-h3.size-smaller {
- font-size: var(--reactist-font-size-caption);
-}
-
-h4.heading,
-h5.heading,
-h6.heading {
- /*
- * unlike at higher levels, this one is kept as the same size as h3's
- * you can make it two levels larger visually, but making it smaller has no effect
- */
- font-size: var(--reactist-font-size-body);
-}
-
-h4.size-largest,
-h5.size-largest,
-h6.size-largest {
- font-size: var(--reactist-font-size-header);
-}
-
-h4.size-larger,
-h5.size-larger,
-h6.size-larger {
- font-size: var(--reactist-font-size-subtitle);
-}
-
-/* h4/h5/h6 can't be made smaller, maybe we reconsider this? */
-
-/* truncated text */
-
-.lineClampMultipleLines {
- display: -webkit-box;
- -webkit-box-orient: vertical;
- overflow: hidden;
+.variant-heading-2 {
+ font-size: 26px;
+ font-weight: var(--reactist-font-weight-strong);
+ letter-spacing: 0.22px;
+ line-height: normal;
}
-.lineClamp-1 {
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
+.variant-heading-3 {
+ font-size: 20px;
+ font-weight: var(--reactist-font-weight-strong);
+ letter-spacing: 0;
+ line-height: normal;
}
-.lineClamp-2 {
- -webkit-line-clamp: 2;
-}
-.lineClamp-3 {
- -webkit-line-clamp: 3;
-}
-.lineClamp-4 {
- -webkit-line-clamp: 4;
-}
-.lineClamp-5 {
- -webkit-line-clamp: 5;
+.variant-heading-4 {
+ font-size: 18px;
+ font-weight: var(--reactist-typography-font-weight-semibold);
+ letter-spacing: 0;
+ line-height: normal;
}
diff --git a/src/heading/heading.stories.tsx b/src/heading/heading.stories.tsx
index 3ecf79e4e..53f1d51b4 100644
--- a/src/heading/heading.stories.tsx
+++ b/src/heading/heading.stories.tsx
@@ -20,55 +20,19 @@ export default {
export function HeadingStory() {
return (
-
-
-
- Heading level 1, largest
-
-
- Heading level 1, larger
-
- Heading level 1
-
- Heading level 1, smaller
-
-
-
-
-
- Heading level 2, largest
-
-
- Heading level 2, larger
-
- Heading level 2
-
- Heading level 2, smaller
-
-
-
-
-
- Heading level 3, largest
-
-
- Heading level 3, larger
-
- Heading level 3
-
- Heading level 3, smaller
-
-
-
-
-
- Heading level 4 / 5 / 6, largest
-
-
- Heading level 4 / 5 / 6, larger
-
- Heading level 4 / 5 / 6
-
+
+ Heading 1
+ Heading 2
+ Heading 3
+ Heading 4
+ Heading 5, visually heading-4
+ Heading 6, visually heading-4
+
+ Semantic h2, visual heading-1
+
+ }>
+ Button with heading typography
+
)
@@ -81,7 +45,7 @@ HeadingStory.parameters = {
export function TruncatedHeadingStory() {
return (
-
+
This is a long title which we will use demonstrate truncating content. When this
overflows and begins to drop to a new line, its overflowing content will be replaced
by ellipses.
@@ -111,15 +75,13 @@ export function ResponsiveHeadingStory(props: React.ComponentProps {
Heading
,
)
+
expect(screen.getByTestId('heading-element')).toHaveClass('right')
expect(screen.getByTestId('heading-element')).not.toHaveClass('wrong')
})
- it('renders the expected heading tag name based on the level', () => {
- const { rerender } = render(
-
+ it.each([
+ [1, 'H1', 'heading-1'],
+ [2, 'H2', 'heading-2'],
+ [3, 'H3', 'heading-3'],
+ [4, 'H4', 'heading-4'],
+ [5, 'H5', 'heading-4'],
+ [6, 'H6', 'heading-4'],
+ ] as const)('renders level %s as %s with %s', (level, tagName, variant) => {
+ render(
+
Heading
,
)
- expect(screen.getByTestId('heading-element').tagName).toBe('H1')
+ const element = screen.getByTestId('heading-element')
- for (const level of [2, 3, 4, 5, 6] as const) {
- rerender(
-
- Heading
- ,
- )
- expect(screen.getByTestId('heading-element').tagName).toBe(`H${level}`)
- }
+ expect(element.tagName).toBe(tagName)
+ expect(element).toHaveClass('variant-' + variant)
})
- it('renders its children as its content', () => {
+ it('lets semantic level and visual variant differ', () => {
render(
-
- Hello world
+
+ Heading
,
)
- expect(screen.getByTestId('heading-element').innerHTML).toMatchInlineSnapshot(
- `"Hello world"`,
- )
+ const element = screen.getByTestId('heading-element')
+
+ expect(element.tagName).toBe('H2')
+ expect(element).toHaveClass('variant-heading-1')
})
- describe('size="…"', () => {
- it('adds the appropriate class names', () => {
- const { rerender } = render(
-
- Heading
- ,
- )
- const textElement = screen.getByTestId('heading-element')
- expect(textElement).not.toHaveClass('size-smaller')
- expect(textElement).not.toHaveClass('size-larger')
- expect(textElement).not.toHaveClass('size-largest')
+ it('requires an explicit variant for custom rendering', () => {
+ render(
+ }
+ >
+ Edit title
+ ,
+ )
- for (const size of ['smaller', 'larger', 'largest'] as const) {
- rerender(
-
- Heading
- ,
- )
- expect(textElement).toHaveClass(`size-${size}`)
- }
- })
+ expect(screen.getByRole('button', { name: 'Edit title' })).toHaveClass('variant-heading-2')
})
- describe('weight="…"', () => {
- it('adds the appropriate class names', () => {
- const { rerender } = render(
-
- Heading
- ,
- )
- const textElement = screen.getByTestId('heading-element')
- expect(textElement).not.toHaveClass('weight-regular')
- expect(textElement).not.toHaveClass('weight-light')
+ it('forwards its semantic heading ref', () => {
+ const ref = React.createRef()
- rerender(
-
- Heading
- ,
- )
- expect(textElement).toHaveClass('weight-medium')
+ render(
+
+ Heading
+ ,
+ )
- rerender(
-
- Heading
- ,
- )
- expect(textElement).toHaveClass('weight-light')
- })
+ expect(ref.current?.tagName).toBe('H2')
+ })
+
+ it('renders its children as its content', () => {
+ render(
+
+ Hello world
+ ,
+ )
+
+ expect(screen.getByTestId('heading-element').innerHTML).toMatchInlineSnapshot(
+ `"Hello world"`,
+ )
})
describe('tone="…"', () => {
@@ -109,6 +100,7 @@ describe('Heading', () => {
,
)
const textElement = screen.getByTestId('heading-element')
+
expect(textElement).not.toHaveClass('tone-normal')
expect(textElement).not.toHaveClass('tone-secondary')
expect(textElement).not.toHaveClass('tone-danger')
@@ -132,6 +124,7 @@ describe('Heading', () => {
,
)
const textElement = screen.getByTestId('heading-element')
+
expect(textElement).not.toHaveClass('textAlign-start')
expect(textElement).not.toHaveClass('textAlign-center')
expect(textElement).not.toHaveClass('textAlign-end')
@@ -158,6 +151,7 @@ describe('Heading', () => {
,
)
const textElement = screen.getByTestId('heading-element')
+
expect(textElement).toHaveClass('textAlign-start')
expect(textElement).toHaveClass('tablet-textAlign-center')
expect(textElement).toHaveClass('desktop-textAlign-end')
@@ -172,6 +166,7 @@ describe('Heading', () => {
,
)
const textElement = screen.getByTestId('heading-element')
+
expect(textElement.className).not.toMatch(/lineClamp/)
expect(textElement).not.toHaveClass('paddingRight-xsmall')
@@ -182,7 +177,7 @@ describe('Heading', () => {
,
)
expect(textElement).toHaveClass(`lineClamp-${lineClamp}`)
- expect(textElement).not.toHaveClass(`lineClampMultipleLines`)
+ expect(textElement).not.toHaveClass('lineClampMultipleLines')
expect(textElement).toHaveClass('paddingRight-xsmall')
}
@@ -193,7 +188,7 @@ describe('Heading', () => {
,
)
expect(textElement).toHaveClass(`lineClamp-${lineClamp}`)
- expect(textElement).toHaveClass(`lineClampMultipleLines`)
+ expect(textElement).toHaveClass('lineClampMultipleLines')
expect(textElement).toHaveClass('paddingRight-xsmall')
}
})
@@ -209,6 +204,12 @@ describe('Heading', () => {
Heading
Heading
Heading
+
+ Overridden heading
+
+ }>
+ Button heading
+
>,
)
const results = await axe(container)
@@ -217,3 +218,19 @@ describe('Heading', () => {
})
})
})
+
+const invalidHeadingProps = (
+ // @ts-expect-error level and render are mutually exclusive
+ }>
+ Invalid
+
+)
+// eslint-disable-next-line no-void
+void invalidHeadingProps
+
+const missingRenderVariant = (
+ // @ts-expect-error custom render requires a variant
+ }>Invalid
+)
+// eslint-disable-next-line no-void
+void missingRenderVariant
diff --git a/src/heading/heading.tsx b/src/heading/heading.tsx
index fb3501e93..ddab2e197 100644
--- a/src/heading/heading.tsx
+++ b/src/heading/heading.tsx
@@ -1,122 +1,89 @@
import * as React from 'react'
-import { Box } from '../box'
-import { getClassNames } from '../utils/responsive-props'
+import { Role } from '@ariakit/react'
+
+import { getTypographyClassName } from '../typography/typography'
import styles from './heading.module.css'
-import type { BoxProps } from '../box'
-import type { ObfuscatedClassName, Tone } from '../utils/common-types'
+import type { TypographyStyleProps } from '../typography/typography'
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 | '1' | '2' | '3' | '4' | '5' | '6'
-type HeadingElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
+type HeadingVariant = 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4'
+
+type HeadingBaseProps = Omit, 'children' | 'className'> &
+ TypographyStyleProps & {
+ /** Heading content. */
+ children: React.ReactNode
+ }
-type HeadingProps = Omit, 'className' | 'children'> & {
- children: React.ReactNode
- /**
- * The semantic level of the heading.
- */
+type SemanticHeadingProps = {
+ /** Semantic HTML heading level. */
level: HeadingLevel
+ /** Visual heading style; inferred from level when omitted. */
+ variant?: HeadingVariant
+ /** Custom rendering is unavailable when semantic level is supplied. */
+ render?: never
+}
+
+type RenderedHeadingProps = {
+ /** Semantic level is unavailable when a custom element is supplied. */
+ level?: never
+ /** Visual heading style. */
+ variant: HeadingVariant
+ /** Custom element receiving heading typography. */
+ render: React.ReactElement
+}
- /**
- * The weight of the heading. Used to de-emphasize the heading visually when using 'medium' or 'light'.
- *
- * @default 'regular'
- */
- weight?: 'regular' | 'medium' | 'light'
-
- /**
- * Shifts the default heading visual text size up or down, depending on the original size
- * imposed by the `level`. The heading continues to be semantically at the given level.
- *
- * By default, no value is applied, and the default size from the level is applied. The values
- * have the following effect:
- *
- * - 'smaller' shifts the default level size down in the font-size scale (it tends to make the
- * level look visually as if it were of the immediately lower level).
- * - 'larger' has the opposite effect than 'smaller' shifting the visual font size up in the
- * scale.
- * - 'largest' can be thought of as applying 'larger' twice.
- *
- * @see level
- * @default undefined
- */
- size?: 'smaller' | 'larger' | 'largest'
-
- /**
- * The tone (semantic color) of the heading.
- *
- * @default 'normal'
- */
- tone?: Tone
-
- /**
- * Used to truncate the heading to a given number of lines.
- *
- * It will add an ellipsis (`…`) to the text at the end of the last line, only if the text was
- * truncated. If the text fits without it being truncated, no ellipsis is added.
- *
- * By default, the text is not truncated at all, no matter how many lines it takes to render it.
- *
- * @default undefined
- */
- lineClamp?: 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5'
-
- /**
- * How to align the heading text horizontally.
- *
- * @default 'start'
- */
- align?: BoxProps['textAlign']
+/** Renders a semantic heading or applies heading typography to a custom element. */
+type HeadingProps = HeadingBaseProps & (SemanticHeadingProps | RenderedHeadingProps)
+
+function getHeadingVariant(level: HeadingLevel | undefined): HeadingVariant {
+ const numericLevel = Number(level ?? 1)
+
+ return ('heading-' + Math.min(numericLevel, 4)) as HeadingVariant
}
-const Heading = React.forwardRef(
- function Heading(
- {
- level,
- weight = 'regular',
- size,
- tone = 'normal',
- children,
- lineClamp,
- align,
- exceptionallySetClassName,
- ...props
- },
- ref,
- ) {
- // In TypeScript v4.1, this would be properly recognized without needing the type assertion
- // https://devblogs.microsoft.com/typescript/announcing-typescript-4-1-beta/#template-literal-types
- const headingElementName = `h${level}` as HeadingElement
- const lineClampMultipleLines =
- typeof lineClamp === 'string' ? parseInt(lineClamp, 10) > 1 : (lineClamp || 0) > 1
-
- return (
-
- {children}
-
- )
+function getHeadingRender(level: HeadingLevel | undefined) {
+ return level == null ? undefined : React.createElement('h' + level)
+}
+
+/** Renders a semantic heading or applies heading typography to a custom element. */
+const Heading = React.forwardRef(function Heading(
+ {
+ level,
+ variant,
+ render,
+ tone = 'normal',
+ align,
+ lineClamp,
+ exceptionallySetClassName,
+ children,
+ ...props
},
-)
+ ref,
+) {
+ const resolvedVariant = variant ?? getHeadingVariant(level)
+
+ return (
+
+ {children}
+
+ )
+})
Heading.displayName = 'Heading'
-export type { HeadingLevel, HeadingProps }
+export type { HeadingLevel, HeadingProps, HeadingVariant }
export { Heading }
diff --git a/src/index.ts b/src/index.ts
index a27472339..5071a901c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,6 +19,7 @@ export * from './notice'
export * from './toast'
// text and typography
+export * from './display'
export * from './heading'
export * from './prose'
export * from './text'
diff --git a/src/menu/menu.stories.jsx b/src/menu/menu.stories.jsx
index 8a582829f..b6f43f438 100644
--- a/src/menu/menu.stories.jsx
+++ b/src/menu/menu.stories.jsx
@@ -47,7 +47,7 @@ function StructuredMenuItem({ icon, label, shortcut }) {
) : null}
- {label}
+ {label}
{shortcut ? (
@@ -224,7 +224,7 @@ export const LinkMenuItemStory = {
}
>
- Link without an icon
+ Link without an icon
}
>
- Disabled link without an icon
+ Disabled link without an icon
diff --git a/src/modal/modal.stories.tsx b/src/modal/modal.stories.tsx
index 9c82f3428..c02d9da9c 100644
--- a/src/modal/modal.stories.tsx
+++ b/src/modal/modal.stories.tsx
@@ -137,9 +137,7 @@ export function ModalWithHeaderBodyAndCustomFooter() {
-
- Do whatever you want down here
-
+ Do whatever you want down here
@@ -307,7 +305,7 @@ export function MinimalisticConfirmationModal() {
- Are you sure you want to leave?
+ Are you sure you want to leave?
-
+
Try hovering the button, then clicking "Force hide" before the 3-second
timeout expires.
diff --git a/src/typography/typography.module.css b/src/typography/typography.module.css
new file mode 100644
index 000000000..d915f38a0
--- /dev/null
+++ b/src/typography/typography.module.css
@@ -0,0 +1,57 @@
+:root {
+ --reactist-typography-font-family-sf-for-web: 'SF Pro Display', sans-serif;
+ --reactist-typography-font-weight-medium: 500;
+ --reactist-typography-font-weight-semibold: 600;
+}
+
+.typography {
+ color: var(--reactist-content-primary);
+}
+
+.font-family-default {
+ font-family: var(--reactist-font-family);
+}
+
+.font-family-sf-for-web {
+ font-family: var(--reactist-typography-font-family-sf-for-web);
+}
+
+.tone-secondary {
+ color: var(--reactist-content-secondary);
+}
+
+.tone-danger {
+ color: var(--reactist-content-danger);
+}
+
+.tone-positive {
+ color: var(--reactist-content-positive);
+}
+
+.lineClampMultipleLines {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.lineClamp-1 {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lineClamp-2 {
+ -webkit-line-clamp: 2;
+}
+
+.lineClamp-3 {
+ -webkit-line-clamp: 3;
+}
+
+.lineClamp-4 {
+ -webkit-line-clamp: 4;
+}
+
+.lineClamp-5 {
+ -webkit-line-clamp: 5;
+}
diff --git a/src/typography/typography.stories.module.css b/src/typography/typography.stories.module.css
new file mode 100644
index 000000000..bd846d271
--- /dev/null
+++ b/src/typography/typography.stories.module.css
@@ -0,0 +1,39 @@
+.reference {
+ --reactist-content-primary: #000;
+
+ width: 400px;
+ height: 1628px;
+ color: #000;
+}
+
+.header {
+ box-sizing: border-box;
+ width: 400px;
+ height: 43px;
+ margin: 0;
+ padding: 0;
+ font-family: var(--reactist-font-family);
+ font-size: 32px;
+ font-weight: var(--reactist-font-weight-strong);
+ letter-spacing: 0.41px;
+ line-height: normal;
+}
+
+.rows {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 400px;
+}
+
+.row,
+.rowEnd {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ width: 400px;
+}
+
+.rowEnd {
+ align-items: flex-end;
+}
diff --git a/src/typography/typography.stories.tsx b/src/typography/typography.stories.tsx
new file mode 100644
index 000000000..3b9e988c9
--- /dev/null
+++ b/src/typography/typography.stories.tsx
@@ -0,0 +1,151 @@
+import * as React from 'react'
+
+import { Display } from '../display'
+import { Heading } from '../heading'
+import { Text } from '../text'
+
+import styles from './typography.stories.module.css'
+
+type ReferenceRow = {
+ height: number
+ content?: React.ReactNode
+ alignEnd?: boolean
+}
+
+const rows: ReferenceRow[] = [
+ { height: 128, content: Display 1, alignEnd: true },
+ { height: 117, content: Display 2 },
+ { height: 96, content: Display 3 },
+ { height: 74, content: Display 4 },
+ { height: 56, content: Display 5 },
+ { height: 43, content: Header 1 },
+ { height: 35, content: Header 2 },
+ { height: 27, content: Header 3 },
+ { height: 24, content: Header 4 },
+ { height: 23, content: Subheader 1 },
+ {
+ height: 23,
+ content: (
+
+ Subheader 1 Strikethrough
+
+ ),
+ },
+ { height: 24, content: Subheader 2 },
+ {
+ height: 24,
+ content: (
+
+ Subheader 2 Strikethrough
+
+ ),
+ },
+ { height: 24 },
+ { height: 24 },
+ { height: 21, content: Body 1 },
+ { height: 22, content: Body 2 },
+ { height: 22, content: Body 3 },
+ {
+ height: 22,
+ content: (
+
+ Body 3 Strikethrough
+
+ ),
+ },
+ { height: 20, content: Callout 1 },
+ {
+ height: 20,
+ content: (
+
+ Callout 1 Strikethrough
+
+ ),
+ },
+ { height: 20, content: Callout 2 },
+ {
+ height: 20,
+ content: (
+
+ Callout 2 Strikethrough
+
+ ),
+ },
+ { height: 20, content: Caption 1 },
+ { height: 15, content: Caption 2 },
+ {
+ height: 15,
+ content: (
+
+ Caption 2 Strikethrough
+
+ ),
+ },
+ {
+ height: 15,
+ content: (
+
+ Caption 2 Underline
+
+ ),
+ },
+ { height: 20, content: Caption 3 },
+ {
+ height: 20,
+ content: (
+
+ Caption 3 Underline
+
+ ),
+ },
+ {
+ height: 20,
+ content: (
+
+ Caption 3 Strikethrough
+
+ ),
+ },
+ { height: 13, content: Footnote 1 },
+ {
+ height: 13,
+ content: (
+
+ Footnote 1 Caps
+
+ ),
+ },
+ { height: 13, content: Footnote 2 },
+]
+
+export default {
+ title: 'Typography/SF reference',
+ parameters: {
+ figma: {
+ path: 'Global > Text Styles > SF *FOR WEB*',
+ url: 'https://www.figma.com/design/xo9yAsH8PQUpi0eTJh9pmR/Product-Library---Global?node-id=9062-3316',
+ },
+ },
+}
+
+export function SFReference() {
+ return (
+
+ Web (SF – default)
+
+ {rows.map(({ height, content, alignEnd }, index) => (
+
+ {content}
+
+ ))}
+
+
+ )
+}
+
+SFReference.parameters = { chromatic: { disableSnapshot: false } }
diff --git a/src/typography/typography.ts b/src/typography/typography.ts
new file mode 100644
index 000000000..16820dc7f
--- /dev/null
+++ b/src/typography/typography.ts
@@ -0,0 +1,56 @@
+import classNames from 'classnames'
+
+import { getBoxClassNames } from '../box'
+import { getClassNames } from '../utils/responsive-props'
+
+import styles from './typography.module.css'
+
+import type { BoxProps } from '../box'
+import type { ObfuscatedClassName, Tone } from '../utils/common-types'
+
+type TypographyLineClamp = 1 | 2 | 3 | 4 | 5 | '1' | '2' | '3' | '4' | '5'
+
+type TypographyStyleProps = ObfuscatedClassName & {
+ /** The semantic color of the text. */
+ tone?: Tone
+ /** Horizontal text alignment, including responsive values. */
+ align?: BoxProps['textAlign']
+ /** Truncates text after the given number of lines. */
+ lineClamp?: TypographyLineClamp
+}
+
+type TypographyClassNameOptions = TypographyStyleProps & {
+ variantClassName: string
+ fontFamilyClassName?: string
+ modifierClassNames?: Array
+}
+
+function getTypographyClassName({
+ variantClassName,
+ fontFamilyClassName = styles.fontFamilyDefault,
+ modifierClassNames,
+ tone = 'normal',
+ align,
+ lineClamp,
+ exceptionallySetClassName,
+}: TypographyClassNameOptions) {
+ const lineClampMultipleLines = Number(lineClamp ?? 0) > 1
+
+ return classNames(
+ getBoxClassNames({
+ textAlign: align,
+ paddingRight: lineClamp ? 'xsmall' : undefined,
+ }),
+ exceptionallySetClassName,
+ styles.typography,
+ fontFamilyClassName,
+ variantClassName,
+ modifierClassNames,
+ tone !== 'normal' ? getClassNames(styles, 'tone', tone) : null,
+ lineClampMultipleLines ? styles.lineClampMultipleLines : null,
+ lineClamp ? getClassNames(styles, 'lineClamp', String(lineClamp)) : null,
+ )
+}
+
+export type { TypographyLineClamp, TypographyStyleProps }
+export { getTypographyClassName }
diff --git a/stories/components/color.stories.tsx b/stories/components/color.stories.tsx
index 764336408..3b757c937 100644
--- a/stories/components/color.stories.tsx
+++ b/stories/components/color.stories.tsx
@@ -47,7 +47,7 @@ function Swatch({ color }: { color: string }) {
export function Colors() {
return (
-
+
Framework
@@ -65,7 +65,7 @@ export function Colors() {
))}
-
+
Content