diff --git a/docs/STYLING.md b/docs/STYLING.md index dde119b6..367a002a 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -29,8 +29,9 @@ element. Neither step injects styles at runtime. - `theme/semantic-map.ts`: the one default mapping (`mapSemanticColors`) from generated families and surfaces onto the colour contract's leaves. - `theme/diagnostics.ts`: the `compileTheme` diagnostics data model (family, surface, solid-anchor, - and contrast-check detail) consumed by the "Theme/Diagnostics" and "Theme/Color token board" - Storybook stories. + and contrast-check detail) consumed by the "Theme/Diagnostics" Storybook story. +- `theme/token-board.tsx`: the contract-driven "Theme/Token board" Storybook story, which renders + every contract leaf for the active theme and colour mode. - `theme/build-theme.ts`: the internal `compileTheme(foundation) → { css, diagnostics }` value pipeline, `buildTheme`, `themeClassName`, and contrast validation. - `theme/foundations.ts`: `defineTheme(...)` inputs for the bundled Tactile and Paper themes. diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index ae806e98..c39af7a5 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -24,9 +24,9 @@ Per colour mode, `compileTheme` (in `build-theme.ts`): `compileTheme` returns `{ css, diagnostics }`; `ThemeDiagnostics` records everything the pipeline resolved (both modes' families, surfaces, solid-anchor search, and contrast checks) for tooling. The -Storybook "Theme/Diagnostics" story is a read-only inspector over this data model, and "Theme/Color -token board" renders every `color.*` contract leaf (driven off `flattenThemeContract()`) for both -bundled themes and modes. +Storybook "Theme/Diagnostics" story is a read-only inspector over this data model, and "Theme/Token +board" renders every contract leaf (driven off `flattenThemeContract()`) for both bundled themes and +modes. ## The v2 repaint is expected diff --git a/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx b/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx deleted file mode 100644 index 7af28341..00000000 --- a/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { expect } from 'storybook/test'; -import preview from '../../.storybook/preview.js'; -import { ColorTokenBoard } from './color-token-board.js'; -import { flattenThemeContract } from './contract.js'; - -const meta = preview.meta({ - component: ColorTokenBoard, - tags: ['theme'], - title: 'Theme/Color token board', -}); - -const colorLeafCount = flattenThemeContract().filter(([path]) => path.startsWith('color.')).length; - -/** - * Every `color.*` semantic contract leaf, resolved for the active theme and colour mode. Switch the - * theme and colour mode in the Storybook toolbar to compare. Captured in full by - * `color-token-board.visual.test.tsx` across both bundled themes and modes, so a generator or - * mapping change produces an obvious visual diff even where no component happens to consume the - * changed leaf (theme-v2 #249). - */ -export const Board = meta.story({ - play: async ({ canvasElement }) => { - // Derived from the contract, not hardcoded, so the board's own claim that coverage cannot - // drift out of sync with the contract is actually enforced: this fails the moment a rendered - // swatch count and the contract's colour-leaf count disagree. - const swatches = canvasElement.querySelectorAll('[role="img"]'); - await expect(swatches.length).toBe(colorLeafCount); - }, -}); diff --git a/packages/@luke-ui/react/src/theme/color-token-board.test.ts b/packages/@luke-ui/react/src/theme/color-token-board.test.ts deleted file mode 100644 index 7bf05d84..00000000 --- a/packages/@luke-ui/react/src/theme/color-token-board.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test'; -import type { ColorTreeNode } from './color-token-board.js'; -import { buildColorTree } from './color-token-board.js'; -import { flattenThemeContract } from './contract.js'; - -function collectLeaves(node: ColorTreeNode): Array<{ path: string; varName: string }> { - if (node.kind === 'leaf') return [{ path: node.path, varName: node.varName }]; - return Object.values(node.children).flatMap(collectLeaves); -} - -describe('buildColorTree', () => { - const colorPairs = flattenThemeContract().filter(([path]) => path.startsWith('color.')); - - it('produces exactly one leaf per color.* contract path, derived from the contract', () => { - const leaves = collectLeaves(buildColorTree()); - expect(leaves).toHaveLength(colorPairs.length); - }); - - it('carries every contract path and var name through unchanged', () => { - const byPath = (a: [string, string], b: [string, string]) => a[0].localeCompare(b[0]); - const leaves = collectLeaves(buildColorTree()); - const leavesByPath = new Map(leaves.map((leaf) => [leaf.path, leaf.varName])); - expect([...leavesByPath.entries()].sort(byPath)).toEqual([...colorPairs].sort(byPath)); - }); - - it('nests a top-level colour leaf directly, with no intermediate group', () => { - const scrim = buildColorTree().children.scrim; - expect(scrim).toEqual({ kind: 'leaf', path: 'color.scrim', varName: '--luke-color-scrim' }); - }); - - it('nests a deep intent leaf under its full chain of contract path segments', () => { - const tree = buildColorTree(); - const intent = tree.children.intent; - if (intent?.kind !== 'group') throw new Error('expected an intent group'); - const danger = intent.children.danger; - if (danger?.kind !== 'group') throw new Error('expected a danger group'); - const surface = danger.children.surface; - if (surface?.kind !== 'group') throw new Error('expected a surface group'); - - expect(surface.children.subtle).toEqual({ - kind: 'leaf', - path: 'color.intent.danger.surface.subtle', - varName: '--luke-color-intent-danger-surface-subtle', - }); - }); -}); diff --git a/packages/@luke-ui/react/src/theme/color-token-board.tsx b/packages/@luke-ui/react/src/theme/color-token-board.tsx deleted file mode 100644 index 2821d957..00000000 --- a/packages/@luke-ui/react/src/theme/color-token-board.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Renders every `color.*` semantic contract leaf as a labelled swatch, grouped by the contract's - * own tree shape. Driven entirely by `flattenThemeContract()`: add, rename, or remove a colour leaf - * in `contract.ts` and this board follows automatically, so token coverage cannot silently drift out - * of sync with the contract (theme-v2 #249). Read-only and diagnostic — it introduces no new public - * API and computes no colour itself, it only reads the resolved `--luke-*` custom properties that - * the active theme and colour mode already supply. - */ - -import type { CSSProperties } from 'react'; -import { createElement } from 'react'; -import { vars } from './contract.css.js'; -import { flattenThemeContract } from './contract.js'; - -interface ColorLeafNode { - kind: 'leaf'; - path: string; - varName: string; -} - -export interface ColorGroupNode { - kind: 'group'; - children: Record; -} - -export type ColorTreeNode = ColorLeafNode | ColorGroupNode; - -// Capped at the contract's deepest colour group (color.intent..surface.); depths past -// this reuse the smallest heading. -const HEADING_TAGS = ['h2', 'h3', 'h4', 'h5'] as const; - -function headingTagAt(depth: number): (typeof HEADING_TAGS)[number] { - const index = Math.min(Math.max(depth - 1, 0), HEADING_TAGS.length - 1); - return HEADING_TAGS[index] ?? 'h5'; -} - -const boardStyle = { - display: 'grid', - gap: vars.space[800], -} as const satisfies CSSProperties; - -const groupSectionStyle = { - display: 'grid', - gap: vars.space[300], -} as const satisfies CSSProperties; - -const headingStyle = { - margin: 0, - textTransform: 'capitalize', -} as const satisfies CSSProperties; - -const swatchGridStyle = { - display: 'flex', - flexWrap: 'wrap', - gap: vars.space[300], -} as const satisfies CSSProperties; - -const swatchCardStyle = { - display: 'grid', - gap: vars.space[100], - inlineSize: '9rem', - justifyItems: 'start', -} as const satisfies CSSProperties; - -const swatchBoxStyle = { - blockSize: '2.5rem', - borderColor: vars.color.border.decorative, - borderRadius: vars.radius.detail, - borderStyle: 'solid', - borderWidth: 1, - inlineSize: '100%', -} as const satisfies CSSProperties; - -const swatchLabelStyle = { - fontSize: vars.font[100].fontSize, - fontWeight: vars.font.weight.label, - overflowWrap: 'anywhere', -} as const satisfies CSSProperties; - -const swatchVarStyle = { - color: vars.color.text.secondary, - fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', - fontSize: vars.font[100].fontSize, - overflowWrap: 'anywhere', -} as const satisfies CSSProperties; - -/** - * A semantic-token / scale board covering every `color.*` contract leaf for the active theme and - * colour mode, so a generator or mapping change (`scale.ts`, `elevation.ts`, `semantic-map.ts`) - * produces an obvious visual diff even where no component happens to consume the changed leaf. - */ -export function ColorTokenBoard() { - const tree = buildColorTree(); - return ( -
- {Object.entries(tree.children).map(([key, node]) => ( - - ))} -
- ); -} - -function ColorNodeView({ - depth, - name, - node, -}: { - depth: number; - name: string; - node: ColorTreeNode; -}) { - // A heading tag chosen by tree depth is rendered with `createElement` rather than a capitalised - // JSX tag: assigning the dynamic tag to a variable and rendering it as `` reads as a - // component declared during render (react-hooks-js/static-components), which this is not — it is - // a plain host element whose tag name varies. - const heading = createElement(headingTagAt(depth), { style: headingStyle }, name); - - if (node.kind === 'leaf') { - return ( -
- {heading} -
- -
-
- ); - } - - const entries = Object.entries(node.children); - const leafEntries = entries.filter( - (entry): entry is [string, ColorLeafNode] => entry[1].kind === 'leaf', - ); - const groupEntries = entries.filter( - (entry): entry is [string, ColorGroupNode] => entry[1].kind === 'group', - ); - - return ( -
- {heading} - {leafEntries.length > 0 ? ( -
- {leafEntries.map(([key, leaf]) => ( - - ))} -
- ) : null} - {groupEntries.map(([key, child]) => ( - - ))} -
- ); -} - -function ColorSwatch({ label, path, varName }: { label: string; path: string; varName: string }) { - return ( -
- - {label} - {varName} -
- ); -} - -/** - * Builds the colour subtree from `flattenThemeContract()`'s flat `[path, varName]` pairs, grouped by - * path segment so the board's structure mirrors `contract.ts`'s own nesting exactly. Exported for - * `color-token-board.test.ts`, which unit-tests the grouping logic directly. - */ -export function buildColorTree(): ColorGroupNode { - const root: ColorGroupNode = { children: {}, kind: 'group' }; - for (const [path, varName] of flattenThemeContract()) { - if (!path.startsWith('color.')) continue; - const segments = path.split('.').slice(1); - let cursor = root; - segments.forEach((segment, index) => { - if (index === segments.length - 1) { - cursor.children[segment] = { kind: 'leaf', path, varName }; - return; - } - const existing = cursor.children[segment]; - if (existing?.kind === 'group') { - cursor = existing; - return; - } - const next: ColorGroupNode = { children: {}, kind: 'group' }; - cursor.children[segment] = next; - cursor = next; - }); - } - return root; -} diff --git a/packages/@luke-ui/react/src/theme/color-token-board.visual.test.tsx b/packages/@luke-ui/react/src/theme/color-token-board.visual.test.tsx deleted file mode 100644 index 02969c08..00000000 --- a/packages/@luke-ui/react/src/theme/color-token-board.visual.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, test } from 'vite-plus/test'; -import { - captureVisualAppearance, - renderVisual, - visualAppearances, -} from '../test-utils/render-visual.js'; -import { ColorTokenBoard } from './color-token-board.js'; - -// Captures every `color.*` contract leaf for both bundled themes and modes. Theme v2 repainted -// 34/47 leaves but moved only 5 of ~122 existing captures (#249) — this board makes any future -// generator or semantic-mapping change produce an obvious, intentional diff regardless of whether a -// component happens to consume the changed leaf. -test.each(visualAppearances)('semantic colour token board: $theme $mode', async (appearance) => { - const scene = renderVisual(, appearance); - await expect.element(scene).toBeVisible(); - - await captureVisualAppearance(scene, 'theme/color-token-board', appearance); -}); diff --git a/packages/@luke-ui/react/src/theme/token-board.stories.tsx b/packages/@luke-ui/react/src/theme/token-board.stories.tsx new file mode 100644 index 00000000..29e6adf4 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/token-board.stories.tsx @@ -0,0 +1,29 @@ +import { expect } from 'storybook/test'; +import preview from '../../.storybook/preview.js'; +import { flattenThemeContract } from './contract.js'; +import { TokenBoard } from './token-board.js'; + +const meta = preview.meta({ + component: TokenBoard, + tags: ['theme'], + title: 'Theme/Token board', +}); + +const contractLeafCount = flattenThemeContract().length; + +/** + * Every contract leaf, resolved for the active theme and colour mode. Switch the theme and colour + * mode in the Storybook toolbar to compare. Captured in full by `token-board.visual.test.tsx` across + * both bundled themes and modes, so a generator or mapping change produces an obvious visual diff + * even where no component happens to consume the changed leaf (theme-v2 #249, generalised past + * colour in #257). + */ +export const Board = meta.story({ + play: async ({ canvasElement }) => { + // Derived from the contract, not hardcoded, so the board's own claim that coverage cannot + // drift out of sync with the contract is actually enforced: this fails the moment a rendered + // sample count and the contract's total leaf count disagree. + const swatches = canvasElement.querySelectorAll('[role="img"]'); + await expect(swatches.length).toBe(contractLeafCount); + }, +}); diff --git a/packages/@luke-ui/react/src/theme/token-board.test.ts b/packages/@luke-ui/react/src/theme/token-board.test.ts new file mode 100644 index 00000000..83ff33a6 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/token-board.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { flattenThemeContract } from './contract.js'; +import type { TokenTreeNode } from './token-board.js'; +import { buildTokenTree } from './token-board.js'; + +function collectLeaves(node: TokenTreeNode): Array<{ path: string; varName: string }> { + if (node.kind === 'leaf') return [{ path: node.path, varName: node.varName }]; + return Object.values(node.children).flatMap(collectLeaves); +} + +describe('buildTokenTree', () => { + const contractPairs = flattenThemeContract(); + + it('produces exactly one leaf per contract path, derived from the contract', () => { + const leaves = collectLeaves(buildTokenTree()); + expect(leaves).toHaveLength(contractPairs.length); + }); + + it('carries every contract path and var name through unchanged', () => { + const byPath = (a: [string, string], b: [string, string]) => a[0].localeCompare(b[0]); + const leaves = collectLeaves(buildTokenTree()); + const leavesByPath = new Map(leaves.map((leaf) => [leaf.path, leaf.varName])); + expect([...leavesByPath.entries()].sort(byPath)).toEqual([...contractPairs].sort(byPath)); + }); + + it('nests a top-level colour leaf under its family group, with no intermediate group', () => { + const tree = buildTokenTree(); + const color = tree.children.color; + if (color?.kind !== 'group') throw new Error('expected a color group'); + expect(color.children.scrim).toEqual({ + kind: 'leaf', + path: 'color.scrim', + varName: '--luke-color-scrim', + }); + }); + + it('nests a deep intent leaf under its full chain of contract path segments', () => { + const tree = buildTokenTree(); + const color = tree.children.color; + if (color?.kind !== 'group') throw new Error('expected a color group'); + const intent = color.children.intent; + if (intent?.kind !== 'group') throw new Error('expected an intent group'); + const danger = intent.children.danger; + if (danger?.kind !== 'group') throw new Error('expected a danger group'); + const surface = danger.children.surface; + if (surface?.kind !== 'group') throw new Error('expected a surface group'); + + expect(surface.children.subtle).toEqual({ + kind: 'leaf', + path: 'color.intent.danger.surface.subtle', + varName: '--luke-color-intent-danger-surface-subtle', + }); + }); + + it('groups every non-colour family at the top level too', () => { + const tree = buildTokenTree(); + const nonColorFamilies = new Set( + contractPairs + .map(([path]) => path.split('.')[0]) + .filter((family): family is string => family !== undefined && family !== 'color'), + ); + for (const family of nonColorFamilies) { + expect(Object.keys(tree.children)).toContain(family); + } + }); + + it('nests a motion leaf under its duration or easing group', () => { + const tree = buildTokenTree(); + const motion = tree.children.motion; + if (motion?.kind !== 'group') throw new Error('expected a motion group'); + const duration = motion.children.duration; + if (duration?.kind !== 'group') throw new Error('expected a duration group'); + + expect(duration.children.fast).toEqual({ + kind: 'leaf', + path: 'motion.duration.fast', + varName: '--luke-motion-duration-fast', + }); + }); +}); diff --git a/packages/@luke-ui/react/src/theme/token-board.tsx b/packages/@luke-ui/react/src/theme/token-board.tsx new file mode 100644 index 00000000..3c56e46f --- /dev/null +++ b/packages/@luke-ui/react/src/theme/token-board.tsx @@ -0,0 +1,522 @@ +/** + * Renders every contract leaf as a labelled sample, grouped by the contract's own tree shape, + * including the top-level family it belongs to (`color`, `depth`, `font`, and so on). Driven + * entirely by `flattenThemeContract()`: add, rename, or remove a leaf in `contract.ts` and this + * board follows automatically, so token coverage cannot silently drift out of sync with the + * contract (theme-v2 #249, generalised past colour in #257). Read-only and diagnostic — it + * introduces no new public API and computes no value itself, it only reads the resolved `--luke-*` + * custom properties that the active theme and colour mode already supply. + */ + +import type { CSSProperties, ReactNode } from 'react'; +import { createElement, useLayoutEffect, useRef, useState } from 'react'; +import { vars } from './contract.css.js'; +import { flattenThemeContract, themeVarName } from './contract.js'; +import type { themeContractTree } from './contract.js'; + +interface TokenLeafNode { + kind: 'leaf'; + path: string; + varName: string; +} + +export interface TokenGroupNode { + kind: 'group'; + children: Record; +} + +export type TokenTreeNode = TokenLeafNode | TokenGroupNode; + +/** One of the contract's own top-level branches, for example `color` or `motion`. */ +type Family = keyof typeof themeContractTree; + +// Capped at the contract's deepest group (color.intent..surface., 4 levels above the +// leaf); depths past this reuse the smallest heading. +const HEADING_TAGS = ['h2', 'h3', 'h4', 'h5'] as const; + +function headingTagAt(depth: number): (typeof HEADING_TAGS)[number] { + const index = Math.min(Math.max(depth - 1, 0), HEADING_TAGS.length - 1); + return HEADING_TAGS[index] ?? 'h5'; +} + +/** `actionControlFinish` reads as one word; splits it (and others like it) into heading-sized words. */ +function humanizeSegment(segment: string): string { + return segment.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); +} + +const boardStyle = { + display: 'grid', + gap: vars.space[800], +} as const satisfies CSSProperties; + +const groupSectionStyle = { + display: 'grid', + gap: vars.space[300], +} as const satisfies CSSProperties; + +const headingStyle = { + margin: 0, + textTransform: 'capitalize', +} as const satisfies CSSProperties; + +const swatchGridStyle = { + display: 'flex', + flexWrap: 'wrap', + gap: vars.space[300], +} as const satisfies CSSProperties; + +const swatchCardStyle = { + display: 'grid', + gap: vars.space[100], + inlineSize: '9rem', + justifyItems: 'start', +} as const satisfies CSSProperties; + +const swatchBoxStyle = { + blockSize: '2.5rem', + borderColor: vars.color.border.decorative, + borderRadius: vars.radius.detail, + borderStyle: 'solid', + borderWidth: 1, + inlineSize: '100%', +} as const satisfies CSSProperties; + +const previewFrameStyle = { + alignItems: 'center', + blockSize: '2.5rem', + display: 'flex', + inlineSize: '100%', + justifyContent: 'flex-start', + overflow: 'hidden', +} as const satisfies CSSProperties; + +const swatchLabelStyle = { + fontSize: vars.font[100].fontSize, + fontWeight: vars.font.weight.label, + overflowWrap: 'anywhere', +} as const satisfies CSSProperties; + +const swatchVarStyle = { + color: vars.color.text.secondary, + fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', + fontSize: vars.font[100].fontSize, + overflowWrap: 'anywhere', +} as const satisfies CSSProperties; + +const motionKeyframesStyle = `@keyframes luke-token-board-motion { from { transform: translateX(0); } to { transform: translateX(2.5rem); } } @media (prefers-reduced-motion: reduce) { [data-token-board-motion] { animation: none !important; } }`; + +// Fixed counterparts for whichever motion axis a given leaf is not itself demonstrating, so a +// duration sample still has a easing curve to move along and vice versa. +const fallbackMotionDuration = '0.6s'; +const fallbackMotionEasing = 'ease-in-out'; + +/** + * A semantic-token board covering every contract leaf for the active theme and colour mode, so a + * generator or mapping change (`scale.ts`, `elevation.ts`, `semantic-map.ts`, `build-theme.ts`) + * produces an obvious visual diff even where no component happens to consume the changed leaf. + */ +export function TokenBoard() { + const tree = buildTokenTree(); + return ( +
+ + {Object.entries(tree.children).map(([key, node]) => ( + + ))} +
+ ); +} + +function TokenNodeView({ + depth, + name, + node, +}: { + depth: number; + name: string; + node: TokenTreeNode; +}) { + // A heading tag chosen by tree depth is rendered with `createElement` rather than a capitalised + // JSX tag: assigning the dynamic tag to a variable and rendering it as `` reads as a + // component declared during render (react-hooks-js/static-components), which this is not — it is + // a plain host element whose tag name varies. + const heading = createElement( + headingTagAt(depth), + { style: headingStyle }, + humanizeSegment(name), + ); + + if (node.kind === 'leaf') { + return ( +
+ {heading} +
+ +
+
+ ); + } + + const entries = Object.entries(node.children); + const leafEntries = entries.filter( + (entry): entry is [string, TokenLeafNode] => entry[1].kind === 'leaf', + ); + const groupEntries = entries.filter( + (entry): entry is [string, TokenGroupNode] => entry[1].kind === 'group', + ); + + return ( +
+ {heading} + {leafEntries.length > 0 ? ( +
+ {leafEntries.map(([key, leaf]) => ( + + ))} +
+ ) : null} + {groupEntries.map(([key, child]) => ( + + ))} +
+ ); +} + +function TokenSwatch({ label, path, varName }: { label: string; path: string; varName: string }) { + return ( +
+ + {label} + + {varName} +
+ ); +} + +/** + * Reads the live resolved value of `varName` off the swatch's own theme root, rather than the + * design-time value a `vars.*` reference would print, so it tracks a colour-mode toggle without a + * remount. Re-reads on any class or `data-color-mode` change to the nearest `[data-color-mode]` + * ancestor. + */ +function ResolvedValue({ varName }: { varName: string }) { + const ref = useRef(null); + const [resolvedValue, setResolvedValue] = useState(''); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + const updateValue = () => { + const nextValue = getComputedStyle(element).getPropertyValue(varName).trim(); + setResolvedValue((currentValue) => (currentValue === nextValue ? currentValue : nextValue)); + }; + const themeRoot = element.closest('[data-color-mode]'); + const observer = new MutationObserver(updateValue); + + updateValue(); + if (themeRoot) { + observer.observe(themeRoot, { + attributeFilter: ['class', 'data-color-mode'], + attributes: true, + }); + } + + return () => observer.disconnect(); + }, [varName]); + + return ( + + {resolvedValue || 'Resolving…'} + + ); +} + +interface LeafPreviewProps { + path: string; + segments: ReadonlyArray; + varName: string; +} + +type PreviewRenderer = (props: LeafPreviewProps) => ReactNode; + +function ColorPreview({ path, varName }: LeafPreviewProps) { + return ( + + ); +} + +function DepthPreview({ path, varName }: LeafPreviewProps) { + return ( + + ); +} + +function FinishPreview({ path, varName }: LeafPreviewProps) { + return ( + + ); +} + +function RadiusPreview({ path, varName }: LeafPreviewProps) { + return ( + + ); +} + +function SpacePreview({ path, varName }: LeafPreviewProps) { + return ( + + + + ); +} + +/** Shared by `controlSize` and `iconSize`: both are box dimensions, sized directly from the token. */ +function SizePreview({ path, varName }: LeafPreviewProps) { + return ( + + + + ); +} + +function MotionPreview({ path, segments, varName }: LeafPreviewProps) { + // `motion.duration.fast` and `motion.easing.standard` each drive one animation axis; the other + // axis takes a fixed, non-token value purely so the dot has something to animate along. + const axis = segments[1]; + const duration = axis === 'duration' ? `var(${varName})` : fallbackMotionDuration; + const easing = axis === 'easing' ? `var(${varName})` : fallbackMotionEasing; + + return ( + + + + ); +} + +function TextSample({ + children = 'Aa', + label, + style, + wrap = false, +}: { + children?: ReactNode; + label: string; + style: CSSProperties; + wrap?: boolean; +}) { + return ( + + + {children} + + + ); +} + +/** + * `baselineTrim` and `capHeightTrim` are capsize margin offsets applied to a `::before`/`::after` + * pair in `recipes/text.css.ts`; a plain swatch cannot show a margin, so this applies the trim + * directly to a bar's block-start or block-end margin instead. + */ +function TrimSample({ + label, + marginProperty, + varName, +}: { + label: string; + marginProperty: 'marginBlockEnd' | 'marginBlockStart'; + varName: string; +}) { + return ( + + + + ); +} + +function FontPreview({ path, segments, varName }: LeafPreviewProps) { + const [, second, third] = segments; + const label = `${path} sample`; + + if (second === 'family') + return ; + if (second === 'weight') + return ; + + // `second` is a font-size-step key here (`fontSizeSteps` in contract.ts); every step carries the + // same five sub-properties (see `fontStep` in contract.ts), so the step's own fontSize var sizes + // every other sub-property's sample legibly. `themeVarName` (from contract.ts) builds that sibling + // reference the same way the contract builds every variable name, rather than re-deriving it here. + const fontSizeVarName = themeVarName(['font', second ?? '', 'fontSize']); + + if (third === 'fontSize') + return ; + if (third === 'lineHeight') { + return ( + + Aa Aa + + ); + } + if (third === 'letterSpacing') { + return ( + + ); + } + + const marginProperty = third === 'baselineTrim' ? 'marginBlockStart' : 'marginBlockEnd'; + return ; +} + +/** + * Previews a plain colour swatch cannot express — motion, spacing, radius, depth, and control-finish + * tokens each need a family-specific visual — keyed by the contract's own top-level family names. + * Typed as `Record` rather than `Partial`, so a ninth top-level family added to + * `contract.ts` is a type error here until this board grows a renderer for it too. + */ +const familyPreviews: Record = { + actionControlFinish: FinishPreview, + color: ColorPreview, + controlSize: SizePreview, + depth: DepthPreview, + font: FontPreview, + iconSize: SizePreview, + motion: MotionPreview, + radius: RadiusPreview, + space: SpacePreview, +}; + +function TokenPreview({ path, varName }: { path: string; varName: string }) { + const segments = path.split('.'); + const family = segments[0] as Family; + const Preview = familyPreviews[family]; + return ; +} + +/** + * Builds the whole token tree from `flattenThemeContract()`'s flat `[path, varName]` pairs, grouped + * by path segment so the board's structure mirrors `contract.ts`'s own nesting exactly, including its + * top-level families. Exported for `token-board.test.ts`, which unit-tests the grouping logic + * directly. + */ +export function buildTokenTree(): TokenGroupNode { + const root: TokenGroupNode = { children: {}, kind: 'group' }; + for (const [path, varName] of flattenThemeContract()) { + const segments = path.split('.'); + let cursor = root; + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor.children[segment] = { kind: 'leaf', path, varName }; + return; + } + const existing = cursor.children[segment]; + if (existing?.kind === 'group') { + cursor = existing; + return; + } + const next: TokenGroupNode = { children: {}, kind: 'group' }; + cursor.children[segment] = next; + cursor = next; + }); + } + return root; +} diff --git a/packages/@luke-ui/react/src/theme/token-board.visual.test.tsx b/packages/@luke-ui/react/src/theme/token-board.visual.test.tsx new file mode 100644 index 00000000..bc3a9a35 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/token-board.visual.test.tsx @@ -0,0 +1,19 @@ +import { expect, test } from 'vite-plus/test'; +import { + captureVisualAppearance, + renderVisual, + visualAppearances, +} from '../test-utils/render-visual.js'; +import { TokenBoard } from './token-board.js'; + +// Captures every contract leaf for both bundled themes and modes. Theme v2 repainted 34/47 colour +// leaves but moved only 5 of ~122 existing captures (#249); generalising the board past colour in +// #257 closes the remaining gap, where the other 81 leaves previously had no visual capture at all — +// this board makes any future generator or semantic-mapping change produce an obvious, intentional +// diff regardless of whether a component happens to consume the changed leaf. +test.each(visualAppearances)('token board: $theme $mode', async (appearance) => { + const scene = renderVisual(, appearance); + await expect.element(scene).toBeVisible(); + + await captureVisualAppearance(scene, 'theme/token-board', appearance); +}); diff --git a/packages/@luke-ui/react/src/theme/tokens.stories.tsx b/packages/@luke-ui/react/src/theme/tokens.stories.tsx deleted file mode 100644 index 6feb5734..00000000 --- a/packages/@luke-ui/react/src/theme/tokens.stories.tsx +++ /dev/null @@ -1,939 +0,0 @@ -import { vars } from '@luke-ui/react/theme'; -import type { CSSProperties, ReactNode } from 'react'; -import { useLayoutEffect, useRef, useState } from 'react'; -import { expect } from 'storybook/test'; -import preview from '../../.storybook/preview.js'; - -interface ColorSample { - label: string; - value: string; -} - -interface FontStep { - baselineTrim: string; - capHeightTrim: string; - fontSize: string; - letterSpacing: string; - lineHeight: string; -} - -interface IntentSample { - key: string; - label: string; - roles: Array; - surfaces: Array; -} - -interface IntentRoleSample extends ColorSample { - background?: string; - key: string; - preview: 'border' | 'onSolid' | 'text'; -} - -interface IntentSurfaceVars { - solid: string; - solidHover: string; - solidPressed: string; - subtle: string; - subtleHover: string; - subtlePressed: string; -} - -interface MotionSample { - accessibleLabel: string; - duration: string; - durationKey: string; - easing: string; - easingKey: string; - label: string; -} - -interface TokenDatum { - path: string; - value: string; -} - -interface TokenRow { - key: string; - preview: ReactNode; - values: Array; -} - -interface TokenSectionProps { - children: ReactNode; - title: string; -} - -interface TokenTableProps { - caption: string; - rows: Array; -} - -interface ValueSample { - label: string; - value: string; -} - -const surfaceSamples: Array = [ - { label: 'Canvas', value: vars.color.surface.canvas }, - { label: 'Recessed', value: vars.color.surface.recessed }, - { label: 'Floating', value: vars.color.surface.floating }, - { label: 'Overlay', value: vars.color.surface.overlay }, - { label: 'Scrim', value: vars.color.scrim }, - { label: 'Loading skeleton', value: vars.color.loadingSkeleton }, -]; - -const textSamples: Array = [ - { label: 'Primary', value: vars.color.text.primary }, - { label: 'Secondary', value: vars.color.text.secondary }, - { label: 'Disabled', value: vars.color.text.disabled }, -]; - -const borderSamples: Array = [ - { label: 'Decorative', value: vars.color.border.decorative }, - { label: 'Control', value: vars.color.border.control }, - { label: 'Focus', value: vars.color.border.focus }, -]; - -const intentSamples: Array = [ - { - key: 'neutral', - label: 'Neutral', - roles: [ - { - background: vars.color.intent.neutral.surface.solid, - key: 'onSolid', - label: 'On solid', - preview: 'onSolid', - value: vars.color.intent.neutral.onSolid, - }, - ], - surfaces: intentSurfaceSamples(vars.color.intent.neutral.surface), - }, - { - key: 'accent', - label: 'Accent', - roles: [ - { key: 'border', label: 'Border', preview: 'border', value: vars.color.intent.accent.border }, - { key: 'text', label: 'Text', preview: 'text', value: vars.color.intent.accent.text }, - { - key: 'textHover', - label: 'Text hover', - preview: 'text', - value: vars.color.intent.accent.textHover, - }, - { - background: vars.color.intent.accent.surface.solid, - key: 'onSolid', - label: 'On solid', - preview: 'onSolid', - value: vars.color.intent.accent.onSolid, - }, - ], - surfaces: intentSurfaceSamples(vars.color.intent.accent.surface), - }, - { - key: 'info', - label: 'Info', - roles: feedbackRoles(vars.color.intent.info.border, vars.color.intent.info.text), - surfaces: [{ label: 'Subtle', value: vars.color.intent.info.surface.subtle }], - }, - { - key: 'success', - label: 'Success', - roles: feedbackRoles(vars.color.intent.success.border, vars.color.intent.success.text), - surfaces: [{ label: 'Subtle', value: vars.color.intent.success.surface.subtle }], - }, - { - key: 'warning', - label: 'Warning', - roles: feedbackRoles(vars.color.intent.warning.border, vars.color.intent.warning.text), - surfaces: [{ label: 'Subtle', value: vars.color.intent.warning.surface.subtle }], - }, - { - key: 'danger', - label: 'Danger', - roles: intentRoles( - vars.color.intent.danger.border, - vars.color.intent.danger.text, - vars.color.intent.danger.onSolid, - vars.color.intent.danger.surface.solid, - ), - surfaces: intentSurfaceSamples(vars.color.intent.danger.surface), - }, -]; - -const depthSamples: Array = [ - { label: 'Recessed', value: vars.depth.recessed }, - { label: 'Resting', value: vars.depth.resting }, - { label: 'Raised', value: vars.depth.raised }, - { label: 'Floating', value: vars.depth.floating }, - { label: 'Overlay', value: vars.depth.overlay }, -]; - -const finishSamples: Array = [ - { label: 'Recessed', value: vars.actionControlFinish.recessed }, - { label: 'Resting', value: vars.actionControlFinish.resting }, - { label: 'Raised', value: vars.actionControlFinish.raised }, -]; - -const fontSamples = [ - { label: '100', step: vars.font[100] }, - { label: '200', step: vars.font[200] }, - { label: '300', step: vars.font[300] }, - { label: '400', step: vars.font[400] }, - { label: '500', step: vars.font[500] }, - { label: '600', step: vars.font[600] }, - { label: '700', step: vars.font[700] }, - { label: '800', step: vars.font[800] }, - { label: '900', step: vars.font[900] }, -] as const satisfies ReadonlyArray<{ label: string; step: FontStep }>; - -const fontWeightSamples: Array = [ - { label: 'Body', value: vars.font.weight.body }, - { label: 'Label', value: vars.font.weight.label }, - { label: 'Heading', value: vars.font.weight.heading }, - { label: 'Emphasis', value: vars.font.weight.emphasis }, -]; - -const spaceSamples: Array = [ - { label: '100', value: vars.space[100] }, - { label: '200', value: vars.space[200] }, - { label: '300', value: vars.space[300] }, - { label: '400', value: vars.space[400] }, - { label: '600', value: vars.space[600] }, - { label: '800', value: vars.space[800] }, - { label: '1000', value: vars.space[1000] }, - { label: '1200', value: vars.space[1200] }, - { label: '1600', value: vars.space[1600] }, -]; - -const radiusSamples: Array = [ - { label: 'Detail', value: vars.radius.detail }, - { label: 'Control', value: vars.radius.control }, - { label: 'Surface', value: vars.radius.surface }, - { label: 'Overlay', value: vars.radius.overlay }, - { label: 'Full', value: vars.radius.full }, -]; - -const sizeSamples = [ - { group: 'controlSize', label: 'Control small', token: 'small', value: vars.controlSize.small }, - { - group: 'controlSize', - label: 'Control medium', - token: 'medium', - value: vars.controlSize.medium, - }, - { group: 'iconSize', label: 'Icon xsmall', token: 'xsmall', value: vars.iconSize.xsmall }, - { group: 'iconSize', label: 'Icon small', token: 'small', value: vars.iconSize.small }, - { group: 'iconSize', label: 'Icon medium', token: 'medium', value: vars.iconSize.medium }, - { group: 'iconSize', label: 'Icon large', token: 'large', value: vars.iconSize.large }, -] as const; - -const motionSamples: Array = [ - { - accessibleLabel: 'Fast standard motion sample', - duration: vars.motion.duration.fast, - durationKey: 'fast', - easing: vars.motion.easing.standard, - easingKey: 'standard', - label: 'Fast / standard', - }, - { - accessibleLabel: 'Fast exit motion sample', - duration: vars.motion.duration.fast, - durationKey: 'fast', - easing: vars.motion.easing.exit, - easingKey: 'exit', - label: 'Fast / exit', - }, -]; - -const meta = preview.meta({ - component: ThemeTokens, - tags: ['theme'], - title: 'Theme/Tokens', -}); - -const pageStyle = { - display: 'grid', - gap: vars.space[1000], - inlineSize: '100%', - marginInline: 'auto', - maxInlineSize: '80rem', -} as const satisfies CSSProperties; - -const sectionStyle = { - display: 'grid', - gap: vars.space[300], -} as const satisfies CSSProperties; - -const tableWrapStyle = { - borderColor: vars.color.border.decorative, - borderRadius: vars.radius.surface, - borderStyle: 'solid', - borderWidth: 1, - inlineSize: '100%', - overflow: 'auto', -} as const satisfies CSSProperties; - -const tableStyle = { - borderCollapse: 'collapse', - fontSize: vars.font[200].fontSize, - inlineSize: '100%', - minInlineSize: '54rem', - tableLayout: 'fixed', -} as const satisfies CSSProperties; - -const headerCellStyle = { - backgroundColor: vars.color.surface.recessed, - borderBlockEndColor: vars.color.border.decorative, - borderBlockEndStyle: 'solid', - borderBlockEndWidth: 1, - fontWeight: vars.font.weight.label, - paddingBlock: vars.space[300], - paddingInline: vars.space[400], - textAlign: 'start', -} as const satisfies CSSProperties; - -const cellStyle = { - borderBlockEndColor: vars.color.border.decorative, - borderBlockEndStyle: 'solid', - borderBlockEndWidth: 1, - overflowWrap: 'anywhere', - paddingBlock: vars.space[300], - paddingInline: vars.space[400], - verticalAlign: 'middle', -} as const satisfies CSSProperties; - -const codeStyle = { - fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', - fontSize: vars.font[100].fontSize, - lineHeight: vars.font[100].lineHeight, -} as const satisfies CSSProperties; - -const valueListStyle = { - display: 'grid', - gap: vars.space[100], -} as const satisfies CSSProperties; - -const previewFrameStyle = { - alignItems: 'center', - blockSize: '2.5rem', - display: 'flex', - inlineSize: '7rem', - justifyContent: 'center', -} as const satisfies CSSProperties; - -const swatchStyle = { - blockSize: '2rem', - borderColor: vars.color.border.decorative, - borderRadius: vars.radius.detail, - borderStyle: 'solid', - borderWidth: 1, - display: 'inline-block', - inlineSize: '6rem', -} as const satisfies CSSProperties; - -/** - * Compact visual reference for every public semantic variable. Change the theme and colour mode in - * the Storybook toolbar to compare both the previews and their resolved CSS values. - */ -export const Reference = meta.story({ - play: async ({ canvas, canvasElement }) => { - const accentSubtle = canvas.getByRole('img', { name: 'Accent subtle colour sample' }); - const accentSubtleHover = canvas.getByRole('img', { - name: 'Accent subtle hover colour sample', - }); - const accentSubtlePressed = canvas.getByRole('img', { - name: 'Accent subtle pressed colour sample', - }); - await expect(getComputedStyle(accentSubtle).backgroundColor).not.toBe( - getComputedStyle(accentSubtleHover).backgroundColor, - ); - await expect(getComputedStyle(accentSubtleHover).backgroundColor).not.toBe( - getComputedStyle(accentSubtlePressed).backgroundColor, - ); - - const primaryText = canvas.getByRole('img', { name: 'Primary text colour sample' }); - const disabledText = canvas.getByRole('img', { name: 'Disabled text colour sample' }); - await expect(getComputedStyle(primaryText).backgroundColor).not.toBe( - getComputedStyle(disabledText).backgroundColor, - ); - - const controlBorder = canvas.getByRole('img', { name: 'Control border colour sample' }); - const focusBorder = canvas.getByRole('img', { name: 'Focus border colour sample' }); - await expect(getComputedStyle(controlBorder).borderColor).not.toBe( - getComputedStyle(focusBorder).borderColor, - ); - - const depthOverlay = canvas.getByRole('img', { name: 'Overlay depth sample' }); - await expect(getComputedStyle(depthOverlay).boxShadow).not.toBe('none'); - - const font100 = canvas.getByRole('img', { name: 'Font 100 size sample' }); - const font900 = canvas.getByRole('img', { name: 'Font 900 size sample' }); - await expect(Number.parseFloat(getComputedStyle(font900).fontSize)).toBeGreaterThan( - Number.parseFloat(getComputedStyle(font100).fontSize), - ); - const bodyWeight = canvas.getByText('Body weight'); - const emphasisWeight = canvas.getByText('Emphasis weight'); - await expect(Number(getComputedStyle(emphasisWeight).fontWeight)).toBeGreaterThan( - Number(getComputedStyle(bodyWeight).fontWeight), - ); - - const space100 = canvas.getByRole('img', { name: 'Space 100 sample' }); - const space1600 = canvas.getByRole('img', { name: 'Space 1600 sample' }); - await expect(space1600.getBoundingClientRect().width).toBeGreaterThan( - space100.getBoundingClientRect().width, - ); - - const mediumControl = canvas.getByRole('img', { name: 'Control medium size sample' }); - await expect(getComputedStyle(mediumControl).blockSize).toBe('40px'); - - const fastMotion = canvas.getByRole('img', { name: 'Fast standard motion sample' }); - await expect(getComputedStyle(fastMotion).animationDuration).toBe('0.12s'); - - const resolvedCanvas = canvasElement.querySelector('[data-token-path="color.surface.canvas"]'); - await expect(resolvedCanvas).not.toBeNull(); - await expect(resolvedCanvas?.textContent).not.toContain('var('); - }, -}); - -function ThemeTokens() { - return ( -
- -
-

Theme tokens

-

- Public semantic variables resolved from the active theme and colour mode. -

-
- - - { - const path = surfacePath(sample.label); - return tokenRow( - path, - , - sample.value, - ); - })} - /> - - - - { - const path = `color.text.${toKey(sample.label)}`; - return tokenRow( - path, - , - sample.value, - ); - })} - /> - - - - { - const path = `color.border.${toKey(sample.label)}`; - return tokenRow( - path, - , - sample.value, - ); - })} - /> - - - - {intentSamples.map((intent) => ( -
-

{intent.label}

- { - const surfaceKey = toKey(sample.label); - const path = `color.intent.${intent.key}.surface.${surfaceKey}`; - return tokenRow( - path, - , - sample.value, - ); - }), - ...intent.roles.map((sample) => { - const path = `color.intent.${intent.key}.${sample.key}`; - return tokenRow( - path, - , - sample.value, - ); - }), - ]} - /> -
- ))} -
- - - { - const path = `depth.${toKey(sample.label)}`; - return tokenRow( - path, - , - sample.value, - ); - }), - ...finishSamples.map((sample) => { - const path = `actionControlFinish.${toKey(sample.label)}`; - return tokenRow( - path, - , - sample.value, - ); - }), - ]} - /> - - - - Sphinx of black quartz, - vars.font.family, - ), - ...fontWeightSamples.map((sample) => - tokenRow( - `font.weight.${toKey(sample.label)}`, - {`${sample.label} weight`}, - sample.value, - ), - ), - ]} - /> - ({ - key: `font.${label}`, - preview: ( - - Aa - - ), - values: fontStepData(label, step), - }))} - /> - - - - - tokenRow( - `space.${sample.label}`, - , - sample.value, - ), - )} - /> - - - - - tokenRow( - `radius.${toKey(sample.label)}`, - , - sample.value, - ), - )} - /> - - - - - tokenRow( - `${sample.group}.${sample.token}`, - , - sample.value, - ), - )} - /> - - - - ({ - key: sample.label, - preview: , - values: [ - { path: `motion.duration.${sample.durationKey}`, value: sample.duration }, - { path: `motion.easing.${sample.easingKey}`, value: sample.easing }, - ], - }))} - /> - -
- ); -} - -function TokenSection({ children, title }: TokenSectionProps) { - return ( -
-

{title}

- {children} -
- ); -} - -function TokenTable({ caption, rows }: TokenTableProps) { - return ( -
- - - - - - - - - - - - - - - - - - {rows.map((row) => ( - - - - - - - ))} - -
- {caption} -
- Preview - - Semantic token - - Resolved value - - CSS variable -
{row.preview} -
- {row.values.map((datum) => ( - - {datum.path} - - ))} -
-
-
- {row.values.map((datum) => ( - - ))} -
-
-
- {row.values.map((datum) => ( - - {customPropertyName(datum.value)} - - ))} -
-
-
- ); -} - -function ResolvedValue({ path, value }: { path: string; value: string }) { - const ref = useRef(null); - const [resolvedValue, setResolvedValue] = useState(''); - - useLayoutEffect(() => { - if (!ref.current) return; - const element = ref.current; - const updateValue = () => { - const propertyName = customPropertyName(value); - const nextValue = propertyName.startsWith('--') - ? getComputedStyle(element).getPropertyValue(propertyName).trim() - : value; - setResolvedValue((currentValue) => (currentValue === nextValue ? currentValue : nextValue)); - }; - const themeRoot = element.closest('[data-color-mode]'); - const observer = new MutationObserver(updateValue); - - updateValue(); - if (themeRoot) { - observer.observe(themeRoot, { - attributeFilter: ['class', 'data-color-mode'], - attributes: true, - }); - } - - return () => observer.disconnect(); - }, [value]); - - return ( - - {resolvedValue || 'Resolving…'} - - ); -} - -function ColorPreview({ label, value }: { label: string; value: string }) { - return ; -} - -function TextPreview({ label, value }: { label: string; value: string }) { - return ; -} - -function BorderPreview({ label, value }: { label: string; value: string }) { - return ( - - ); -} - -function IntentRolePreview({ - intentLabel, - sample, -}: { - intentLabel: string; - sample: IntentRoleSample; -}) { - if (sample.preview === 'border') { - return ; - } - - return ( - - Aa - - ); -} - -function DepthPreview({ label, value }: { label: string; value: string }) { - return ( - - ); -} - -function FinishPreview({ label, value }: { label: string; value: string }) { - return ( - - ); -} - -function SpacePreview({ label, value }: { label: string; value: string }) { - return ( - - - - ); -} - -function RadiusPreview({ label, value }: { label: string; value: string }) { - return ( - - ); -} - -function SizePreview({ label, value }: { label: string; value: string }) { - return ( - - - - ); -} - -function MotionPreview({ sample }: { sample: MotionSample }) { - return ( - - - - ); -} - -function tokenRow(path: string, previewNode: ReactNode, value: string): TokenRow { - return { key: path, preview: previewNode, values: [{ path, value }] }; -} - -function fontStepData(label: string, step: FontStep): Array { - return [ - { path: `font.${label}.fontSize`, value: step.fontSize }, - { path: `font.${label}.lineHeight`, value: step.lineHeight }, - { path: `font.${label}.letterSpacing`, value: step.letterSpacing }, - { path: `font.${label}.baselineTrim`, value: step.baselineTrim }, - { path: `font.${label}.capHeightTrim`, value: step.capHeightTrim }, - ]; -} - -function intentSurfaceSamples(surface: IntentSurfaceVars): Array { - return [ - { label: 'Subtle', value: surface.subtle }, - { label: 'Subtle hover', value: surface.subtleHover }, - { label: 'Subtle pressed', value: surface.subtlePressed }, - { label: 'Solid', value: surface.solid }, - { label: 'Solid hover', value: surface.solidHover }, - { label: 'Solid pressed', value: surface.solidPressed }, - ]; -} - -function intentRoles( - border: string, - text: string, - onSolid: string, - solid: string, -): Array { - return [ - { key: 'border', label: 'Border', preview: 'border', value: border }, - { key: 'text', label: 'Text', preview: 'text', value: text }, - { background: solid, key: 'onSolid', label: 'On solid', preview: 'onSolid', value: onSolid }, - ]; -} - -/** Roles for a static feedback intent: soft border and text only (no solid surface or onSolid). */ -function feedbackRoles(border: string, text: string): Array { - return [ - { key: 'border', label: 'Border', preview: 'border', value: border }, - { key: 'text', label: 'Text', preview: 'text', value: text }, - ]; -} - -function customPropertyName(value: string): string { - const match = /^var\((--[^,)]+)/.exec(value); - return match?.[1] ?? value; -} - -function surfacePath(label: string): string { - if (label === 'Scrim') return 'color.scrim'; - if (label === 'Loading skeleton') return 'color.loadingSkeleton'; - return `color.surface.${toKey(label)}`; -} - -function toKey(label: string): string { - return label - .toLowerCase() - .replaceAll(/\s+(.)/g, (_match, letter: string) => letter.toUpperCase()); -}