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}