diff --git a/apps/docs/content/docs/components/typography/emoji.mdx b/apps/docs/content/docs/components/typography/emoji.mdx index d6023382..4786ce89 100644 --- a/apps/docs/content/docs/components/typography/emoji.mdx +++ b/apps/docs/content/docs/components/typography/emoji.mdx @@ -12,7 +12,7 @@ Use `Emoji` when an emoji needs a reliable accessible label. /> `Emoji` accepts all `Text` props except `children` and `elementType`, so it inherits typography -controls such as `fontSize` and `color`. +props such as the `size` scale, semantic `color` values, and `fontWeight` roles. ## Best practices diff --git a/apps/docs/content/docs/components/typography/heading.mdx b/apps/docs/content/docs/components/typography/heading.mdx index d062a225..71dd4dae 100644 --- a/apps/docs/content/docs/components/typography/heading.mdx +++ b/apps/docs/content/docs/components/typography/heading.mdx @@ -37,13 +37,14 @@ or children. ## Typography -`Heading` accepts all `Text` props except `fontSize`, which is controlled by the heading level. By -default it applies `fontWeight="bold"` and `lineHeight="tight"`. +By default, `Heading` maps h1 through h6 to size steps `'800'` through `'300'` and applies +`fontWeight="heading"`. Pass any `size` step to override the visual scale without changing the +semantic level. Use `'900'` for display headings. ## Props diff --git a/apps/docs/content/docs/components/typography/numeral.mdx b/apps/docs/content/docs/components/typography/numeral.mdx index be790dc3..df0a3ff1 100644 --- a/apps/docs/content/docs/components/typography/numeral.mdx +++ b/apps/docs/content/docs/components/typography/numeral.mdx @@ -4,7 +4,8 @@ description: Locale-aware number formatting powered by Intl.NumberFormat. --- `Numeral` formats numbers with `Intl.NumberFormat`. It respects locale from React Aria's -`I18nProvider`. +`I18nProvider` and accepts Text typography props such as the `size` scale, semantic `color` values, +`fontWeight`, trim, alignment, and numeric glyph variants. -See the token reference below for valid `color`, `fontFamily`, `fontSize`, `lineHeight`, and -`fontWeight` values. +The active theme supplies the curated font family and the weights behind each role. `Text` trims the +space above capitals and below the baseline by default. Set `shouldDisableTrim` when the text must +participate in an untrimmed layout; line clamping disables trim automatically. ## Text transform and decoration @@ -58,25 +59,17 @@ See the token reference below for valid `color`, `fontFamily`, `fontSize`, `line ### `color` tokens -`neutralSubtle`, `neutralBold`, `neutralDisabled`, `neutralBoldInverted`, `positive`, `informative`, -`caution`, `critical`, `inherit` +`primary`, `secondary`, `accent`, `info`, `success`, `warning`, `danger` -### `fontFamily` tokens +### `size` steps -`sans`, `mono` +`'100'`, `'200'`, `'300'`, `'400'`, `'500'`, `'600'`, `'700'`, `'800'`, `'900'` -### `fontSize` tokens +`'300'` is the default. Each step combines font size, line height, letter spacing, and Capsize trim. -`xxsmall`, `xsmall`, `small`, `standard`, `medium`, `large`, `xlarge`, `xxlarge`, `h1`, `h2`, `h3`, -`h4`, `h5`, `h6` +### `fontWeight` roles -### `lineHeight` tokens - -`nospace`, `tight`, `loose` - -### `fontWeight` tokens - -`regular`, `medium`, `bold`, `inherit` +`body`, `label`, `heading`, `emphasis` ### `textDecoration` values @@ -90,8 +83,8 @@ See the token reference below for valid `color`, `fontFamily`, `fontSize`, `line Use `Heading` for actual section headings because it manages semantic level nesting automatically. -Use `Text` with a heading-sized `fontSize` token when content should look like a heading but is not -semantically one, such as a large stat number. +Use `Text` with a larger `size` and the `heading` or `emphasis` `fontWeight` role when content +should look prominent but is not semantically a heading, such as a large stat number. ## Props diff --git a/apps/docs/src/examples/emoji/basic.tsx b/apps/docs/src/examples/emoji/basic.tsx index ad86e200..0d074d72 100644 --- a/apps/docs/src/examples/emoji/basic.tsx +++ b/apps/docs/src/examples/emoji/basic.tsx @@ -4,7 +4,7 @@ export default function Basic() { return (
- +
); } diff --git a/apps/docs/src/examples/heading/typography.tsx b/apps/docs/src/examples/heading/typography.tsx index 3ffd885a..6c2470ff 100644 --- a/apps/docs/src/examples/heading/typography.tsx +++ b/apps/docs/src/examples/heading/typography.tsx @@ -3,11 +3,11 @@ import { Heading } from '@luke-ui/react/heading'; export default function Typography() { return (
- + Informative heading - - Light-weight heading + + Display-sized heading
); diff --git a/apps/docs/src/examples/text/typography.tsx b/apps/docs/src/examples/text/typography.tsx index d6209716..e8cc7e67 100644 --- a/apps/docs/src/examples/text/typography.tsx +++ b/apps/docs/src/examples/text/typography.tsx @@ -3,11 +3,11 @@ import { Text } from '@luke-ui/react/text'; export default function Typography() { return (
- - Heading-like text + + Large heading treatment - - Alert-like inline text + + Important danger text
); diff --git a/docs/STYLING.md b/docs/STYLING.md index 16159ed8..003c544b 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -29,9 +29,11 @@ stylesheet text. It is pure and Node-compatible. It generates the full semantic and throws a `ThemeContrastError` naming each failing mode and token pair when a generated pair misses WCAG 2.2 AA contrast. -The semantic contract includes composite `font.100` through `font.900` steps. Each step groups its -font size, line height, and letter spacing so components cannot combine unrelated values. Icon sizes -carry forward the `xsmall`, `small`, `medium`, and `large` scale at 16px, 20px, 24px, and 32px. +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. `buildTheme` computes those trim values from the curated Inter, Apple System, or DM Sans +font metrics. 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/button/index.tsx b/packages/@luke-ui/react/src/button/index.tsx index 7abdbd92..85714e15 100644 --- a/packages/@luke-ui/react/src/button/index.tsx +++ b/packages/@luke-ui/react/src/button/index.tsx @@ -73,7 +73,7 @@ export function Button(props: ButtonProps): JSX.Element { )} {startIcon} - + {typeof children === 'function' ? children(renderProps) : children} {endIcon} diff --git a/packages/@luke-ui/react/src/emoji/emoji.stories.tsx b/packages/@luke-ui/react/src/emoji/emoji.stories.tsx index 0d8b743d..85075fa7 100644 --- a/packages/@luke-ui/react/src/emoji/emoji.stories.tsx +++ b/packages/@luke-ui/react/src/emoji/emoji.stories.tsx @@ -35,7 +35,9 @@ const baseArgs = { export const Default = meta.story({ args: baseArgs, play: async ({ canvas }) => { - await expect(canvas.getByRole('img', { name: 'Celebration' })).toBeInTheDocument(); + const emoji = canvas.getByRole('img', { name: 'Celebration' }); + await expect(emoji.textContent).toBe('🎉'); + await expect(getComputedStyle(emoji).fontSize).toBe('16px'); }, render: (props) => (
@@ -50,12 +52,21 @@ export const Default = meta.story({ */ export const Size = meta.story({ args: baseArgs, + play: async ({ canvas }) => { + const rockets = canvas.getAllByRole('img', { name: 'Rocket' }); + await expect(rockets.map((emoji) => getComputedStyle(emoji).fontSize)).toEqual([ + '60px', + '24px', + '16px', + '12px', + ]); + }, render: (props) => (
- - - - + + + +
), }); 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 7140ac1f..05803f32 100644 --- a/packages/@luke-ui/react/src/emoji/emoji.visual.test.tsx +++ b/packages/@luke-ui/react/src/emoji/emoji.visual.test.tsx @@ -1,10 +1,11 @@ import type { CSSProperties } from 'react'; -import { test } from 'vite-plus/test'; +import { expect, test } from 'vite-plus/test'; import { - captureVisual, + captureVisualAppearance, renderVisual, Stack, variantValuesFor, + visualAppearances, } from '../test-utils/render-visual.js'; import { Emoji } from './index.js'; @@ -14,23 +15,27 @@ const rowStyle = { gap: '1rem', } satisfies CSSProperties; -const fontSizes = variantValuesFor()(['small', 'standard', 'large']); +const sizes = variantValuesFor()(['100', '300', '500', '700', '900']); -test('sizes and colors', async () => { - const locator = renderVisual( - -
- {fontSizes.map((fontSize) => ( - - ))} -
-
- - - -
-
, - ); +for (const appearance of visualAppearances) { + test(`sizes and colours: ${appearance.theme} ${appearance.mode}`, async () => { + const locator = renderVisual( + +
+ {sizes.map((size) => ( + + ))} +
+
+ + + +
+
, + appearance, + ); + await expect.element(locator).toBeVisible(); - await captureVisual(locator, 'emoji/sizes-colors'); -}); + await captureVisualAppearance(locator, 'emoji/sizes-colors', appearance); + }); +} diff --git a/packages/@luke-ui/react/src/emoji/index.tsx b/packages/@luke-ui/react/src/emoji/index.tsx index 4ae05398..92693700 100644 --- a/packages/@luke-ui/react/src/emoji/index.tsx +++ b/packages/@luke-ui/react/src/emoji/index.tsx @@ -14,12 +14,12 @@ export interface EmojiProps extends DistributiveOmit + {emoji} ); diff --git a/packages/@luke-ui/react/src/heading/heading.stories.tsx b/packages/@luke-ui/react/src/heading/heading.stories.tsx index f601b1df..d54ad3bd 100644 --- a/packages/@luke-ui/react/src/heading/heading.stories.tsx +++ b/packages/@luke-ui/react/src/heading/heading.stories.tsx @@ -24,7 +24,16 @@ const levels = [1, 2, 3, 4, 5, 6] as const satisfies Array { - await expect(canvas.getByRole('heading', { name: /Level 1/ })).toBeInTheDocument(); + const expectedSizes = ['35px', '28px', '24px', '20px', '18px', '16px']; + await Promise.all( + levels.map(async (level, index) => { + const heading = canvas.getByRole('heading', { name: `Level ${level} heading` }); + const style = getComputedStyle(heading); + await expect(heading.tagName).toBe(`H${level}`); + await expect(style.fontSize).toBe(expectedSizes[index]); + await expect(style.fontWeight).toBe('600'); + }), + ); }, render: (props) => (
@@ -58,20 +67,26 @@ export const ElementType = meta.story({ }); /** - * Override visual heading size with `elementType` while preserving semantic level. + * Override visual heading size with `size` while preserving semantic level. */ -export const OverridingFontStyles = meta.story({ +export const SizeOverride = meta.story({ args: { level: 2, }, + play: async ({ canvas }) => { + const display = canvas.getByRole('heading', { name: /display size 900/ }); + await expect(display.tagName).toBe('H2'); + await expect(getComputedStyle(display).fontSize).toBe('60px'); + await expect(getComputedStyle(display).fontWeight).toBe('600'); + }, render: (props) => (
Level 2 semantic and visual - - Level 2 semantic, h4 visual style + + Level 2 semantic, size 500 - - Level 2 semantic, h1 visual style + + Level 2 semantic, display size 900
), 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 1a36077d..a6c2e6f4 100644 --- a/packages/@luke-ui/react/src/heading/heading.visual.test.tsx +++ b/packages/@luke-ui/react/src/heading/heading.visual.test.tsx @@ -1,27 +1,36 @@ -import { test } from 'vite-plus/test'; +import { expect, test } from 'vite-plus/test'; import { captureVisual, + captureVisualAppearance, renderVisual, Stack, variantValuesFor, + visualAppearances, } from '../test-utils/render-visual.js'; import { Heading } from './index.js'; const levels = variantValuesFor()([1, 2, 3, 4, 5, 6]); -test('levels', async () => { - const locator = renderVisual( - - {levels.map((level) => ( - - Level {level} heading +for (const appearance of visualAppearances) { + test(`levels: ${appearance.theme} ${appearance.mode}`, async () => { + const locator = renderVisual( + + {levels.map((level) => ( + + Level {level} heading + + ))} + + Display heading - ))} - , - ); + , + appearance, + ); + await expect.element(locator).toBeVisible(); - await captureVisual(locator, 'heading/levels'); -}); + await captureVisualAppearance(locator, 'heading/levels', appearance); + }); +} test('truncated heading', async () => { const locator = renderVisual( diff --git a/packages/@luke-ui/react/src/heading/index.tsx b/packages/@luke-ui/react/src/heading/index.tsx index 54c3624a..952ba34b 100644 --- a/packages/@luke-ui/react/src/heading/index.tsx +++ b/packages/@luke-ui/react/src/heading/index.tsx @@ -2,7 +2,6 @@ import type { HeadingLevel, HeadingLevelsProps } from '../heading-context/index. import { HeadingLevels, HeadingPresenceProvider } from '../heading-context/index.js'; import type { TextProps } from '../text/index.js'; import { Text } from '../text/index.js'; -import type { DistributiveOmit } from '../types/distributive-omit.js'; export type { HeadingLevel } from '../heading-context/index.js'; /** Valid heading tag name for Luke UI headings. */ @@ -13,14 +12,23 @@ export type HeadingTag = `h${HeadingLevel}`; * * @tier atom */ -export interface HeadingProps extends DistributiveOmit { +export interface HeadingProps extends TextProps { /** Heading level override. Inherits from context when omitted. */ level?: HeadingLevel; } -/** Heading component with automatic level composition. */ +const sizeByLevel = { + 1: '800', + 2: '700', + 3: '600', + 4: '500', + 5: '400', + 6: '300', +} as const; + +/** Semantic heading with automatic level composition and level-based typography. */ export function Heading(props: HeadingProps) { - const { elementType, level, ...textProps } = props; + const { elementType, fontWeight = 'heading', level, size, ...textProps } = props; const baseProps: Pick = level === undefined ? {} : { base: level }; return ( @@ -29,9 +37,8 @@ export function Heading(props: HeadingProps) { diff --git a/packages/@luke-ui/react/src/numeral/index.tsx b/packages/@luke-ui/react/src/numeral/index.tsx index 54ab9c26..aa071881 100644 --- a/packages/@luke-ui/react/src/numeral/index.tsx +++ b/packages/@luke-ui/react/src/numeral/index.tsx @@ -47,7 +47,7 @@ export interface NumeralProps extends Omit< value: number; } -/** Formats a number and renders it with `Text`. */ +/** Formats a number and renders it with the same typography props as `Text`. */ export function Numeral(props: NumeralProps) { const { locale: localeFromContext } = useLocale(); const isWithinHeading = useIsWithinHeading(); @@ -88,9 +88,6 @@ export function Numeral(props: NumeralProps) { const content = getCachedNumberFormat(resolvedLocale, numeralFormatOptions).format(value); const resolvedShouldDisableTrim = shouldDisableTrim ?? isWithinHeading; - const resolvedColor = color ?? (isWithinHeading ? 'inherit' : undefined); - const colorProps: Pick = - resolvedColor === undefined ? {} : { color: resolvedColor }; return ( {content} diff --git a/packages/@luke-ui/react/src/numeral/numeral.stories.tsx b/packages/@luke-ui/react/src/numeral/numeral.stories.tsx index 0b9d6916..a936f32c 100644 --- a/packages/@luke-ui/react/src/numeral/numeral.stories.tsx +++ b/packages/@luke-ui/react/src/numeral/numeral.stories.tsx @@ -3,6 +3,7 @@ import type { NumeralProps } from '@luke-ui/react/numeral'; import { Numeral } from '@luke-ui/react/numeral'; import { Text } from '@luke-ui/react/text'; import type { CSSProperties } from 'react'; +import { expect } from 'storybook/test'; import preview from '../../.storybook/preview.js'; const meta = preview.meta({ @@ -32,6 +33,12 @@ const baseArgs = { */ export const Default = meta.story({ args: baseArgs, + play: async ({ canvas }) => { + const numeral = canvas.getByText('120,000'); + const style = getComputedStyle(numeral); + await expect(style.fontSize).toBe('16px'); + await expect(style.fontVariantNumeric).toContain('tabular-nums'); + }, }); /** @@ -96,10 +103,23 @@ export const Precision = meta.story({ */ export const Composition = meta.story({ args: baseArgs, + play: async ({ canvas }) => { + const heading = canvas.getByRole('heading', { name: /Acme Corporation shares hit/ }); + const numeral = canvas.getByText('1,456,789'); + const headingStyle = getComputedStyle(heading); + const numeralStyle = getComputedStyle(numeral); + await expect(numeralStyle.fontFamily).toBe(headingStyle.fontFamily); + await expect(numeralStyle.fontSize).toBe(headingStyle.fontSize); + await expect(numeralStyle.fontWeight).toBe(headingStyle.fontWeight); + await expect(numeralStyle.fontVariantNumeric).toContain('tabular-nums'); + await expect(numeralStyle.color).not.toBe(headingStyle.color); + }, render: (props) => (
- Acme Corporation shares hit today + Acme Corporation shares hit{' '} + {' '} + today We asked investors which private company's stock they would most like to own. More than{' '} 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 f2afeb55..fa852a0e 100644 --- a/packages/@luke-ui/react/src/numeral/numeral.visual.test.tsx +++ b/packages/@luke-ui/react/src/numeral/numeral.visual.test.tsx @@ -1,6 +1,11 @@ import type { CSSProperties } from 'react'; -import { test } from 'vite-plus/test'; -import { captureVisual, renderVisual, Stack } from '../test-utils/render-visual.js'; +import { expect, test } from 'vite-plus/test'; +import { + captureVisualAppearance, + renderVisual, + Stack, + visualAppearances, +} from '../test-utils/render-visual.js'; import { Numeral } from './index.js'; const rowStyle = { @@ -8,24 +13,34 @@ const rowStyle = { gap: '1.5rem', } satisfies CSSProperties; -test('formats and alignment', async () => { - const locator = renderVisual( - -
- - - -
-
- - - -
-
- -
-
, - ); +for (const appearance of visualAppearances) { + test(`formats and typography: ${appearance.theme} ${appearance.mode}`, async () => { + const locator = renderVisual( + +
+ + + +
+
+ + + +
+
+ +
+
, + appearance, + ); + await expect.element(locator).toBeVisible(); - await captureVisual(locator, 'numeral/formats-alignment'); -}); + await captureVisualAppearance(locator, 'numeral/formats-typography', appearance); + }); +} diff --git a/packages/@luke-ui/react/src/recipes/index.ts b/packages/@luke-ui/react/src/recipes/index.ts index 27fc71b1..49f46e7a 100644 --- a/packages/@luke-ui/react/src/recipes/index.ts +++ b/packages/@luke-ui/react/src/recipes/index.ts @@ -22,10 +22,10 @@ export type { TextAlign, TextColor, TextDecoration, - TextFontFamily, TextFontVariantNumeric, TextFontWeight, TextLineClampVariant, + TextSize, TextTransform, TextVariants, TextWrap, diff --git a/packages/@luke-ui/react/src/recipes/text.browser.test.ts b/packages/@luke-ui/react/src/recipes/text.browser.test.ts new file mode 100644 index 00000000..228ef544 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/text.browser.test.ts @@ -0,0 +1,179 @@ +import '@luke-ui/react/themes/machined-edge.css'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { machinedEdgeFoundation } from '../theme/foundations.js'; +import { buildTheme, themeClassName, themeRootClassName } from '../theme/index.js'; +import { machinedEdgeThemeClassName } from '../themes/index.js'; +import { text } from './text.css.js'; + +let mounted: Array = []; +let styles: Array = []; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; + for (const style of styles) style.remove(); + styles = []; +}); + +test("defaults to size '300', body weight, and primary colour", () => { + const defaultText = mountText(); + const explicitText = mountText({ color: 'primary', fontWeight: 'body', size: '300' }); + const style = getComputedStyle(defaultText); + const explicitStyle = getComputedStyle(explicitText); + + expect(style.fontFamily).toBe(explicitStyle.fontFamily); + expect(style.fontSize).toBe('16px'); + expect(style.fontWeight).toBe('400'); + expect(style.lineHeight).toBe('24px'); + expect(style.color).toBe(explicitStyle.color); +}); + +test('size composes font size, line height, and letter spacing', () => { + const style = getComputedStyle(mountText({ size: '600' })); + + expect(style.fontSize).toBe('24px'); + expect(style.lineHeight).toBe('30px'); + expect(style.letterSpacing).toBe('-0.15px'); +}); + +test('semantic colour and weight roles resolve through the active theme', () => { + const primary = getComputedStyle(mountText()); + const dangerEmphasis = getComputedStyle(mountText({ color: 'danger', fontWeight: 'emphasis' })); + + expect(dangerEmphasis.color).not.toBe(primary.color); + expect(dangerEmphasis.fontWeight).toBe('700'); +}); + +test('trim is rendered by default and can be disabled', () => { + const trimmed = mountText(); + const untrimmed = mountText({ shouldDisableTrim: true }); + + expect(getComputedStyle(trimmed, '::before').content).toBe('""'); + expect(getComputedStyle(trimmed, '::after').content).toBe('""'); + expect(getComputedStyle(untrimmed, '::before').content).toBe('none'); + expect(getComputedStyle(untrimmed, '::after').content).toBe('none'); +}); + +test('font inheritance also preserves surrounding currentColor', () => { + const root = mountRoot(); + root.style.color = 'rgb(1, 2, 3)'; + root.style.font = 'italic 500 18px / 22px serif'; + const element = root.appendChild(document.createElement('span')); + element.className = text({ shouldDisableTrim: true, shouldInheritFont: true }); + const style = getComputedStyle(element); + + expect(style.color).toBe('rgb(1, 2, 3)'); + expect(style.fontFamily).toBe('serif'); + expect(style.fontSize).toBe('18px'); + expect(style.fontWeight).toBe('500'); +}); + +test('an explicit semantic colour overrides inherited currentColor', () => { + const root = mountRoot(); + root.style.color = 'rgb(1, 2, 3)'; + const element = root.appendChild(document.createElement('span')); + element.className = text({ color: 'danger', shouldDisableTrim: true, shouldInheritFont: true }); + + expect(getComputedStyle(element).color).not.toBe('rgb(1, 2, 3)'); +}); + +test('an explicit numeric variant survives font inheritance', () => { + const root = mountRoot(); + root.style.fontVariantNumeric = 'normal'; + const element = root.appendChild(document.createElement('span')); + element.className = text({ + fontVariantNumeric: 'tabular-nums', + shouldDisableTrim: true, + shouldInheritFont: true, + }); + + expect(getComputedStyle(element).fontVariantNumeric).toContain('tabular-nums'); +}); + +test('all size steps use the generated Capsize trims for every curated font', () => { + const families = ['inter', 'apple-system', 'dm-sans'] as const; + const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; + const representativeTrims = new Set(); + + for (const fontFamily of families) { + const className = installTheme(fontFamily); + const root = mountRoot(className); + const rootStyle = getComputedStyle(root); + const authoredFontFamily = normalizeFontFamily( + rootStyle.getPropertyValue('--luke-font-family'), + ); + expect(authoredFontFamily).toContain(curatedFamilyIdentity[fontFamily]); + + for (const size of sizes) { + const element = root.appendChild(document.createElement('span')); + element.className = text({ size }); + element.textContent = `${fontFamily} ${size}`; + const style = getComputedStyle(element); + const before = getComputedStyle(element, '::before'); + const after = getComputedStyle(element, '::after'); + const capHeightTrim = rootStyle.getPropertyValue(`--luke-font-${size}-cap-height-trim`); + const baselineTrim = rootStyle.getPropertyValue(`--luke-font-${size}-baseline-trim`); + const fontSize = Number.parseFloat(style.fontSize); + + const computedFontFamily = normalizeFontFamily(style.fontFamily); + expect(computedFontFamily).toContain(computedFamilyIdentity[fontFamily]); + expect(before.content).toBe('""'); + expect(after.content).toBe('""'); + expect(Number.parseFloat(before.marginBottom)).toBeCloseTo( + Number.parseFloat(capHeightTrim) * fontSize, + 2, + ); + expect(Number.parseFloat(after.marginTop)).toBeCloseTo( + Number.parseFloat(baselineTrim) * fontSize, + 2, + ); + + if (size === '300') representativeTrims.add(`${capHeightTrim}:${baselineTrim}`); + } + } + + expect(representativeTrims.size).toBe(3); +}); + +function mountText(options: Parameters[0] = {}) { + const element = mountRoot().appendChild(document.createElement('span')); + element.className = text(options); + element.textContent = 'Text'; + return element; +} + +function mountRoot(themeClass = machinedEdgeThemeClassName) { + const root = document.body.appendChild(document.createElement('div')); + root.className = `${themeRootClassName} ${themeClass}`; + root.dataset.colorMode = 'light'; + mounted.push(root); + return root; +} + +function installTheme(fontFamily: 'inter' | 'apple-system' | 'dm-sans') { + const name = `capsize-${fontFamily}`; + const style = document.head.appendChild(document.createElement('style')); + style.textContent = buildTheme({ + ...machinedEdgeFoundation, + name, + typography: { fontFamily }, + }); + styles.push(style); + return themeClassName(name); +} + +const curatedFamilyIdentity = { + 'apple-system': '-apple-system', + 'dm-sans': 'DM Sans', + inter: 'Inter', +} as const; + +const computedFamilyIdentity = { + 'apple-system': 'system-ui', + 'dm-sans': 'DM Sans', + inter: 'Inter', +} as const; + +function normalizeFontFamily(fontFamily: string) { + return fontFamily.replaceAll(/["']/g, '').trim(); +} diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index 3f4ed6d7..d2ad4dc7 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -1,67 +1,28 @@ -import fontMetrics from '@capsizecss/metrics/appleSystem'; import { createTextStyle } from '@capsizecss/vanilla-extract'; import type { ComplexStyleRule } from '@vanilla-extract/css'; import type { RecipeVariants } from '@vanilla-extract/recipes'; -import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; -import { vars } from '../styles/vars.css.js'; -import { dimensionToPxNumber } from '../tokens/converters.js'; -import { tokenKeys } from '../tokens/groups.js'; -import type { - FontFamilyToken, - FontSizeToken, - FontWeightToken, - ForegroundColorToken, - LineHeightToken, -} from '../tokens/index.js'; -import { tokens } from '../tokens/index.js'; - -type StylePrimitive = string | number; - -function createVariants>( - keys: ReadonlyArray, - getStyle: (key: Key) => Style, -): Record { - const styles = {} as Record; - for (const key of keys) { - styles[key] = getStyle(key); - } - return styles; -} - -function createPropertyVariants( - keys: ReadonlyArray, - property: Property, - values: Record, -): Record> { - return createVariants(keys, (key) => ({ - [property]: values[key], - })) as Record>; -} - -interface GetTypographyInput { - fontSize: FontSizeToken; - lineHeight: LineHeightToken; -} - -function getTypographyClass(input: GetTypographyInput, debugId?: string) { - const fontSize = dimensionToPxNumber(tokens.fontSize[input.fontSize].$value); - const lineHeight = tokens.lineHeight[input.lineHeight].$value; - - return createTextStyle( - { - fontMetrics, - fontSize, - leading: fontSize * lineHeight, - }, - debugId, - ); -} - -const colorKeys = tokenKeys(tokens.foregroundColor); -const fontFamilyKeys = tokenKeys(tokens.fontFamily); -const fontSizeKeys = tokenKeys(tokens.fontSize); -const fontWeightKeys = tokenKeys(tokens.fontWeight); -const lineHeightKeys = tokenKeys(tokens.lineHeight); +import { styleInLayer, recipeInLayer } from '../styles/layered-style.css.js'; +import { vars } from '../theme/contract.css.js'; + +const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; +/** Typography size steps. */ +export type TextSize = (typeof sizes)[number]; + +/** Semantic text colours. */ +export type TextColor = + | 'primary' + | 'secondary' + | 'accent' + | 'info' + | 'success' + | 'warning' + | 'danger'; +/** Semantic font-weight roles. */ +export type TextFontWeight = 'body' | 'label' | 'heading' | 'emphasis'; +/** Logical text alignment values. */ +export type TextAlign = 'start' | 'center' | 'end'; +/** Text wrapping values. */ +export type TextWrap = 'unset' | 'balance' | 'pretty'; const textDecorationKeys = ['none', 'underline', 'line-through', 'inherit'] as const; /** Text decoration variant values. */ @@ -71,10 +32,6 @@ const textTransformKeys = ['none', 'capitalize', 'uppercase', 'lowercase', 'inhe /** Text transform variant values. */ export type TextTransform = (typeof textTransformKeys)[number]; -const textAlignKeys = ['start', 'center', 'end'] as const; -/** Text alignment variant values. */ -export type TextAlign = (typeof textAlignKeys)[number]; - const fontVariantNumericKeys = [ 'unset', 'diagonal-fractions', @@ -85,17 +42,6 @@ const fontVariantNumericKeys = [ /** Numeric glyph variant values. */ export type TextFontVariantNumeric = (typeof fontVariantNumericKeys)[number]; -const textWrapKeys = ['unset', 'balance', 'pretty'] as const; -/** Text wrap variant values. */ -export type TextWrap = (typeof textWrapKeys)[number]; - -/** Text color variant values. */ -export type TextColor = ForegroundColorToken | 'inherit'; -/** Text font-family variant values. */ -export type TextFontFamily = FontFamilyToken; -/** Text font-weight variant values. */ -export type TextFontWeight = FontWeightToken | 'inherit'; - const lineClampNone = {} satisfies ComplexStyleRule; const lineClampSingleLine = { display: 'block', @@ -129,165 +75,120 @@ const lineClampVariants = { export type TextLineClampVariant = keyof typeof lineClampVariants; const base = styleInLayer('recipes', { - color: vars.color.neutralBold, - fontFamily: vars.fontFamily.sans, + color: vars.color.text.primary, + fontFamily: vars.font.family, minInlineSize: 0, overflowWrap: 'break-word', }); -const colorVariants: Record = (() => { - const variants = createPropertyVariants(colorKeys, 'color', vars.color) as Record< - TextColor, - { color: TextColor } - >; - variants.inherit = { color: 'inherit' }; - return variants; -})(); - -const fontFamilyVariants = createPropertyVariants( - fontFamilyKeys, - 'fontFamily', - vars.fontFamily, -) as Record; - -const fontWeightVariants: Record = (() => { - const variants = createPropertyVariants(fontWeightKeys, 'fontWeight', vars.fontWeight) as Record< - TextFontWeight, - { fontWeight: TextFontWeight } - >; - variants.inherit = { fontWeight: 'inherit' }; - return variants; -})(); - -const textDecorationVariants = createVariants(textDecorationKeys, (textDecoration) => ({ - textDecoration, -})); - -const textTransformVariants = createVariants(textTransformKeys, (textTransform) => ({ - textTransform, -})); - -const textAlignVariants = { - start: { textAlign: 'start' }, - center: { textAlign: 'center' }, - end: { textAlign: 'end' }, +const colorVariants = { + accent: { color: vars.color.intent.accent.text }, + danger: { color: vars.color.intent.danger.text }, + info: { color: vars.color.intent.info.text }, + primary: { color: vars.color.text.primary }, + secondary: { color: vars.color.text.secondary }, + success: { color: vars.color.intent.success.text }, + warning: { color: vars.color.intent.warning.text }, } as const; -const fontVariantNumericVariants = createVariants(fontVariantNumericKeys, (fontVariantNumeric) => ({ - fontVariantNumeric: fontVariantNumeric === 'unset' ? 'normal' : fontVariantNumeric, -})); - -const textWrapVariants = { - balance: { textWrap: 'balance' }, - pretty: { textWrap: 'pretty' }, - unset: {}, +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 fontSizeVariants = Object.fromEntries(fontSizeKeys.map((key) => [key, {}])) as Record< - FontSizeToken, - {} ->; - -const lineHeightVariants = Object.fromEntries(lineHeightKeys.map((key) => [key, {}])) as Record< - LineHeightToken, - {} ->; - -const typographyCompoundVariants = (() => { - const variants: Array<{ - variants: { - fontSize: FontSizeToken; - lineHeight: LineHeightToken; - shouldDisableTrim: boolean; - }; - style: - | string - | { - fontSize: string; - lineHeight: string; - }; - }> = []; - - for (const fontSize of fontSizeKeys) { - for (const lineHeight of lineHeightKeys) { - variants.push({ - style: getTypographyClass( - { fontSize, lineHeight }, - `text_typography_${fontSize}_${lineHeight}`, - ), - variants: { - fontSize, - lineHeight, - shouldDisableTrim: false, - }, - }); - - variants.push({ - style: { - fontSize: vars.fontSize[fontSize], - lineHeight: vars.lineHeight[lineHeight], - }, - variants: { - fontSize, - lineHeight, - shouldDisableTrim: true, - }, - }); - } - } +const sizeVariants = Object.fromEntries( + sizes.map((size) => [ + size, + { + fontSize: vars.font[size].fontSize, + letterSpacing: vars.font[size].letterSpacing, + lineHeight: vars.font[size].lineHeight, + }, + ]), +) as Record; - return variants; -})(); +const sizeStepCompoundVariants = sizes.map((size) => { + const { baselineTrim, capHeightTrim, fontSize, lineHeight } = vars.font[size]; + return { + style: createTextStyle( + { baselineTrim, capHeightTrim, fontSize, lineHeight }, + `text_typography_${size}`, + ), + variants: { shouldDisableTrim: false, size }, + }; +}); /** Vanilla-extract recipe for the `Text` primitive's styles. */ export const text = recipeInLayer('recipes', { base, - compoundVariants: typographyCompoundVariants, + compoundVariants: sizeStepCompoundVariants, defaultVariants: { - color: 'neutralBold', - fontFamily: 'sans', - fontSize: 'standard', - fontWeight: 'regular', + fontVariantNumeric: 'unset', isVisuallyHidden: false, lineClamp: false, - lineHeight: 'loose', shouldDisableTrim: false, shouldInheritFont: false, + size: '300', textAlign: 'start', textDecoration: 'none', textTransform: 'none', textWrap: 'unset', - fontVariantNumeric: 'unset', + fontWeight: 'body', }, variants: { - color: colorVariants, - fontFamily: fontFamilyVariants, - fontSize: fontSizeVariants, - fontVariantNumeric: fontVariantNumericVariants, - fontWeight: fontWeightVariants, + fontVariantNumeric: { + 'diagonal-fractions': { fontVariantNumeric: 'diagonal-fractions' }, + ordinal: { fontVariantNumeric: 'ordinal' }, + 'slashed-zero': { fontVariantNumeric: 'slashed-zero' }, + 'tabular-nums': { fontVariantNumeric: 'tabular-nums' }, + unset: { fontVariantNumeric: 'normal' }, + }, isVisuallyHidden: { false: {}, - true: { - position: 'absolute', - transform: 'scale(0)', - }, + true: { position: 'absolute', transform: 'scale(0)' }, }, lineClamp: lineClampVariants, - lineHeight: lineHeightVariants, - shouldDisableTrim: { - false: {}, - true: {}, + shouldDisableTrim: { false: {}, true: {} }, + size: sizeVariants, + textAlign: { + center: { textAlign: 'center' }, + end: { textAlign: 'end' }, + start: { textAlign: 'start' }, }, + textDecoration: { + inherit: { textDecoration: 'inherit' }, + 'line-through': { textDecoration: 'line-through' }, + none: { textDecoration: 'none' }, + underline: { textDecoration: 'underline' }, + }, + textTransform: { + capitalize: { textTransform: 'capitalize' }, + inherit: { textTransform: 'inherit' }, + lowercase: { textTransform: 'lowercase' }, + none: { textTransform: 'none' }, + uppercase: { textTransform: 'uppercase' }, + }, + textWrap: { + balance: { textWrap: 'balance' }, + pretty: { textWrap: 'pretty' }, + unset: {}, + }, + fontWeight: weightVariants, shouldInheritFont: { false: {}, true: { - font: 'inherit', + color: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + fontStyle: 'inherit', + fontWeight: 'inherit', + letterSpacing: 'inherit', + lineHeight: 'inherit', }, }, - textAlign: textAlignVariants, - textDecoration: textDecorationVariants, - textTransform: textTransformVariants, - textWrap: textWrapVariants, + color: colorVariants, }, }); diff --git a/packages/@luke-ui/react/src/text/index.tsx b/packages/@luke-ui/react/src/text/index.tsx index 66aaa005..5f489204 100644 --- a/packages/@luke-ui/react/src/text/index.tsx +++ b/packages/@luke-ui/react/src/text/index.tsx @@ -6,28 +6,18 @@ interface TextVariantProps extends NonNullable {} interface TextStyleProps { /** - * Sets text color. - * @default 'neutralBold' + * Sets text colour. + * @default 'primary' */ color?: TextVariantProps['color']; - /** - * Sets the font family. - * @default 'sans' - */ - fontFamily?: TextVariantProps['fontFamily']; - /** - * Sets the font size. - * @default 'standard' - */ - fontSize?: TextVariantProps['fontSize']; /** * Sets numeric glyph style. * @default 'unset' */ fontVariantNumeric?: TextVariantProps['fontVariantNumeric']; /** - * Sets the font weight. - * @default 'regular' + * Sets the semantic font-weight role. + * @default 'body' */ fontWeight?: TextVariantProps['fontWeight']; /** @@ -38,20 +28,20 @@ interface TextStyleProps { /** Clamps text lines. `true` clamps to 1 line; numeric values clamp to 1–5. */ lineClamp?: TextVariantProps['lineClamp']; /** - * Sets the line height. - * @default 'loose' - */ - lineHeight?: TextVariantProps['lineHeight']; - /** - * Turns off cap-height trim. + * Turns off cap-height trim. Trimming is disabled automatically when `lineClamp` is set. * @default false */ shouldDisableTrim?: TextVariantProps['shouldDisableTrim']; /** - * Makes text inherit font styles. + * Makes text inherit its surrounding font and colour styles. * @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' @@ -82,21 +72,19 @@ interface TextStyleProps { export type TextProps = Omit, keyof TextStyleProps> & TextStyleProps; -/** Styled text component with token-based typography controls. */ +/** Styled text with a coordinated type scale and semantic colour controls. */ export function Text(props: TextProps) { const { children, className, color, - fontFamily, - fontSize, fontVariantNumeric, fontWeight, isVisuallyHidden, lineClamp, - lineHeight, - shouldInheritFont, shouldDisableTrim, + shouldInheritFont, + size, textAlign, textDecoration, textTransform, @@ -112,15 +100,13 @@ export function Text(props: TextProps) { className={cx( styles.text({ color, - fontFamily, - fontSize, fontVariantNumeric, fontWeight, isVisuallyHidden, lineClamp, - lineHeight, shouldDisableTrim: resolvedShouldDisableTrim, shouldInheritFont, + size, textAlign, textDecoration, textTransform, diff --git a/packages/@luke-ui/react/src/text/text.stories.tsx b/packages/@luke-ui/react/src/text/text.stories.tsx index 3ea916c3..80562be7 100644 --- a/packages/@luke-ui/react/src/text/text.stories.tsx +++ b/packages/@luke-ui/react/src/text/text.stories.tsx @@ -1,6 +1,5 @@ import type { TextProps } from '@luke-ui/react/text'; import { Text } from '@luke-ui/react/text'; -import { tokenKeys, tokens } from '@luke-ui/react/tokens'; import { mergeProps } from '@luke-ui/react/utils'; import type { CSSProperties } from 'react'; import { expect } from 'storybook/test'; @@ -62,7 +61,6 @@ function MiddleTruncatedIdentifier(props: MiddleTruncatedIdentifierProps) { {...mergeProps(textProps, row)} aria-label={identifier} elementType="div" - fontFamily="mono" shouldDisableTrim title={identifier} > @@ -81,28 +79,33 @@ function MiddleTruncatedIdentifier(props: MiddleTruncatedIdentifierProps) { const baseArgs = { children: storyText, - fontSize: 'standard', - lineHeight: 'loose', -} as const satisfies Pick; + size: '300', +} as const satisfies Pick; -const colors = [...tokenKeys(tokens.foregroundColor), 'inherit'] satisfies Array< - TextProps['color'] +const colors = [ + 'primary', + 'secondary', + 'accent', + 'info', + 'success', + '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 >; -const fontFamilies = tokenKeys(tokens.fontFamily) satisfies Array; - -const fontSizes = tokenKeys(tokens.fontSize) satisfies Array; - -const headingFontSizes = fontSizes.filter((fontSize) => { - return ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'large', 'xlarge', 'xxlarge'].includes(fontSize); -}); - -const fontWeights = [...tokenKeys(tokens.fontWeight), 'inherit'] satisfies Array< - TextProps['fontWeight'] ->; - -const lineHeights = tokenKeys(tokens.lineHeight) satisfies Array; - export type LineClampOption = NonNullable; const lineClampOptions: ReadonlyArray = [false, true, 1, 2, 3, 4, 5]; @@ -112,38 +115,31 @@ const lineClampOptions: ReadonlyArray = [false, true, 1, 2, 3, export const Default = meta.story({ args: baseArgs, play: async ({ canvas }) => { - await expect(canvas.getByText(/quick brown/)).toBeInTheDocument(); + const element = canvas.getByText(/quick brown/); + const style = getComputedStyle(element); + await expect(style.fontSize).toBe('16px'); + await expect(style.lineHeight).toBe('24px'); + await expect(style.fontWeight).toBe('400'); }, }); /** - * Font size controls text scale for body copy and compact text treatment. + * Each size step applies font size, line height, letter spacing, and trim as one treatment. */ -export const FontSize = meta.story({ - render: (props) => ( -
- {fontSizes.map((fontSize) => ( - - {fontSize}: {storyText} - - ))} -
- ), -}); - -/** - * Larger size tokens are best for heading-like emphasis. - */ -export const LargerFontSize = meta.story({ - args: { - ...baseArgs, - lineHeight: 'tight', - } satisfies Pick, +export const Size = meta.story({ + play: async ({ canvas }) => { + const small = getComputedStyle(canvas.getByText(/^100:/)); + const display = getComputedStyle(canvas.getByText(/^900:/)); + await expect(small.fontSize).toBe('12px'); + await expect(small.lineHeight).toBe('16px'); + await expect(display.fontSize).toBe('60px'); + await expect(display.lineHeight).toBe('60px'); + }, render: (props) => (
- {headingFontSizes.map((fontSize) => ( - - {fontSize}: {storyText} + {sizes.map((size) => ( + + {size}: {storyText} ))}
@@ -151,12 +147,12 @@ export const LargerFontSize = meta.story({ }); /** - * Font weight supports regular, medium, bold, and inherited styles. + * Weight roles let each theme control body, labels, headings, and emphasis coherently. */ -export const FontWeight = meta.story({ +export const Weight = meta.story({ render: (props) => (
- {fontWeights.map((fontWeight) => ( + {weights.map((fontWeight) => ( {fontWeight}: {storyText} @@ -166,37 +162,7 @@ export const FontWeight = meta.story({ }); /** - * Font family tokens allow switching between sans and mono text styles. - */ -export const FontFamily = meta.story({ - render: (props) => ( -
- {fontFamilies.map((fontFamily) => ( - - {fontFamily}: {storyText} - - ))} -
- ), -}); - -/** - * Line height controls rhythm and readability for short and long text. - */ -export const LineHeight = meta.story({ - render: (props) => ( -
- {lineHeights.map((lineHeight) => ( - - {lineHeight}: {loremIpsum} - - ))} -
- ), -}); - -/** - * Color maps to the design-system foreground tokens plus `inherit`. + * Use semantic colours to communicate hierarchy, status, and emphasis consistently across themes. */ export const Color = meta.story({ render: (props) => ( @@ -217,7 +183,7 @@ export const EmptyText = meta.story({ args: { ...baseArgs, children: '', - } satisfies Pick, + } satisfies Pick, render: (props) => , }); @@ -304,14 +270,14 @@ export const MiddleTruncation = meta.story({ }); return (
- + End truncation - + {importantIdentifier} - + Middle truncation ( - + Danger: This action is not reversible. 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 ec2112fb..06f0cb4b 100644 --- a/packages/@luke-ui/react/src/text/text.visual.test.tsx +++ b/packages/@luke-ui/react/src/text/text.visual.test.tsx @@ -1,37 +1,61 @@ import type { CSSProperties } from 'react'; -import { test } from 'vite-plus/test'; -import { captureVisual, renderVisual, Stack } from '../test-utils/render-visual.js'; +import { expect, test } from 'vite-plus/test'; +import { + captureVisual, + captureVisualAppearance, + renderVisual, + Stack, + visualAppearances, +} from '../test-utils/render-visual.js'; import { Text } from './index.js'; const rowStyle = { alignItems: 'baseline', display: 'flex', + flexWrap: 'wrap', gap: '1rem', } satisfies CSSProperties; -test('typography variants', async () => { - const locator = renderVisual( - -
- Small text - Standard text - Large text -
-
- Regular - Medium - Bold -
-
- Subtle - Informative - Critical -
-
, - ); +for (const appearance of visualAppearances) { + test(`type scale: ${appearance.theme} ${appearance.mode}`, async () => { + const locator = renderVisual( + +
+ 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 +
+
+ Body + Label + Heading + Emphasis +
+
+ Primary + Secondary + Accent + Info + Success + Warning + Danger +
+ Trimmed by default + Trim disabled +
, + appearance, + ); + await expect.element(locator).toBeVisible(); - await captureVisual(locator, 'text/typography-variants'); -}); + await captureVisualAppearance(locator, 'text/type-scale', appearance); + }); +} test('line clamp and transforms', async () => { const locator = renderVisual( diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index d4811017..23fa0a3b 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -231,6 +231,21 @@ describe('buildTheme defaults', () => { } expect(css).toContain('--luke-color-border-focus: oklch('); }); + + it('emits Capsize trim values for each curated font family', () => { + const trimValues = (fontFamily: 'inter' | 'apple-system' | 'dm-sans') => { + const css = buildTheme({ ...minimalFoundation, typography: { fontFamily } }); + const identity = splitBlocks(css).identity; + return { + baseline: extractValue(identity, '--luke-font-300-baseline-trim'), + capHeight: extractValue(identity, '--luke-font-300-cap-height-trim'), + }; + }; + + const values = [trimValues('inter'), trimValues('apple-system'), trimValues('dm-sans')]; + expect(new Set(values.map(({ baseline }) => baseline)).size).toBe(3); + expect(new Set(values.map(({ capHeight }) => capHeight)).size).toBe(3); + }); }); describe('buildTheme foundation validation', () => { diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index d3b624e2..231fdbe2 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -1,3 +1,7 @@ +import appleSystemMetrics from '@capsizecss/metrics/appleSystem'; +import dMSansMetrics from '@capsizecss/metrics/dMSans'; +import interMetrics from '@capsizecss/metrics/inter'; +import { precomputeValues } from '@capsizecss/vanilla-extract'; import type { Oklch } from './color.js'; import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js'; import { flattenThemeContract } from './contract.js'; @@ -13,11 +17,11 @@ import { /** * Compiles a theme foundation into a complete static stylesheet. * - * Pure and Node-compatible: no vanilla-extract, no DOM, and deterministic output. Returns - * stylesheet text containing the theme identity class plus both colour-mode blocks, selected by - * `data-color-mode` with `prefers-color-scheme` as the fallback. Throws {@link ThemeContrastError} - * naming the mode and token pair when any generated pair misses WCAG 2.2 AA (4.5:1 for text pairs, - * 3:1 for non-text UI pairs). Colours are computed and emitted in OKLCH. + * Pure and Node-compatible: no DOM and deterministic output. Returns stylesheet text containing + * the theme identity class plus both colour-mode blocks, selected by `data-color-mode` with + * `prefers-color-scheme` as the fallback. Throws {@link ThemeContrastError} naming the mode and + * token pair when any generated pair misses WCAG 2.2 AA (4.5:1 for text pairs, 3:1 for non-text UI + * pairs). Colours are computed and emitted in OKLCH. */ export function buildTheme(foundation: ThemeFoundation): string { validateFoundation(foundation); @@ -153,6 +157,12 @@ const FONT_VALUES = { 'font.900.lineHeight': '60px', } as const; +const FONT_METRICS = { + 'apple-system': appleSystemMetrics, + 'dm-sans': dMSansMetrics, + inter: interMetrics, +} as const; + const ICON_SIZE_VALUES = { 'iconSize.large': '32px', 'iconSize.medium': '24px', @@ -667,6 +677,7 @@ function buildIdentityValues(foundation: ThemeFoundation): Record { + const values: Record = {}; + for (const step of [100, 200, 300, 400, 500, 600, 700, 800, 900] as const) { + const fontSize = Number.parseFloat(FONT_VALUES[`font.${step}.fontSize`]); + const leading = Number.parseFloat(FONT_VALUES[`font.${step}.lineHeight`]); + const { baselineTrim, capHeightTrim } = precomputeValues({ + fontMetrics: FONT_METRICS[fontFamily], + fontSize, + leading, + }); + values[`font.${step}.baselineTrim`] = baselineTrim; + values[`font.${step}.capHeightTrim`] = capHeightTrim; + } + return values; +} + function declarations( pairs: Array<[path: string, varName: string]>, values: Record, diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts index b3bf8dd2..358743ff 100644 --- a/packages/@luke-ui/react/src/theme/contract.test.ts +++ b/packages/@luke-ui/react/src/theme/contract.test.ts @@ -36,8 +36,10 @@ describe('theme contract', () => { expect(countLeaves(vars)).toBe(flattenThemeContract().length); }); - it('exposes composite font steps and the carried-forward icon-size scale', () => { + 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)', diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index f0f99dc9..51731171 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -1,3 +1,11 @@ +const fontStep = { + baselineTrim: null, + capHeightTrim: null, + fontSize: null, + letterSpacing: null, + lineHeight: null, +}; + /** * The semantic token tree shared by the vanilla-extract contract and `buildTheme`, so typed paths * and emitted CSS variable names can never diverge. Leaves are `null`; every path maps to one @@ -94,15 +102,15 @@ export const themeContractTree = { depth: { recessed: null, resting: null, raised: null, floating: null, overlay: null }, actionControlFinish: { recessed: null, resting: null, raised: null }, font: { - 100: { fontSize: null, letterSpacing: null, lineHeight: null }, - 200: { fontSize: null, letterSpacing: null, lineHeight: null }, - 300: { fontSize: null, letterSpacing: null, lineHeight: null }, - 400: { fontSize: null, letterSpacing: null, lineHeight: null }, - 500: { fontSize: null, letterSpacing: null, lineHeight: null }, - 600: { fontSize: null, letterSpacing: null, lineHeight: null }, - 700: { fontSize: null, letterSpacing: null, lineHeight: null }, - 800: { fontSize: null, letterSpacing: null, lineHeight: null }, - 900: { fontSize: null, letterSpacing: null, lineHeight: null }, + 100: { ...fontStep }, + 200: { ...fontStep }, + 300: { ...fontStep }, + 400: { ...fontStep }, + 500: { ...fontStep }, + 600: { ...fontStep }, + 700: { ...fontStep }, + 800: { ...fontStep }, + 900: { ...fontStep }, family: null, weight: { body: null, label: null, heading: null, emphasis: null }, }, diff --git a/packages/@luke-ui/react/src/tokens/converters.ts b/packages/@luke-ui/react/src/tokens/converters.ts index 0f7168c8..8404b2dc 100644 --- a/packages/@luke-ui/react/src/tokens/converters.ts +++ b/packages/@luke-ui/react/src/tokens/converters.ts @@ -11,10 +11,6 @@ export function dimensionToRemString(value: DimensionTokenValue, base: number = return value.unit === 'rem' ? `${value.value}rem` : pxToRem(value.value, base); } -export function dimensionToPxNumber(value: DimensionTokenValue, base: number = 16): number { - return value.unit === 'px' ? value.value : value.value * base; -} - /** Converts a `DurationTokenValue` to a CSS time string. */ export function durationToString(value: DurationTokenValue): string { return `${value.value}${value.unit}`; diff --git a/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx b/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx index 28ea5bff..29eae79d 100644 --- a/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx +++ b/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx @@ -851,10 +851,8 @@ function TokenTypeStory({ return (
- - {title} - - + {title} + {description}