diff --git a/apps/docs/content/docs/components/typography/blockquote.mdx b/apps/docs/content/docs/components/typography/blockquote.mdx index 96368b1e..21e21ac5 100644 --- a/apps/docs/content/docs/components/typography/blockquote.mdx +++ b/apps/docs/content/docs/components/typography/blockquote.mdx @@ -14,10 +14,11 @@ conveys quotation semantics to assistive technology. ## Typography -`Blockquote` accepts the `size`, `fontWeight`, and `lineClamp` props from -[Text](/components/typography/text). +`Blockquote` accepts the `typography`, `fontWeight`, and `lineClamp` props from +[Text](/components/typography/text). Pass `typography` when the quotation needs a different style +from the default. - + diff --git a/apps/docs/content/docs/components/typography/emoji.mdx b/apps/docs/content/docs/components/typography/emoji.mdx index ddff5663..ff058da2 100644 --- a/apps/docs/content/docs/components/typography/emoji.mdx +++ b/apps/docs/content/docs/components/typography/emoji.mdx @@ -7,15 +7,12 @@ props: path: packages/@luke-ui/react/src/emoji/index.tsx --- -`Emoji` renders an emoji with a reliable accessible name. It accepts the same typography props as -`Text`. +`Emoji` renders an emoji with a reliable accessible name. It inherits its size from the surrounding +text. -## Typography - -`Emoji` accepts every `Text` prop except `children` and `elementType`, including `size`, semantic -`color`, and `fontWeight`. +Wrap `Emoji` in [Text](/components/typography/text) when it needs a specific typography treatment. ## Accessibility diff --git a/apps/docs/content/docs/components/typography/heading.mdx b/apps/docs/content/docs/components/typography/heading.mdx index b2f5450e..4c8ac69c 100644 --- a/apps/docs/content/docs/components/typography/heading.mdx +++ b/apps/docs/content/docs/components/typography/heading.mdx @@ -29,14 +29,13 @@ children. Do not skip levels, such as an h2 followed by an h4. Someone who uses a screen reader navigates a page by heading level. -## Size and colour +## Typography -By default, `Heading` maps h1 through h6 to size steps `'800'` through `'300'` and uses the -`heading` font-weight role. Set `size` to change visual scale. It does not change the semantic -level. Use `'900'` for display headings. Set `color` for a semantic text colour, such as `info` for -a heading that needs to read as informational. +By default, `Heading` maps h1–h4 to `heading1`–`heading4`, h5 to `lead`, and h6 to `body`, and uses +the `heading` font-weight role. Set `typography` to change the visual style without changing the +semantic level. Use `display` for oversized marketing-style headings. - + ## Related components diff --git a/apps/docs/content/docs/components/typography/text.mdx b/apps/docs/content/docs/components/typography/text.mdx index 5f271b4c..3ca5f184 100644 --- a/apps/docs/content/docs/components/typography/text.mdx +++ b/apps/docs/content/docs/components/typography/text.mdx @@ -1,6 +1,6 @@ --- title: Text -description: Styled text with a coordinated type scale and semantic colour roles. +description: Styled text with semantic typography styles and colour roles. source: packages/@luke-ui/react/src/text props: - name: TextProps @@ -25,17 +25,15 @@ element types. Email address ``` -## Size +## Typography -Use `size` to set font size, line height, letter spacing, and Capsize trim as one coordinated step. -`'300'` is the default. Sizes `'200'` through `'400'` work well for running text. Smaller steps suit -compact UI labels, while larger steps are useful for display text that is not a semantic heading. +Pass `typography` to choose a style. `body` is the default. See [Typography](/docs/typography) for +the full style reference. - + -The active theme supplies the font family and values behind each `fontWeight` role. Use `body` for -text, `label` for controls, `heading` for prominent non-heading text, and `emphasis` when text needs -more weight. +Set `fontWeight` only when you need to override the style's weight. Themes expose `body`, `label`, +`heading`, and `emphasis` roles for that purpose. ## Alignment @@ -76,8 +74,8 @@ rendered text only, so avoid using uppercase styling as a substitute for a clear ## Related components Use [Heading](/components/typography/heading) for a section heading. It manages heading levels for -you. Use `Text` with a larger `size` and the `heading` or `emphasis` `fontWeight` for prominent -content that is not a heading. A large status value is one example. +you. Use `Text` with a larger style such as `display` or `heading1` for prominent content that is +not a heading. A large status value is one example. Use [Numeral](/components/typography/numeral) to format locale-aware numbers and [Emoji](/components/typography/emoji) for an emoji with a reliable accessible label. diff --git a/apps/docs/content/docs/docs/authoring-a-theme.mdx b/apps/docs/content/docs/docs/authoring-a-theme.mdx index 7e544af7..d035a0fa 100644 --- a/apps/docs/content/docs/docs/authoring-a-theme.mdx +++ b/apps/docs/content/docs/docs/authoring-a-theme.mdx @@ -26,8 +26,8 @@ Each colour accepts one string, adapted independently for light and dark, or an `{ light, dark }` pair. Omit either side to fall back to that role's default. Luke UI supplies accessible mode-specific defaults for every optional colour. `radius` is a generative `base` and `multiplier` scale with explicit per-step overrides. `depth` and `actionControlFinish` are optional -and deep-partial per mode over curated, extremely-subtle defaults. The type scale and motion values -are source-owned and are not authored. +and deep-partial per mode over curated, extremely-subtle defaults. The typography styles and motion +values are source-owned and are not authored. diff --git a/apps/docs/content/docs/docs/layout.mdx b/apps/docs/content/docs/docs/layout.mdx index aa42b8f7..aba17d1b 100644 --- a/apps/docs/content/docs/docs/layout.mdx +++ b/apps/docs/content/docs/docs/layout.mdx @@ -70,7 +70,7 @@ in both writing directions without a second layout rule. The layout APIs exclude semantic colour, typography, and interaction states. Choose a component API for those concerns. When a custom element needs a visual token, use `vars` from `@luke-ui/react/theme`. Semantic values follow the active identity and colour mode, while Luke UI -defines its spacing and type steps in source. +defines its spacing scale and typography styles in source. ## Continue learning diff --git a/apps/docs/content/docs/docs/typography.mdx b/apps/docs/content/docs/docs/typography.mdx index 8c58bc04..0fffea4a 100644 --- a/apps/docs/content/docs/docs/typography.mdx +++ b/apps/docs/content/docs/docs/typography.mdx @@ -1,54 +1,61 @@ --- title: Typography -description: Use Luke UI's coordinated type scale through semantic typography components. +description: Use Luke UI's semantic typography styles through typography components. --- -Luke UI defines nine fixed type steps. Each step combines font size, line height, letter spacing, -and Capsize trim. The active theme supplies the font family and semantic weight roles. +Luke UI defines semantic typography styles. Each style is a complete treatment: font family, size, +weight, line height, letter spacing, and Capsize trim. ## Base components Use `Text` for body copy and other non-heading content. Use `Heading` for document and section -headings. Heading manages semantic levels and selects a matching type step by default. +headings. Heading manages semantic levels and selects a matching typography style by default. -Use `Numeral` for locale-aware values. It inherits the type scale. It formats currencies, +Use `Numeral` for locale-aware values. It inherits the typography styles. It formats currencies, percentages, units, and compact values. Use `Emoji` when an emoji needs a reliable accessible label. -## Type scale +## Typography styles -`Text` and `Heading` use the steps `100` through `900`. Each step has a fixed font size, line -height, and letter spacing. A theme recomputes the trim values for its font family. +`Text` and `Heading` use these styles, from smallest to largest: -The specimen keeps each step's font size, line height, letter spacing, and trim together. +| Style | Intended use | +| --------------------- | ------------------------------------------------------------------------- | +| `caption` | Ancillary text at the smallest size | +| `support` | Secondary UI copy at label size with body weight — messages and list text | +| `label` | Terse UI and control text at the same size with label weight | +| `body` | Normal body copy | +| `lead` | Larger introductory body copy | +| `heading4`–`heading1` | Visual heading hierarchy | +| `display` | Oversized editorial or marketing moments | - + -Text defaults to `300`. Heading selects a size from its level, and accepts `size` when the visual -hierarchy needs a deliberate override. - -Set `size` when the visual treatment must differ from the component default. Keep the correct -heading level when you change its size. +`Text` defaults to `body`. `Heading` selects a style from its level, and accepts `typography` when +the visual hierarchy needs a deliberate override. An `h2` can use `heading3` visually without +changing its semantic level. There is no `heading5` or `heading6` style; those HTML levels reuse +`lead` and `body` with heading weight. ## Capsize trims -Each step includes Capsize trim values for the active font family. A line box normally reserves +Each style includes Capsize trim values for the active font family. A line box normally reserves space above and below its letters. Trim removes that extra space from a block text element. ## Weight and colour -Themes define the `body`, `label`, `heading`, and `emphasis` weight roles. Use the role that -describes the content, instead of a literal numeric weight. Text also accepts semantic `color` -values such as `primary`, `secondary`, `accent`, `info`, `success`, `warning`, and `danger`. +Each typography style already carries its weight. Pass `fontWeight` only when you need a different +role. Themes expose `body`, `label`, `heading`, and `emphasis` for that purpose, and for `Strong`. +Text also accepts semantic `color` values such as `primary`, `secondary`, `accent`, `info`, +`success`, `warning`, and `danger`. ```tsx - + Last updated 12 minutes ago ``` Applications load any font files required by a custom theme. The theme then assigns that typeface to -the shared scale and weight roles. Luke UI computes trim values from that font's metrics. +the shared styles and weight roles. Luke UI computes trim values from that font's metrics. diff --git a/apps/docs/src/components/components-index.tsx b/apps/docs/src/components/components-index.tsx index 24d93621..956faf1f 100644 --- a/apps/docs/src/components/components-index.tsx +++ b/apps/docs/src/components/components-index.tsx @@ -37,7 +37,7 @@ function CategoryGroup({ group }: { group: ComponentIndexGroup }) { gap="400" marginBlockStart="400" > - {group.title} + {group.title} {group.entries.map((entry) => ( diff --git a/apps/docs/src/components/not-found.tsx b/apps/docs/src/components/not-found.tsx index acfead6f..af7a5ca7 100644 --- a/apps/docs/src/components/not-found.tsx +++ b/apps/docs/src/components/not-found.tsx @@ -9,7 +9,7 @@ export function NotFound() { <>
- + 404 Page Not Found diff --git a/apps/docs/src/examples/blockquote/font-weight.tsx b/apps/docs/src/examples/blockquote/font-weight.tsx index 5c8264b5..4ca0d41e 100644 --- a/apps/docs/src/examples/blockquote/font-weight.tsx +++ b/apps/docs/src/examples/blockquote/font-weight.tsx @@ -1,25 +1,5 @@ import { Blockquote } from '@luke-ui/react/blockquote'; -import { Box } from '@luke-ui/react/box'; export default () => { - return ( - -
- Perfect typography is certainly the most elusive of all arts. Sculpture in stone alone comes - near it in obstinacy. -
-
- Perfect typography is certainly the most elusive of all arts. Sculpture in stone alone comes - near it in obstinacy. -
-
- Perfect typography is certainly the most elusive of all arts. Sculpture in stone alone comes - near it in obstinacy. -
-
- Perfect typography is certainly the most elusive of all arts. Sculpture in stone alone comes - near it in obstinacy. -
-
- ); + return
Sphinx of black quartz, judge my vow
; }; diff --git a/apps/docs/src/examples/blockquote/size.tsx b/apps/docs/src/examples/blockquote/size.tsx deleted file mode 100644 index 27421231..00000000 --- a/apps/docs/src/examples/blockquote/size.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Blockquote } from '@luke-ui/react/blockquote'; -import { Box } from '@luke-ui/react/box'; - -const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; - -export default () => { - return ( - - {sizes.map((size) => ( -
- Perfect typography is certainly the most elusive of all arts. Sculpture in stone alone - comes near it in obstinacy. -
- ))} -
- ); -}; diff --git a/apps/docs/src/examples/blockquote/typography.tsx b/apps/docs/src/examples/blockquote/typography.tsx new file mode 100644 index 00000000..5bd24d27 --- /dev/null +++ b/apps/docs/src/examples/blockquote/typography.tsx @@ -0,0 +1,5 @@ +import { Blockquote } from '@luke-ui/react/blockquote'; + +export default () => { + return
Sphinx of black quartz, judge my vow
; +}; diff --git a/apps/docs/src/examples/checkbox/first-line-alignment.tsx b/apps/docs/src/examples/checkbox/first-line-alignment.tsx index 49512a07..265f1646 100644 --- a/apps/docs/src/examples/checkbox/first-line-alignment.tsx +++ b/apps/docs/src/examples/checkbox/first-line-alignment.tsx @@ -5,10 +5,10 @@ import { Text } from '@luke-ui/react/text'; export default () => { return ( - + A longer label keeps its control aligned when it wraps. - + Larger text keeps the same first-line alignment when it wraps. diff --git a/apps/docs/src/examples/emoji/basic.tsx b/apps/docs/src/examples/emoji/basic.tsx index 31af56b7..05bc7bf6 100644 --- a/apps/docs/src/examples/emoji/basic.tsx +++ b/apps/docs/src/examples/emoji/basic.tsx @@ -1,14 +1,10 @@ -import { Box } from '@luke-ui/react/box'; import { Emoji } from '@luke-ui/react/emoji'; import { Text } from '@luke-ui/react/text'; export default () => { return ( - - - Deployment complete - - - + + Status: + ); }; diff --git a/apps/docs/src/examples/heading/typography.tsx b/apps/docs/src/examples/heading/typography.tsx index 423723b3..b2d9bbd9 100644 --- a/apps/docs/src/examples/heading/typography.tsx +++ b/apps/docs/src/examples/heading/typography.tsx @@ -1,18 +1,9 @@ -import { Box } from '@luke-ui/react/box'; import { Heading } from '@luke-ui/react/heading'; export default () => { return ( - - - The quick brown fox jumps over the lazy dog - - - The quick brown fox jumps over the lazy dog - - - The quick brown fox jumps over the lazy dog - - + + Account overview + ); }; diff --git a/apps/docs/src/examples/loading-skeleton/provider.tsx b/apps/docs/src/examples/loading-skeleton/provider.tsx index 2be0a4fd..9c4fcc80 100644 --- a/apps/docs/src/examples/loading-skeleton/provider.tsx +++ b/apps/docs/src/examples/loading-skeleton/provider.tsx @@ -12,7 +12,7 @@ export default () => { - + No local prop @@ -20,7 +20,7 @@ export default () => { - + isLoading @@ -28,7 +28,7 @@ export default () => { - + {'isLoading={false}'} diff --git a/apps/docs/src/examples/overview/concentric-radius.tsx b/apps/docs/src/examples/overview/concentric-radius.tsx index c070e662..eac878af 100644 --- a/apps/docs/src/examples/overview/concentric-radius.tsx +++ b/apps/docs/src/examples/overview/concentric-radius.tsx @@ -24,7 +24,7 @@ export default () => { }} /> - Outer radius from inner radius + gap + Outer radius from inner radius + gap ); }; diff --git a/apps/docs/src/examples/overview/depth.tsx b/apps/docs/src/examples/overview/depth.tsx index d20f1dd6..e7081040 100644 --- a/apps/docs/src/examples/overview/depth.tsx +++ b/apps/docs/src/examples/overview/depth.tsx @@ -24,7 +24,7 @@ export default () => { boxShadow: depth, }} /> - + {name} diff --git a/apps/docs/src/examples/overview/radius-roles.tsx b/apps/docs/src/examples/overview/radius-roles.tsx index 2f549609..9cf4d812 100644 --- a/apps/docs/src/examples/overview/radius-roles.tsx +++ b/apps/docs/src/examples/overview/radius-roles.tsx @@ -34,7 +34,7 @@ export default () => { borderRadius: role.value, }} /> - + {role.label} diff --git a/apps/docs/src/examples/overview/role-colours.tsx b/apps/docs/src/examples/overview/role-colours.tsx index d104a9f3..0f44b426 100644 --- a/apps/docs/src/examples/overview/role-colours.tsx +++ b/apps/docs/src/examples/overview/role-colours.tsx @@ -51,7 +51,7 @@ export default () => { borderRadius: vars.radius.surface, }} > - + {mode === 'light' ? 'Light mode' : 'Dark mode'} { { > {spaceScale.map(([step, value]) => ( - + {step} ({value}) { + return Review your account details.; +}; diff --git a/apps/docs/src/examples/text/numeric-glyphs.tsx b/apps/docs/src/examples/text/numeric-glyphs.tsx index f2bbc000..b7a223fe 100644 --- a/apps/docs/src/examples/text/numeric-glyphs.tsx +++ b/apps/docs/src/examples/text/numeric-glyphs.tsx @@ -19,7 +19,7 @@ export default () => { > {variants.map((variant) => ( - + {variant.label} diff --git a/apps/docs/src/examples/text/trim.tsx b/apps/docs/src/examples/text/trim.tsx index b6bc2f42..4601f072 100644 --- a/apps/docs/src/examples/text/trim.tsx +++ b/apps/docs/src/examples/text/trim.tsx @@ -18,7 +18,7 @@ export default () => { Trim text - + Aa diff --git a/apps/docs/src/examples/text/typography.tsx b/apps/docs/src/examples/text/typography.tsx index aeb22817..c4f24ad3 100644 --- a/apps/docs/src/examples/text/typography.tsx +++ b/apps/docs/src/examples/text/typography.tsx @@ -1,28 +1,34 @@ import { Box } from '@luke-ui/react/box'; import { Text } from '@luke-ui/react/text'; -import { fontSizeSteps, vars } from '@luke-ui/react/theme'; +import { typeStyles, vars } from '@luke-ui/react/theme'; +import { Fragment } from 'react'; export default () => { return ( - {fontSizeSteps.map((size) => ( - - - {size} - - - - Aa + + {typeStyles.map((typography) => ( + + + {typography} - - - ))} + + + Aa + + + + ))} + ); }; diff --git a/apps/docs/src/lib/token-purpose-groups.ts b/apps/docs/src/lib/token-purpose-groups.ts index 1db18273..207cdfe2 100644 --- a/apps/docs/src/lib/token-purpose-groups.ts +++ b/apps/docs/src/lib/token-purpose-groups.ts @@ -52,7 +52,7 @@ const PURPOSE_DEFINITIONS = [ title: 'Roles', }, { - description: 'Type steps, font families, and weight roles.', + description: 'Type styles, font families, and weight roles.', id: 'typography', related: { label: 'Typography', splat: 'typography' }, showSamples: false, diff --git a/apps/docs/src/routes/index.tsx b/apps/docs/src/routes/index.tsx index 72134538..05fa185f 100644 --- a/apps/docs/src/routes/index.tsx +++ b/apps/docs/src/routes/index.tsx @@ -16,7 +16,7 @@ function Home() { <>
- + Introduction diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index b332df2f..8daec7d2 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -202,7 +202,7 @@ the code first. Exhaustive variant and state coverage belongs in the visual test kitchen sink, not in a docs example. See [TESTING.md](TESTING.md). -A reference page is the exception. A token, type scale, or icon page enumerates on purpose, because +A reference page is the exception. A token, typography, or icon page enumerates on purpose, because the enumeration is the content. ### Prose around an example diff --git a/docs/STYLING.md b/docs/STYLING.md index c7e60a3f..567789b8 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -45,7 +45,7 @@ with no class and no JS required. Neither step injects styles at runtime. device screen width, not the viewport width, to decide when a combobox switches to it. - `styles/`: layout utilities, most exported from `@luke-ui/react/styles`. - `theme/contract.ts`: the semantic token tree, its `--luke-*` variable naming, and the source-owned - `fontSizeSteps` typography step keys. + `typeStyles` typography keys. - `theme/contract.css.ts`: the typed `vars` contract, built by walking the semantic token tree directly so it stays source-owned and free of styling-engine types. - `theme/define-theme.ts`: the public `defineTheme(input)` authoring util, its typed `ThemeInput`, @@ -105,12 +105,13 @@ colour contract. Every role gets the same background, foreground, on-solid, and See [THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for the pipeline, the border and accent contrast policies, and what changed when this generator replaced the original per-token solver. -The semantic contract includes `font.100` through `font.900` size steps. Each step groups its font -size, line height, letter spacing, and per-font Capsize trims so components cannot combine unrelated -values. `font.family.body` is selected from the curated Inter, Apple System, or DM Sans metrics and -`buildTheme` computes the matching trims. `font.family.code` is a fixed neutral monospace stack for -code and keyboard input; it is not a brand-family or Capsize choice. Icon sizes carry forward the -`xsmall`, `small`, `medium`, and `large` scale at 16px, 20px, 24px, and 32px. +The semantic contract includes `font.caption` through `font.display` type styles. Each style groups +its font family, size, weight, line height, letter spacing, and per-font Capsize trims so components +cannot combine unrelated values. `font.family.body` is selected from the curated Inter, Apple +System, or DM Sans metrics and `buildTheme` computes the matching trims. `font.family.code` is a +fixed neutral monospace stack for code and keyboard input; it is not a brand-family or Capsize +choice. Icon sizes carry forward the `xsmall`, `small`, `medium`, and `large` scale at 16px, 20px, +24px, and 32px. Each colour mode authors the final composite `box-shadow` for `depth.recessed`, `depth.resting`, `depth.raised`, `depth.floating`, and `depth.overlay`. Components select a semantic depth and do not diff --git a/packages/@luke-ui/react/src/blockquote/index.tsx b/packages/@luke-ui/react/src/blockquote/index.tsx index 85d9a6ef..a3447273 100644 --- a/packages/@luke-ui/react/src/blockquote/index.tsx +++ b/packages/@luke-ui/react/src/blockquote/index.tsx @@ -18,7 +18,7 @@ export type BlockquoteProps = Prettify<_BlockquoteProps>; /** * Block-level quotation from another source, rendered as `
`. - * Composes `Text` for the type scale and semantic font-weight controls. + * Composes `Text` for typography styles and semantic font-weight controls. */ export function Blockquote(props: BlockquoteProps) { const { children, className, ...textProps } = props; diff --git a/packages/@luke-ui/react/src/checkbox/checkbox.visual.test.tsx b/packages/@luke-ui/react/src/checkbox/checkbox.visual.test.tsx index 32ac6b38..14df3bb4 100644 --- a/packages/@luke-ui/react/src/checkbox/checkbox.visual.test.tsx +++ b/packages/@luke-ui/react/src/checkbox/checkbox.visual.test.tsx @@ -10,6 +10,7 @@ import { Stack, } from '../test-utils/visual.js'; import { Text } from '../text/index.js'; +import { typeStyles } from '../theme/contract.js'; import { Checkbox } from './index.js'; test('kitchen sink', async () => { @@ -29,10 +30,10 @@ test('kitchen sink', async () => { Invalid - {(['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const).map((size) => ( - - - {size}: This label wraps to show that the control aligns with its first line. + {typeStyles.map((typography) => ( + + + {typography}: This label wraps to show that the control aligns with its first line. ))} diff --git a/packages/@luke-ui/react/src/emoji/emoji.browser.test.tsx b/packages/@luke-ui/react/src/emoji/emoji.browser.test.tsx new file mode 100644 index 00000000..e78ecb89 --- /dev/null +++ b/packages/@luke-ui/react/src/emoji/emoji.browser.test.tsx @@ -0,0 +1,18 @@ +import { expect, test } from 'vite-plus/test'; +import { render } from '../test-utils/render.js'; +import { Text } from '../text/index.js'; +import { Emoji } from './index.js'; + +test('inherits font size from surrounding Text', () => { + const { locator } = render( + + Hello + , + ); + + const emoji = locator.getByRole('img', { name: 'Waving hand' }).element(); + const parent = emoji.parentElement; + if (!parent) throw new Error('expected a parent element'); + + expect(getComputedStyle(emoji).fontSize).toBe(getComputedStyle(parent).fontSize); +}); diff --git a/packages/@luke-ui/react/src/emoji/emoji.stories.tsx b/packages/@luke-ui/react/src/emoji/emoji.stories.tsx index 0ec33fc9..add990f8 100644 --- a/packages/@luke-ui/react/src/emoji/emoji.stories.tsx +++ b/packages/@luke-ui/react/src/emoji/emoji.stories.tsx @@ -10,12 +10,6 @@ const meta = preview.meta({ title: 'Typography/Emoji', }); -const rowStyle = { - alignItems: 'center', - display: 'flex', - gap: '0.5rem', -} as const satisfies CSSProperties; - const stackStyle = { display: 'flex', flexDirection: 'column', @@ -34,21 +28,31 @@ const baseArgs = { export const Default = meta.story({ args: baseArgs, render: (props) => ( -
- - Release deployed successfully. -
+ + Release deployed successfully + ), }); -export const Size = meta.story({ +/** + * Wrap `Emoji` in `Text` when it needs a specific typography treatment. + */ +export const Inheritance = meta.story({ args: baseArgs, render: (props) => (
- - - - + + Hello + + + Hello + + + Hello + + + Hello +
), }); diff --git a/packages/@luke-ui/react/src/emoji/emoji.visual.test.tsx b/packages/@luke-ui/react/src/emoji/emoji.visual.test.tsx index d0ceb12f..c2dc487a 100644 --- a/packages/@luke-ui/react/src/emoji/emoji.visual.test.tsx +++ b/packages/@luke-ui/react/src/emoji/emoji.visual.test.tsx @@ -1,35 +1,38 @@ import type { CSSProperties } from 'react'; import { test } from 'vite-plus/test'; import { render, visualAppearances } from '../test-utils/render.js'; -import { captureVisualAppearance, Stack, variantValuesFor } from '../test-utils/visual.js'; +import { captureVisualAppearance, Stack } from '../test-utils/visual.js'; +import { Text } from '../text/index.js'; import { Emoji } from './index.js'; -const rowStyle = { - alignItems: 'center', +const stackStyle = { display: 'flex', + flexDirection: 'column', gap: '1rem', } satisfies CSSProperties; -const sizes = variantValuesFor()(['100', '300', '500', '700', '900']); - for (const appearance of visualAppearances) { - test(`sizes and colours: ${appearance.theme} ${appearance.mode}`, async () => { + test(`inherits surrounding typography: ${appearance.theme} ${appearance.mode}`, async () => { const { locator } = render( -
- {sizes.map((size) => ( - - ))} -
-
- - - +
+ + Hello + + + Hello + + + Hello + + + Hello +
, { appearance }, ); - await captureVisualAppearance(locator, 'emoji/sizes-colors', appearance); + await captureVisualAppearance(locator, 'emoji/inheritance', appearance); }); } diff --git a/packages/@luke-ui/react/src/emoji/index.tsx b/packages/@luke-ui/react/src/emoji/index.tsx index 6798a4fa..a05d1bc7 100644 --- a/packages/@luke-ui/react/src/emoji/index.tsx +++ b/packages/@luke-ui/react/src/emoji/index.tsx @@ -1,9 +1,12 @@ -import type { TextProps } from '../text/index.js'; import { Text } from '../text/index.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { Prettify } from '../types/prettify.js'; -type _EmojiOmit = DistributiveOmit; +type _EmojiOmit = DistributiveOmit< + React.ComponentProps<'span'>, + 'aria-label' | 'children' | 'color' | 'role' +>; + interface _EmojiProps extends _EmojiOmit { /** Emoji character to render. */ emoji: string; @@ -18,12 +21,15 @@ interface _EmojiProps extends _EmojiOmit { */ export type EmojiProps = Prettify<_EmojiProps>; -/** Accessible emoji output with the same typography props as `Text`. */ +/** + * Accessible emoji that inherits surrounding font styles. Wrap it in `Text` when it needs a + * specific typography treatment. + */ export function Emoji(props: EmojiProps) { - const { emoji, label, ...textProps } = props; + const { className, emoji, label, ...elementProps } = props; return ( - + {emoji} ); diff --git a/packages/@luke-ui/react/src/heading/heading.browser.test.tsx b/packages/@luke-ui/react/src/heading/heading.browser.test.tsx new file mode 100644 index 00000000..a5f51971 --- /dev/null +++ b/packages/@luke-ui/react/src/heading/heading.browser.test.tsx @@ -0,0 +1,26 @@ +import { expect, test } from 'vite-plus/test'; +import { render } from '../test-utils/render.js'; +import { Text } from '../text/index.js'; +import { Heading } from './index.js'; + +test('keeps semantic heading level independent of visual type style', async () => { + const { locator } = render( + <> + + Styled as heading3 + + + Reference heading3 + + Default h2 + , + ); + + const styled = locator.getByRole('heading', { level: 2, name: 'Styled as heading3' }).element(); + const reference = locator.getByText('Reference heading3').element(); + const defaultH2 = locator.getByRole('heading', { level: 2, name: 'Default h2' }).element(); + + expect(styled.tagName).toBe('H2'); + expect(getComputedStyle(styled).fontSize).toBe(getComputedStyle(reference).fontSize); + expect(getComputedStyle(styled).fontSize).not.toBe(getComputedStyle(defaultH2).fontSize); +}); diff --git a/packages/@luke-ui/react/src/heading/heading.stories.tsx b/packages/@luke-ui/react/src/heading/heading.stories.tsx index 2f07a5b1..a64ee971 100644 --- a/packages/@luke-ui/react/src/heading/heading.stories.tsx +++ b/packages/@luke-ui/react/src/heading/heading.stories.tsx @@ -19,7 +19,7 @@ const stackStyle = { const levels = [1, 2, 3, 4, 5, 6] as const satisfies Array>; /** - * Use `level` to define heading hierarchy and default heading size. + * Use `level` to define heading hierarchy and default typography. */ export const Level = meta.story({ render: (props) => ( @@ -54,20 +54,20 @@ export const ElementType = meta.story({ }); /** - * Override visual heading size with `size` while preserving semantic level. + * Override visual typography with `typography` while preserving semantic level. */ -export const SizeOverride = meta.story({ +export const TypographyOverride = meta.story({ args: { level: 2, }, render: (props) => (
Level 2 semantic and visual - - Level 2 semantic, size 500 + + Level 2 semantic, heading4 typography - - Level 2 semantic, display size 900 + + Level 2 semantic, display typography
), diff --git a/packages/@luke-ui/react/src/heading/heading.visual.test.tsx b/packages/@luke-ui/react/src/heading/heading.visual.test.tsx index 2dfbf427..f4520e1b 100644 --- a/packages/@luke-ui/react/src/heading/heading.visual.test.tsx +++ b/packages/@luke-ui/react/src/heading/heading.visual.test.tsx @@ -19,7 +19,7 @@ for (const appearance of visualAppearances) { Level {level} heading ))} - + Display heading , diff --git a/packages/@luke-ui/react/src/heading/index.tsx b/packages/@luke-ui/react/src/heading/index.tsx index 1e7ce8c4..38673603 100644 --- a/packages/@luke-ui/react/src/heading/index.tsx +++ b/packages/@luke-ui/react/src/heading/index.tsx @@ -20,18 +20,19 @@ interface _HeadingProps extends TextProps { */ export type HeadingProps = Prettify<_HeadingProps>; -const sizeByLevel = { - 1: '800', - 2: '700', - 3: '600', - 4: '500', - 5: '400', - 6: '300', -} as const; +const typographyByLevel = { + 1: 'heading1', + 2: 'heading2', + 3: 'heading3', + 4: 'heading4', + // h5/h6 stay in the outline without inventing heading5/heading6 type styles. + 5: 'lead', + 6: 'body', +} as const satisfies Record>; /** Semantic heading with automatic level composition and level-based typography. */ export function Heading(props: HeadingProps) { - const { elementType, fontWeight = 'heading', level, size, ...textProps } = props; + const { elementType, fontWeight = 'heading', level, typography, ...textProps } = props; const baseProps: Pick = level === undefined ? {} : { base: level }; return ( @@ -41,7 +42,7 @@ export function Heading(props: HeadingProps) { diff --git a/packages/@luke-ui/react/src/numeral/numeral.visual.test.tsx b/packages/@luke-ui/react/src/numeral/numeral.visual.test.tsx index b3c0d04f..bb967c54 100644 --- a/packages/@luke-ui/react/src/numeral/numeral.visual.test.tsx +++ b/packages/@luke-ui/react/src/numeral/numeral.visual.test.tsx @@ -27,7 +27,7 @@ for (const appearance of visualAppearances) { diff --git a/packages/@luke-ui/react/src/recipes/button.css.ts b/packages/@luke-ui/react/src/recipes/button.css.ts index 9194db11..fd4cd20c 100644 --- a/packages/@luke-ui/react/src/recipes/button.css.ts +++ b/packages/@luke-ui/react/src/recipes/button.css.ts @@ -1,5 +1,6 @@ import { styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; +import { FONT_METRIC_SCALE } from '../theme/font-metric-scale.js'; import type { RecipeSelection } from './recipe.js'; import { recipe } from './recipe.js'; @@ -53,8 +54,8 @@ const base = styleInLayer('recipes', { fontWeight: vars.font.weight.label, isolation: 'isolate', justifyContent: 'center', - letterSpacing: vars.font[200].letterSpacing, - lineHeight: vars.font[200].lineHeight, + letterSpacing: FONT_METRIC_SCALE[14].letterSpacing, + lineHeight: FONT_METRIC_SCALE[14].lineHeight, minBlockSize: vars.controlSize.minTarget, minInlineSize: vars.controlSize.minTarget, position: 'relative', @@ -106,16 +107,16 @@ export const button = recipe({ size: { medium: { blockSize: vars.controlSize.medium, - fontSize: vars.font[200].fontSize, + fontSize: FONT_METRIC_SCALE[14].fontSize, gap: vars.space[200], paddingInline: vars.space[400], }, small: { blockSize: vars.controlSize.small, - fontSize: vars.font[100].fontSize, + fontSize: FONT_METRIC_SCALE[12].fontSize, gap: vars.space[100], - letterSpacing: vars.font[100].letterSpacing, - lineHeight: vars.font[100].lineHeight, + letterSpacing: FONT_METRIC_SCALE[12].letterSpacing, + lineHeight: FONT_METRIC_SCALE[12].lineHeight, paddingInline: vars.space[300], }, }, diff --git a/packages/@luke-ui/react/src/recipes/checkbox.css.ts b/packages/@luke-ui/react/src/recipes/checkbox.css.ts index 4f3f14d4..bab6ba25 100644 --- a/packages/@luke-ui/react/src/recipes/checkbox.css.ts +++ b/packages/@luke-ui/react/src/recipes/checkbox.css.ts @@ -1,6 +1,7 @@ import { createVar, fallbackVar } from '@vanilla-extract/css'; import { focusRing, restingFocusRing } from '../styles/focus-ring.js'; import { vars } from '../theme/contract.css.js'; +import { FONT_METRIC_SCALE } from '../theme/font-metric-scale.js'; import { fieldMessageIcon, fieldMessageIndent } from './field.css.js'; import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; import { recipe } from './recipe.js'; @@ -189,7 +190,7 @@ const checkboxConfig = { large: { root: { vars: { - [checkboxControlSize]: vars.font[500].lineHeight, + [checkboxControlSize]: FONT_METRIC_SCALE[20].lineHeight, [checkboxGlyphSize]: vars.iconSize.small, [checkboxIndicatorSize]: vars.iconSize.medium, [fieldMessageIndent]: `calc(${checkboxControlSize} + ${vars.space[200]})`, @@ -199,7 +200,7 @@ const checkboxConfig = { medium: { root: { vars: { - [checkboxControlSize]: vars.font[300].lineHeight, + [checkboxControlSize]: FONT_METRIC_SCALE[16].lineHeight, [checkboxGlyphSize]: vars.iconSize.xsmall, [checkboxIndicatorSize]: vars.iconSize.small, [fieldMessageIndent]: `calc(${checkboxControlSize} + ${vars.space[200]})`, @@ -210,7 +211,7 @@ const checkboxConfig = { root: { vars: { [checkboxControlSize]: vars.iconSize.small, - [checkboxGlyphSize]: vars.font[100].fontSize, + [checkboxGlyphSize]: FONT_METRIC_SCALE[12].fontSize, [checkboxIndicatorSize]: vars.iconSize.xsmall, [fieldMessageIndent]: `calc(${checkboxControlSize} + ${vars.space[200]})`, }, diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index 7ddab995..b8133d76 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -5,6 +5,7 @@ import { focusRing, restingFocusRing } from '../styles/focus-ring.js'; import { styleInLayer } from '../styles/layered-style.css.js'; import { overlayEnterTransition, overlayExitTransition } from '../styles/overlay-motion.js'; import { vars } from '../theme/contract.css.js'; +import { FONT_METRIC_SCALE } from '../theme/font-metric-scale.js'; import { composeInputStateSelectors, descendantDisabledSelector, @@ -163,8 +164,8 @@ const comboboxConfig = { fontFamily: vars.font.family.body, inlineSize: '100%', isolation: 'isolate', - letterSpacing: vars.font[300].letterSpacing, - lineHeight: vars.font[300].lineHeight, + letterSpacing: FONT_METRIC_SCALE[16].letterSpacing, + lineHeight: FONT_METRIC_SCALE[16].lineHeight, minInlineSize: 0, ...restingFocusRing('0px'), overflow: 'visible', @@ -309,8 +310,7 @@ const comboboxConfig = { }, sectionHeading: { color: vars.color.text.secondary, - ...vars.font[200], - fontWeight: vars.font.weight.label, + ...vars.font.label, paddingBlockEnd: vars.space[100], paddingBlockStart: 0, paddingInline: vars.space[300], @@ -319,7 +319,7 @@ const comboboxConfig = { alignItems: 'center', color: vars.color.text.secondary, display: 'flex', - ...vars.font[200], + ...vars.font.support, justifyContent: 'center', paddingBlock: vars.space[600], paddingInline: vars.space[300], @@ -423,7 +423,7 @@ const comboboxConfig = { medium: { inputGroup: { blockSize: vars.controlSize.medium, - fontSize: vars.font[300].fontSize, + fontSize: FONT_METRIC_SCALE[16].fontSize, vars: { [comboboxErrorIconSize]: vars.iconSize[COMBOBOX_ICON_SIZE.medium] }, }, textInput: { @@ -443,7 +443,7 @@ const comboboxConfig = { paddingInlineStart: vars.space[300], }, item: { - ...vars.font[200], + ...vars.font.support, minBlockSize: vars.controlSize.medium, paddingBlock: vars.space[200], paddingInline: vars.space[300], @@ -452,7 +452,9 @@ const comboboxConfig = { small: { inputGroup: { blockSize: vars.controlSize.small, - ...vars.font[200], + fontSize: FONT_METRIC_SCALE[14].fontSize, + letterSpacing: FONT_METRIC_SCALE[14].letterSpacing, + lineHeight: FONT_METRIC_SCALE[14].lineHeight, vars: { [comboboxErrorIconSize]: vars.iconSize[COMBOBOX_ICON_SIZE.small] }, }, textInput: { @@ -472,7 +474,7 @@ const comboboxConfig = { paddingInlineStart: vars.space[200], }, item: { - ...vars.font[200], + ...vars.font.support, minBlockSize: vars.controlSize.small, paddingBlock: vars.space[100], paddingInline: vars.space[300], diff --git a/packages/@luke-ui/react/src/recipes/field.css.ts b/packages/@luke-ui/react/src/recipes/field.css.ts index 80887405..73c49cbf 100644 --- a/packages/@luke-ui/react/src/recipes/field.css.ts +++ b/packages/@luke-ui/react/src/recipes/field.css.ts @@ -44,8 +44,7 @@ const fieldConfig = { }, label: { color: vars.color.text.primary, - ...vars.font[200], - fontWeight: vars.font.weight.label, + ...vars.font.label, minInlineSize: 0, selectors: { @@ -55,7 +54,7 @@ const fieldConfig = { }, }, message: { - ...vars.font[200], + ...vars.font.support, minInlineSize: 0, paddingInlineStart: messageIndent, }, diff --git a/packages/@luke-ui/react/src/recipes/input-group.css.ts b/packages/@luke-ui/react/src/recipes/input-group.css.ts index 09abd6e0..5b6d8f87 100644 --- a/packages/@luke-ui/react/src/recipes/input-group.css.ts +++ b/packages/@luke-ui/react/src/recipes/input-group.css.ts @@ -1,5 +1,6 @@ import { focusRing, restingFocusRing } from '../styles/focus-ring.js'; import { vars } from '../theme/contract.css.js'; +import { FONT_METRIC_SCALE } from '../theme/font-metric-scale.js'; import { composeInputStateSelectors, descendantDisabledSelector, @@ -55,8 +56,8 @@ const inputGroupConfig = { fontFamily: vars.font.family.body, inlineSize: '100%', isolation: 'isolate', - letterSpacing: vars.font[300].letterSpacing, - lineHeight: vars.font[300].lineHeight, + letterSpacing: FONT_METRIC_SCALE[16].letterSpacing, + lineHeight: FONT_METRIC_SCALE[16].lineHeight, minInlineSize: 0, ...restingFocusRing('0px'), overflow: 'visible', @@ -201,7 +202,7 @@ const inputGroupConfig = { medium: { group: { blockSize: vars.controlSize.medium, - fontSize: vars.font[300].fontSize, + fontSize: FONT_METRIC_SCALE[16].fontSize, }, control: { blockSize: vars.controlSize.medium, @@ -209,12 +210,12 @@ const inputGroupConfig = { paddingInlineStart: vars.space[300], }, prefix: { - lineHeight: vars.font[300].lineHeight, + lineHeight: FONT_METRIC_SCALE[16].lineHeight, paddingInlineEnd: vars.space[300], paddingInlineStart: vars.space[300], }, suffix: { - lineHeight: vars.font[300].lineHeight, + lineHeight: FONT_METRIC_SCALE[16].lineHeight, paddingInlineEnd: vars.space[300], paddingInlineStart: vars.space[300], }, @@ -222,9 +223,9 @@ const inputGroupConfig = { small: { group: { blockSize: vars.controlSize.small, - fontSize: vars.font[200].fontSize, - letterSpacing: vars.font[200].letterSpacing, - lineHeight: vars.font[200].lineHeight, + fontSize: FONT_METRIC_SCALE[14].fontSize, + letterSpacing: FONT_METRIC_SCALE[14].letterSpacing, + lineHeight: FONT_METRIC_SCALE[14].lineHeight, }, control: { blockSize: vars.controlSize.small, @@ -232,12 +233,12 @@ const inputGroupConfig = { paddingInlineStart: vars.space[200], }, prefix: { - lineHeight: vars.font[200].lineHeight, + lineHeight: FONT_METRIC_SCALE[14].lineHeight, paddingInlineEnd: vars.space[200], paddingInlineStart: vars.space[200], }, suffix: { - lineHeight: vars.font[200].lineHeight, + lineHeight: FONT_METRIC_SCALE[14].lineHeight, paddingInlineEnd: vars.space[200], paddingInlineStart: vars.space[200], }, diff --git a/packages/@luke-ui/react/src/recipes/kbd.css.ts b/packages/@luke-ui/react/src/recipes/kbd.css.ts index f09d2306..13c7407f 100644 --- a/packages/@luke-ui/react/src/recipes/kbd.css.ts +++ b/packages/@luke-ui/react/src/recipes/kbd.css.ts @@ -1,5 +1,6 @@ import { styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; +import { FONT_METRIC_SCALE } from '../theme/font-metric-scale.js'; import type { RecipeSelection } from './recipe.js'; import { recipe } from './recipe.js'; @@ -13,7 +14,7 @@ const base = styleInLayer('recipes', { color: vars.color.text.primary, display: 'inline-flex', fontFamily: vars.font.family.code, - fontSize: vars.font[100].fontSize, + fontSize: FONT_METRIC_SCALE[12].fontSize, fontWeight: vars.font.weight.body, inlineSize: 'fit-content', lineHeight: 1, diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index 54499f19..f81057bd 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -2,8 +2,8 @@ import type { ComplexStyleRule } from '@vanilla-extract/css'; import { createVar } from '@vanilla-extract/css'; import { styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; -import type { FontSizeStep } from '../theme/contract.js'; -import { fontSizeSteps } from '../theme/contract.js'; +import type { FontWeightRole, TypeStyle } from '../theme/contract.js'; +import { fontWeightRoles, typeStyles } from '../theme/contract.js'; import type { RecipeSelection } from './recipe.js'; import { recipe } from './recipe.js'; import { visuallyHiddenStyle } from './visually-hidden.css.js'; @@ -55,26 +55,25 @@ const colorVariants = { warning: { color: vars.color.foreground.warning.rest }, } as const; -const weightVariants = { - body: { fontWeight: vars.font.weight.body }, - emphasis: { fontWeight: vars.font.weight.emphasis }, - heading: { fontWeight: vars.font.weight.heading }, - label: { fontWeight: vars.font.weight.label }, -} as const; +const weightVariants = Object.fromEntries( + fontWeightRoles.map((fontWeight) => [fontWeight, { fontWeight: vars.font.weight[fontWeight] }]), +) as Record; -const sizeVariants = Object.fromEntries( - fontSizeSteps.map((size) => [ - size, +const typographyVariants = Object.fromEntries( + typeStyles.map((typography) => [ + typography, { - fontSize: vars.font[size].fontSize, - letterSpacing: vars.font[size].letterSpacing, - lineHeight: vars.font[size].lineHeight, - vars: { [textLineHeight]: vars.font[size].lineHeight }, + fontFamily: vars.font[typography].fontFamily, + fontSize: vars.font[typography].fontSize, + letterSpacing: vars.font[typography].letterSpacing, + lineHeight: vars.font[typography].lineHeight, + vars: { [textLineHeight]: vars.font[typography].lineHeight }, }, ]), ) as Record< - FontSizeStep, + TypeStyle, { + fontFamily: string; fontSize: string; letterSpacing: string; lineHeight: string; @@ -82,15 +81,15 @@ const sizeVariants = Object.fromEntries( } >; -const sizeStepCompoundVariants = fontSizeSteps.map((size) => { - const { baselineTrim, capHeightTrim, fontSize, lineHeight } = vars.font[size]; +const typographyCompoundVariants = typeStyles.map((typography) => { + const { baselineTrim, capHeightTrim, fontSize, lineHeight } = vars.font[typography]; return { style: createLayeredTextStyle({ baselineTrim, capHeightTrim, fontSize, lineHeight }), // `shouldInheritFont: true` asks the browser to resolve font size and line height from - // the surrounding context, not this step's Capsize metrics. Without this condition, the + // the surrounding context, not this style's Capsize metrics. Without this condition, the // compound's own `fontSize`/`lineHeight` always wins over the plain `shouldInheritFont` // variant, because vanilla-extract applies compound variants after simple ones. - variants: { shouldDisableTrim: false, shouldInheritFont: false, size } as const, + variants: { shouldDisableTrim: false, shouldInheritFont: false, typography } as const, }; }); @@ -126,19 +125,18 @@ function createLayeredTextStyle({ /** Vanilla-extract recipe for the `Text` primitive's styles. */ export const text = recipe({ base, - compoundVariants: sizeStepCompoundVariants, + compoundVariants: typographyCompoundVariants, defaultVariants: { fontVariantNumeric: 'unset', isVisuallyHidden: false, lineClamp: false, shouldDisableTrim: false, shouldInheritFont: false, - size: '300', textAlign: 'start', textDecoration: 'none', textTransform: 'none', textWrap: 'unset', - fontWeight: 'body', + typography: 'body', }, variants: { fontVariantNumeric: { @@ -154,7 +152,6 @@ export const text = recipe({ }, lineClamp: lineClampVariants, shouldDisableTrim: { false: {}, true: {} }, - size: sizeVariants, textAlign: { center: { textAlign: 'center' }, end: { textAlign: 'end' }, @@ -178,6 +175,7 @@ export const text = recipe({ pretty: { textWrap: 'pretty' }, unset: {}, }, + typography: typographyVariants, fontWeight: weightVariants, shouldInheritFont: { false: {}, diff --git a/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx b/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx index 16e51555..0f74dad1 100644 --- a/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx +++ b/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx @@ -26,8 +26,8 @@ test('applies the public reset and theme contracts', () => { const themeStyles = getComputedStyle(container); expect(getComputedStyle(resetTarget).boxSizing).toBe('border-box'); expect(themeStyles.color).toBe(themeStyles.getPropertyValue('--luke-color-text-primary')); - expect(themeStyles.fontSize).toBe(themeStyles.getPropertyValue('--luke-font-300-font-size')); - expect(themeStyles.lineHeight).toBe(themeStyles.getPropertyValue('--luke-font-300-line-height')); + expect(themeStyles.fontSize).toBe(themeStyles.getPropertyValue('--luke-font-body-font-size')); + expect(themeStyles.lineHeight).toBe(themeStyles.getPropertyValue('--luke-font-body-line-height')); }); function mountFixture() { diff --git a/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts b/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts index 56458493..b6558c1f 100644 --- a/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts +++ b/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts @@ -3,12 +3,12 @@ import type { AtRule, Root, Rule } from 'postcss'; import { parse } from 'postcss'; import selectorParser from 'postcss-selector-parser'; import { expect, test } from 'vite-plus/test'; -import type { FontSizeStep } from '../theme/contract.js'; -import { fontSizeSteps } from '../theme/contract.js'; +import type { TypeStyle } from '../theme/contract.js'; +import { typeStyles } from '../theme/contract.js'; const retainedLayerNames = ['reset', 'theme', 'recipes', 'utilities'] as const; const retainedLayerNameSet = new Set(retainedLayerNames); -type TextClassesBySize = Record>; +type TextClassesByTypography = Record>; const numericLineClampVariants = [2, 3, 4, 5] as const; type NumericLineClampVariant = (typeof numericLineClampVariants)[number]; type LineClampClasses = { @@ -21,9 +21,9 @@ test('builds the public stylesheet with the retained layer contract', async () = const recipes = await import('@luke-ui/react/recipes'); const styles = await import('@luke-ui/react/styles'); const recipeClasses = [...recipes.icon().split(' '), recipes.loadingSkeletonClassName]; - const textClassesBySize = Object.fromEntries( - fontSizeSteps.map((size) => [size, recipes.text({ size }).split(' ')]), - ) as TextClassesBySize; + const textClassesByTypography = Object.fromEntries( + typeStyles.map((typography) => [typography, recipes.text({ typography }).split(' ')]), + ) as TextClassesByTypography; const utilityClasses = styles.createSprinkles({ display: 'grid' }).className?.split(' ') ?? []; const lineClampClasses: LineClampClasses = { numeric: Object.fromEntries( @@ -39,7 +39,7 @@ test('builds the public stylesheet with the retained layer contract', async () = return assertStylesheetContract(stylesheet, { lineClampClasses, recipeClasses, - textClassesBySize, + textClassesByTypography, utilityClasses, }); }).not.toThrow(); @@ -119,12 +119,12 @@ function assertStylesheetContract( { lineClampClasses, recipeClasses, - textClassesBySize, + textClassesByTypography, utilityClasses, }: { lineClampClasses?: LineClampClasses; recipeClasses: Array; - textClassesBySize?: TextClassesBySize; + textClassesByTypography?: TextClassesByTypography; utilityClasses: Array; }, ): void { @@ -136,11 +136,18 @@ function assertStylesheetContract( assertStableSelectors(root); assertSentinel(root, 'luke-ui-reset', 'reset', 'box-sizing', 'border-box'); assertSentinel(root, 'luke-ui-theme', 'theme', 'color', 'var(--luke-color-text-primary)'); - assertSentinel(root, 'luke-ui-theme', 'theme', 'font-family', 'var(--luke-font-family-body)'); + assertSentinel( + root, + 'luke-ui-theme', + 'theme', + 'font-family', + 'var(--luke-font-body-font-family)', + ); + assertSentinel(root, 'luke-ui-theme', 'theme', 'font-size', 'var(--luke-font-body-font-size)'); for (const className of recipeClasses) assertClassOwnership(root, className, 'recipes'); for (const className of utilityClasses) assertClassOwnership(root, className, 'utilities'); - if (textClassesBySize) assertTextTrimOwnership(root, textClassesBySize); + if (textClassesByTypography) assertTextTrimOwnership(root, textClassesByTypography); if (lineClampClasses) assertLineClampOwnership(root, lineClampClasses); } @@ -226,20 +233,25 @@ function assertClassOwnership(root: Root, className: string, layerName: string): expect(rules.some((rule) => rule.nodes.some((node) => node.type === 'decl'))).toBe(true); } -function assertTextTrimOwnership(root: Root, textClassesBySize: TextClassesBySize): void { - for (const size of fontSizeSteps) { - const rules = textClassesBySize[size].flatMap((className) => getRulesForClass(root, className)); +function assertTextTrimOwnership( + root: Root, + textClassesByTypography: TextClassesByTypography, +): void { + for (const typography of typeStyles) { + const rules = textClassesByTypography[typography].flatMap((className) => + getRulesForClass(root, className), + ); assertPseudoDeclaration( rules, '::before', 'margin-block-end', - `var(--luke-font-${size}-cap-height-trim)`, + `var(--luke-font-${typography}-cap-height-trim)`, ); assertPseudoDeclaration( rules, '::after', 'margin-block-start', - `var(--luke-font-${size}-baseline-trim)`, + `var(--luke-font-${typography}-baseline-trim)`, ); } } @@ -334,7 +346,8 @@ const validStylesheetFixture = `@layer reset; @layer theme { .luke-ui-theme { color: var(--luke-color-text-primary); - font-family: var(--luke-font-family-body); + font-family: var(--luke-font-body-font-family); + font-size: var(--luke-font-body-font-size); } } @layer recipes { diff --git a/packages/@luke-ui/react/src/styles/theme-root.css.ts b/packages/@luke-ui/react/src/styles/theme-root.css.ts index 864bc8d0..0dff29c0 100644 --- a/packages/@luke-ui/react/src/styles/theme-root.css.ts +++ b/packages/@luke-ui/react/src/styles/theme-root.css.ts @@ -5,6 +5,5 @@ import { globalStyleInLayer } from './layered-style.css.js'; globalStyleInLayer('theme', classSelector(lukeUiClassNames.themeRoot), { accentColor: vars.color.background.accent.solid.rest, color: vars.color.text.primary, - fontFamily: vars.font.family.body, - ...vars.font[300], + ...vars.font.body, }); diff --git a/packages/@luke-ui/react/src/text/index.tsx b/packages/@luke-ui/react/src/text/index.tsx index b1319ce5..d0525387 100644 --- a/packages/@luke-ui/react/src/text/index.tsx +++ b/packages/@luke-ui/react/src/text/index.tsx @@ -1,5 +1,6 @@ import { Text as RacText } from 'react-aria-components/Text'; import * as styles from '../recipes/text.css.js'; +import { typeStyleWeightRole } from '../theme/contract.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { Prettify } from '../types/prettify.js'; import { cx } from '../utils/index.js'; @@ -18,8 +19,8 @@ interface TextStyleProps { */ fontVariantNumeric?: TextVariantProps['fontVariantNumeric']; /** - * Sets the semantic font-weight role. - * @default 'body' + * Sets the semantic font-weight role. When omitted, the selected typography style supplies its + * weight. */ fontWeight?: TextVariantProps['fontWeight']; /** @@ -39,11 +40,6 @@ interface TextStyleProps { * @default false */ shouldInheritFont?: TextVariantProps['shouldInheritFont']; - /** - * Sets the font size, line height, letter spacing, and trim as one step. - * @default '300' - */ - size?: TextVariantProps['size']; /** * Sets text alignment. * @default 'start' @@ -64,6 +60,12 @@ interface TextStyleProps { * @default 'unset' */ textWrap?: TextVariantProps['textWrap']; + /** + * Applies a complete typography style: family, size, weight, line height, letter spacing, and + * trim. + * @default 'body' + */ + typography?: TextVariantProps['typography']; } type _TextOmit = DistributiveOmit, 'color'>; @@ -90,7 +92,7 @@ const blockTextElementTypes = new Set>([ ]); /** - * Styled text with a coordinated type scale and semantic colour controls. + * Styled text with semantic typography styles and colour controls. * * Capsize trim is applied to known block text elements and skipped for inline or unknown element * types. Set `shouldDisableTrim` explicitly to override this inference. Line clamp always disables @@ -108,14 +110,15 @@ export function Text(props: TextProps) { lineClamp, shouldDisableTrim, shouldInheritFont, - size, textAlign, textDecoration, textTransform, textWrap, + typography, ...racProps } = props; const hasLineClamp = lineClamp !== undefined && lineClamp !== false; + const resolvedTypography = typography ?? 'body'; const resolvedShouldDisableTrim: boolean = (() => { if (hasLineClamp) return true; @@ -130,16 +133,18 @@ export function Text(props: TextProps) { styles.text({ color, fontVariantNumeric, - fontWeight, + ...(shouldInheritFont + ? {} + : { fontWeight: fontWeight ?? typeStyleWeightRole[resolvedTypography] }), isVisuallyHidden, lineClamp, shouldDisableTrim: resolvedShouldDisableTrim, shouldInheritFont, - size, textAlign, textDecoration, textTransform, textWrap, + typography: resolvedTypography, }), className, )} diff --git a/packages/@luke-ui/react/src/text/text.browser.test.tsx b/packages/@luke-ui/react/src/text/text.browser.test.tsx new file mode 100644 index 00000000..7ee73098 --- /dev/null +++ b/packages/@luke-ui/react/src/text/text.browser.test.tsx @@ -0,0 +1,59 @@ +import { expect, test } from 'vite-plus/test'; +import { render } from '../test-utils/render.js'; +import { Text } from './index.js'; + +test('resolves typography default weight, explicit overrides, nesting, and shouldInheritFont', () => { + const { locator } = render( + <> + + Label default + + + Label with body weight + + + Body weight reference + + + Emphasis parent + Nested label default + + Nested label with body weight + + Inherited emphasis + + + Emphasis weight reference + + , + ); + + const exact = { exact: true } as const; + const labelDefault = getComputedStyle( + locator.getByText('Label default', exact).element(), + ).fontWeight; + const labelWithBody = getComputedStyle( + locator.getByText('Label with body weight', exact).element(), + ).fontWeight; + const bodyReference = getComputedStyle( + locator.getByText('Body weight reference', exact).element(), + ).fontWeight; + const nestedLabelDefault = getComputedStyle( + locator.getByText('Nested label default', exact).element(), + ).fontWeight; + const nestedLabelWithBody = getComputedStyle( + locator.getByText('Nested label with body weight', exact).element(), + ).fontWeight; + const inheritedEmphasis = getComputedStyle( + locator.getByText('Inherited emphasis', exact).element(), + ).fontWeight; + const emphasisReference = getComputedStyle( + locator.getByText('Emphasis weight reference', exact).element(), + ).fontWeight; + + expect(labelDefault).not.toBe(bodyReference); + expect(labelWithBody).toBe(bodyReference); + expect(nestedLabelDefault).toBe(labelDefault); + expect(nestedLabelWithBody).toBe(bodyReference); + expect(inheritedEmphasis).toBe(emphasisReference); +}); diff --git a/packages/@luke-ui/react/src/text/text.stories.tsx b/packages/@luke-ui/react/src/text/text.stories.tsx index 428254d7..354834ae 100644 --- a/packages/@luke-ui/react/src/text/text.stories.tsx +++ b/packages/@luke-ui/react/src/text/text.stories.tsx @@ -5,7 +5,7 @@ import { VisuallyHidden as VisuallyHiddenText } from '@luke-ui/react/visually-hi import type { CSSProperties } from 'react'; import preview from '../../.storybook/preview.js'; import { createSprinkles } from '../styles/index.js'; -import { vars } from '../theme/index.js'; +import { typeStyles, vars } from '../theme/index.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; const meta = preview.meta({ @@ -76,8 +76,8 @@ function MiddleTruncatedIdentifier(props: MiddleTruncatedIdentifierProps) { const baseArgs = { children: storyText, - size: '300', -} as const satisfies Pick; + typography: 'body', +} as const satisfies Pick; const colors = [ 'primary', @@ -88,17 +88,6 @@ const colors = [ 'warning', 'danger', ] as const satisfies ReadonlyArray>; -const sizes = [ - '100', - '200', - '300', - '400', - '500', - '600', - '700', - '800', - '900', -] as const satisfies ReadonlyArray>; const weights = ['body', 'label', 'heading', 'emphasis'] as const satisfies ReadonlyArray< NonNullable >; @@ -111,14 +100,14 @@ export const Default = meta.story({ }); /** - * Each size step applies font size, line height, letter spacing, and trim as one treatment. + * Each typography style applies family, size, weight, line height, letter spacing, and trim together. */ -export const Size = meta.story({ +export const Typography = meta.story({ render: (props) => (
- {sizes.map((size) => ( - - {size}: {storyText} + {typeStyles.map((typography) => ( + + {typography}: {storyText} ))}
@@ -162,7 +151,7 @@ export const EmptyText = meta.story({ args: { ...baseArgs, children: '', - } satisfies Pick, + } satisfies Pick, render: (props) => , }); diff --git a/packages/@luke-ui/react/src/text/text.visual.test.tsx b/packages/@luke-ui/react/src/text/text.visual.test.tsx index 6dc2350c..0e6bcc97 100644 --- a/packages/@luke-ui/react/src/text/text.visual.test.tsx +++ b/packages/@luke-ui/react/src/text/text.visual.test.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from 'react'; import { test } from 'vite-plus/test'; import { render, visualAppearances } from '../test-utils/render.js'; import { captureVisual, captureVisualAppearance, Stack } from '../test-utils/visual.js'; +import { typeStyles } from '../theme/contract.js'; import { Text } from './index.js'; const rowStyle = { @@ -12,19 +13,15 @@ const rowStyle = { } satisfies CSSProperties; for (const appearance of visualAppearances) { - test(`type scale: ${appearance.theme} ${appearance.mode}`, async () => { + test(`typography styles: ${appearance.theme} ${appearance.mode}`, async () => { const { locator } = render(
- 100 - 200 - 300 - 400 - 500 - 600 - 700 - 800 - 900 + {typeStyles.map((typography) => ( + + {typography} + + ))}
Body diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts index fd63d2cd..57ccd3d0 100644 --- a/packages/@luke-ui/react/src/theme/contract.test.ts +++ b/packages/@luke-ui/react/src/theme/contract.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vite-plus/test'; import { vars } from './contract.css.js'; -import { flattenThemeContract, fontSizeSteps, spaceScale, themeContractTree } from './contract.js'; +import { flattenThemeContract, spaceScale, themeContractTree, typeStyles } from './contract.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; +import { FONT_METRIC_SCALE } from './font-metric-scale.js'; function countLeaves(node: unknown): number { if (typeof node === 'string') return 1; @@ -70,15 +71,18 @@ describe('theme contract', () => { expect([...emitted].sort(byPath)).toEqual([...expected].sort(byPath)); }); - it('exposes font steps and the carried-forward icon-size scale', () => { - expect(vars.font[100]).toEqual({ - baselineTrim: 'var(--luke-font-100-baseline-trim)', - capHeightTrim: 'var(--luke-font-100-cap-height-trim)', - fontSize: 'var(--luke-font-100-font-size)', - letterSpacing: 'var(--luke-font-100-letter-spacing)', - lineHeight: 'var(--luke-font-100-line-height)', + it('exposes semantic type styles and the carried-forward icon-size scale', () => { + expect(vars.font.body).toEqual({ + baselineTrim: 'var(--luke-font-body-baseline-trim)', + capHeightTrim: 'var(--luke-font-body-cap-height-trim)', + fontFamily: 'var(--luke-font-body-font-family)', + fontSize: 'var(--luke-font-body-font-size)', + fontWeight: 'var(--luke-font-body-font-weight)', + letterSpacing: 'var(--luke-font-body-letter-spacing)', + lineHeight: 'var(--luke-font-body-line-height)', }); - expect(vars.font[900].fontSize).toBe('var(--luke-font-900-font-size)'); + expect(vars.font.display.fontSize).toBe('var(--luke-font-display-font-size)'); + expect(vars.font.heading2.fontWeight).toBe('var(--luke-font-heading2-font-weight)'); expect(vars.font.family).toEqual({ body: 'var(--luke-font-family-body)', code: 'var(--luke-font-family-code)', @@ -91,11 +95,20 @@ describe('theme contract', () => { }); }); - it('keeps fontSizeSteps as the single source of truth for the font contract keys', () => { + it('keeps typeStyles as the single source of truth for the font contract keys', () => { const fontStepKeys = Object.keys(themeContractTree.font).filter((key) => { return key !== 'family' && key !== 'weight'; }); - expect(fontSizeSteps).toEqual(fontStepKeys); + expect(typeStyles).toEqual(fontStepKeys); + }); + + it('keeps literal typography metrics internal while public tokens stay semantic', () => { + for (const step of Object.keys(FONT_METRIC_SCALE)) { + expect(Object.hasOwn(vars.font, step)).toBe(false); + } + for (const style of typeStyles) { + expect(Object.hasOwn(vars.font, style)).toBe(true); + } }); it('defines the selected spacing steps from the 4px scale', () => { diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index 39111635..e39b46ce 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -1,7 +1,12 @@ -const fontStep = { +import type { FontMetricStep } from './font-metric-scale.js'; + +/** Leaves shared by every public type style. */ +const typeStyle = { baselineTrim: null, capHeightTrim: null, + fontFamily: null, fontSize: null, + fontWeight: null, letterSpacing: null, lineHeight: null, }; @@ -34,21 +39,70 @@ const roleForeground = { onSolid: null, }; -/** Source-owned typography size step keys, in display order. */ -export const fontSizeSteps = [ - '100', - '200', - '300', - '400', - '500', - '600', - '700', - '800', - '900', +/** + * Public semantic type styles, in ascending visual size. Each style is a complete typography + * treatment: family, size, weight, line height, letter spacing, and Capsize trims. Styles may share + * private metric steps when they differ by weight rather than size. + */ +export const typeStyles = [ + 'caption', + 'support', + 'label', + 'body', + 'lead', + 'heading4', + 'heading3', + 'heading2', + 'heading1', + 'display', ] as const; -/** A typography size step key. */ -export type FontSizeStep = (typeof fontSizeSteps)[number]; +/** A public semantic type style key. */ +export type TypeStyle = (typeof typeStyles)[number]; + +/** Theme weight roles available on `vars.font.weight` and as `Text`/`Heading` overrides. */ +export const fontWeightRoles = ['body', 'label', 'heading', 'emphasis'] as const; + +/** A theme font-weight role key. */ +export type FontWeightRole = (typeof fontWeightRoles)[number]; + +/** + * Private metric step each public type style resolves from. Kept beside `typeStyles` so + * `FONT_VALUES` emission cannot invent a different mapping. + */ +export const typeStyleMetricStep = { + caption: 12, + support: 14, + label: 14, + body: 16, + lead: 18, + heading4: 20, + heading3: 24, + heading2: 28, + heading1: 35, + display: 60, +} as const satisfies Record; + +/** + * Theme weight role each type style resolves to. Kept beside `typeStyles` so stylesheet emission and + * the Text recipe cannot pick different defaults. + */ +export const typeStyleWeightRole = { + caption: 'body', + support: 'body', + label: 'label', + body: 'body', + lead: 'body', + heading4: 'heading', + heading3: 'heading', + heading2: 'heading', + heading1: 'heading', + display: 'heading', +} as const satisfies Record; + +const fontStyleContract = Object.fromEntries( + typeStyles.map((style) => [style, { ...typeStyle }]), +) as { readonly [Style in TypeStyle]: typeof typeStyle }; /** * The fixed spacing steps shared by the built-in themes. Each value is a selected step from the @@ -151,35 +205,14 @@ export const themeContractTree = { resting: null, raised: null, }, - /** Composite type steps, font families, and theme-controlled weight roles. */ + /** + * Semantic type styles, plus the shared family and weight primitives those styles resolve from. + * `family` and `weight` stay public for code surfaces and weight overrides; they are not type + * styles themselves. The private metric scale that styles compose from is not part of this + * contract. + */ font: { - 100: { - ...fontStep, - }, - 200: { - ...fontStep, - }, - 300: { - ...fontStep, - }, - 400: { - ...fontStep, - }, - 500: { - ...fontStep, - }, - 600: { - ...fontStep, - }, - 700: { - ...fontStep, - }, - 800: { - ...fontStep, - }, - 900: { - ...fontStep, - }, + ...fontStyleContract, family: { body: null, code: null, diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index ed7c0bef..2a07972a 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -89,7 +89,7 @@ interface ThemeInputCommon { overlay?: number; // radius.full is fixed at 9999px and is not authored. }; - /** Typography — family and weights only. The type scale is source-owned (not authored here). */ + /** Typography — family and weights only. The typography styles are source-owned (not authored here). */ typography?: { /** * Curated Capsize-compatible font-family choice. diff --git a/packages/@luke-ui/react/src/theme/font-metric-scale.ts b/packages/@luke-ui/react/src/theme/font-metric-scale.ts new file mode 100644 index 00000000..09915e7a --- /dev/null +++ b/packages/@luke-ui/react/src/theme/font-metric-scale.ts @@ -0,0 +1,30 @@ +/** + * The private typography metric scale behind the public type styles. It is resolved in TypeScript + * and never emitted, so no `--luke-font-12-*` custom properties exist. + * + * Keys are the fixed font sizes in pixels. Multiple public type styles may share one step when they + * differ by weight or family rather than size. Recipes that need raw metrics for geometry or + * composite control chrome read this scale directly instead of borrowing a semantic text treatment. + */ + +type FontMetric = { + fontSize: string; + letterSpacing: string; + lineHeight: string; +}; + +/** The private typography metric steps, from the smallest ancillary size to display. */ +export const FONT_METRIC_SCALE = { + 12: { fontSize: '12px', letterSpacing: '0.0025em', lineHeight: '16px' }, + 14: { fontSize: '14px', letterSpacing: '0', lineHeight: '20px' }, + 16: { fontSize: '16px', letterSpacing: '0', lineHeight: '24px' }, + 18: { fontSize: '18px', letterSpacing: '-0.0025em', lineHeight: '26px' }, + 20: { fontSize: '20px', letterSpacing: '-0.005em', lineHeight: '28px' }, + 24: { fontSize: '24px', letterSpacing: '-0.00625em', lineHeight: '30px' }, + 28: { fontSize: '28px', letterSpacing: '-0.0075em', lineHeight: '36px' }, + 35: { fontSize: '35px', letterSpacing: '-0.01em', lineHeight: '40px' }, + 60: { fontSize: '60px', letterSpacing: '-0.025em', lineHeight: '60px' }, +} as const satisfies Record; + +/** A key on the private typography metric scale. */ +export type FontMetricStep = keyof typeof FONT_METRIC_SCALE; diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx index 924dca32..b05c9eb4 100644 --- a/packages/@luke-ui/react/src/theme/index.tsx +++ b/packages/@luke-ui/react/src/theme/index.tsx @@ -18,11 +18,14 @@ export { vars } from './contract.css.js'; export { spaceScale } from './contract.js'; export type { SpaceStep } from './contract.js'; -/** Typography size step keys in display order. */ -export { fontSizeSteps } from './contract.js'; +/** Public semantic type style keys in ascending visual size. */ +export { typeStyles } from './contract.js'; -/** A typography size step key. */ -export type { FontSizeStep } from './contract.js'; +/** A public semantic type style key. */ +export type { TypeStyle } from './contract.js'; + +/** Theme font-weight role keys used by typography defaults and weight overrides. */ +export type { FontWeightRole } from './contract.js'; /** * `ThemeContrastError` is thrown by `defineTheme` when a hard-gated pair misses WCAG 2.2 AA: 4.5:1 diff --git a/packages/@luke-ui/react/src/theme/stylesheet.test.ts b/packages/@luke-ui/react/src/theme/stylesheet.test.ts index 4bfd46b9..21b3a7ee 100644 --- a/packages/@luke-ui/react/src/theme/stylesheet.test.ts +++ b/packages/@luke-ui/react/src/theme/stylesheet.test.ts @@ -8,7 +8,7 @@ import { tactileFoundation, } from './__fixtures__/theme-css.js'; import { buildTheme } from './build-theme.js'; -import { flattenThemeContract, spaceScale } from './contract.js'; +import { flattenThemeContract, spaceScale, typeStyles } from './contract.js'; import type { ThemeFoundation } from './foundation.js'; import { defaultFontWeights, defaultRadius, defaultSourceColors } from './foundation.js'; @@ -131,9 +131,10 @@ describe('buildTheme output', () => { expect(css).toContain('--luke-control-size-small'); expect(css).toContain('--luke-motion-easing-standard'); expect(css).toContain('--luke-font-weight-body'); - expect(css).toContain('--luke-font-100-font-size:'); - expect(css).toContain('--luke-font-300-line-height:'); - expect(css).toContain('--luke-font-900-letter-spacing:'); + expect(css).toContain('--luke-font-caption-font-size:'); + expect(css).toContain('--luke-font-body-line-height:'); + expect(css).toContain('--luke-font-display-letter-spacing:'); + expect(css).toContain('--luke-font-heading2-font-weight:'); expect(css).toContain('--luke-icon-size-xsmall:'); expect(css).toContain('--luke-icon-size-large:'); }); @@ -239,9 +240,8 @@ describe('buildTheme defaults', () => { expect(css).toContain('--luke-color-border-focus: oklch('); }); - it('emits Capsize trim variables for every curated font family and size', () => { + it('emits Capsize trim variables for every curated font family and type style', () => { const fontFamilies = ['apple-system', 'dm-sans', 'inter'] as const; - const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; for (const fontFamily of fontFamilies) { const css = buildTheme({ @@ -250,9 +250,11 @@ describe('buildTheme defaults', () => { }); const identity = splitBlocks(css).identity; - for (const size of sizes) { - expect(identity).toContain(`--luke-font-${size}-cap-height-trim:`); - expect(identity).toContain(`--luke-font-${size}-baseline-trim:`); + for (const style of typeStyles) { + expect(identity).toContain(`--luke-font-${style}-cap-height-trim:`); + expect(identity).toContain(`--luke-font-${style}-baseline-trim:`); + expect(identity).toContain(`--luke-font-${style}-font-weight:`); + expect(identity).toContain(`--luke-font-${style}-font-family:`); } } }); diff --git a/packages/@luke-ui/react/src/theme/stylesheet.ts b/packages/@luke-ui/react/src/theme/stylesheet.ts index 39d57109..bc6ff5b0 100644 --- a/packages/@luke-ui/react/src/theme/stylesheet.ts +++ b/packages/@luke-ui/react/src/theme/stylesheet.ts @@ -5,7 +5,7 @@ */ import { precomputeValues } from '@capsizecss/vanilla-extract'; -import { flattenThemeContract, fontSizeSteps, spaceScale } from './contract.js'; +import { flattenThemeContract, spaceScale, typeStyles, typeStyleWeightRole } from './contract.js'; import type { ThemeFoundation } from './foundation.js'; import { codeFontFamilyStack, @@ -101,17 +101,24 @@ function buildIdentityValues(foundation: ThemeFoundation): Record = { ...CONTROL_SIZE_VALUES, ...INTERACTION_VALUES, ...FONT_VALUES, ...buildCapsizeValues(fontFamily), - 'font.family.body': themeFontFamilyStacks[fontFamily], + 'font.family.body': bodyFontFamily, 'font.family.code': codeFontFamilyStack, - 'font.weight.body': String(fontWeight?.body ?? defaultFontWeights.body), - 'font.weight.emphasis': String(fontWeight?.emphasis ?? defaultFontWeights.emphasis), - 'font.weight.heading': String(fontWeight?.heading ?? defaultFontWeights.heading), - 'font.weight.label': String(fontWeight?.label ?? defaultFontWeights.label), + 'font.weight.body': resolvedWeights.body, + 'font.weight.emphasis': resolvedWeights.emphasis, + 'font.weight.heading': resolvedWeights.heading, + 'font.weight.label': resolvedWeights.label, 'radius.control': `${radius?.control ?? defaultRadius.control}px`, 'radius.detail': `${radius?.detail ?? defaultRadius.detail}px`, 'radius.full': '9999px', @@ -120,6 +127,10 @@ function buildIdentityValues(foundation: ThemeFoundation): Record { const values: Record = {}; - for (const step of fontSizeSteps) { - const fontSize = Number.parseFloat(FONT_VALUES[`font.${step}.fontSize`]); - const leading = Number.parseFloat(FONT_VALUES[`font.${step}.lineHeight`]); + for (const style of typeStyles) { + const fontSize = Number.parseFloat(FONT_VALUES[`font.${style}.fontSize`]); + const leading = Number.parseFloat(FONT_VALUES[`font.${style}.lineHeight`]); const { baselineTrim, capHeightTrim } = precomputeValues({ fontMetrics: FONT_METRICS[fontFamily], fontSize, leading, }); - values[`font.${step}.baselineTrim`] = baselineTrim; - values[`font.${step}.capHeightTrim`] = capHeightTrim; + values[`font.${style}.baselineTrim`] = baselineTrim; + values[`font.${style}.capHeightTrim`] = capHeightTrim; } return values; } diff --git a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx index 970d9afe..8c5fd4ff 100644 --- a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx +++ b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx @@ -97,7 +97,7 @@ const familyHeaderStyle = { const requirementsTextStyle = { color: vars.color.text.secondary, - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, textTransform: 'none', } as const satisfies CSSProperties; @@ -124,7 +124,7 @@ const stepBoxStyle = { } as const satisfies CSSProperties; const stepLabelStyle = { - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, } as const satisfies CSSProperties; const swatchRowStyle = { @@ -140,7 +140,7 @@ const tableWrapStyle = { const tableStyle = { borderCollapse: 'collapse', - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, inlineSize: '100%', } as const satisfies CSSProperties; @@ -165,12 +165,12 @@ const cellStyle = { const codeStyle = { fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, } as const satisfies CSSProperties; const captionStyle = { color: vars.color.text.secondary, - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, margin: 0, } as const satisfies CSSProperties; diff --git a/packages/@luke-ui/react/src/theme/token-board.tsx b/packages/@luke-ui/react/src/theme/token-board.tsx index 6c00c0cb..1ce5df75 100644 --- a/packages/@luke-ui/react/src/theme/token-board.tsx +++ b/packages/@luke-ui/react/src/theme/token-board.tsx @@ -91,7 +91,7 @@ const previewFrameStyle = { } as const satisfies CSSProperties; const swatchLabelStyle = { - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, fontWeight: vars.font.weight.label, overflowWrap: 'anywhere', } as const satisfies CSSProperties; @@ -99,7 +99,7 @@ const swatchLabelStyle = { const swatchVarStyle = { color: vars.color.text.secondary, fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', - fontSize: vars.font[100].fontSize, + fontSize: vars.font.caption.fontSize, overflowWrap: 'anywhere', } as const satisfies CSSProperties; @@ -463,14 +463,18 @@ function FontPreview({ path, segments, varName }: LeafPreviewProps) { if (second === 'weight') return ; - // `second` is a font-size-step key here (`fontSizeSteps` in contract.ts); every step carries the - // same five sub-properties (see `fontStep` in contract.ts), so the step's own fontSize var sizes + // `second` is a type-style key here (`typeStyles` in contract.ts); every style carries the + // same leaf set (see `typeStyle` in contract.ts), so the style's own fontSize var sizes // every other sub-property's sample legibly. `themeVarName` (from contract.ts) builds that sibling // reference the same way the contract builds every variable name, rather than re-deriving it here. const fontSizeVarName = themeVarName(['font', second ?? '', 'fontSize']); if (third === 'fontSize') return ; + if (third === 'fontFamily') + return ; + if (third === 'fontWeight') + return ; if (third === 'lineHeight') { return ( { + const metrics = FONT_METRIC_SCALE[typeStyleMetricStep[style]]; + return [ + [`font.${style}.fontSize`, metrics.fontSize], + [`font.${style}.letterSpacing`, metrics.letterSpacing], + [`font.${style}.lineHeight`, metrics.lineHeight], + ] as const; + }), +) as { readonly [Key in FontValueKey]: string }; /** Inline and block sizes for the four public icon sizes. */ export const ICON_SIZE_VALUES = {