+
This text is truncated with an ellipsis when it exceeds the max-inline-size of 20rem.
Lorem ipsum dolor sit amet.
diff --git a/packages/@luke-ui/react/src/styles/utilities.test.ts b/packages/@luke-ui/react/src/styles/utilities.test.ts
new file mode 100644
index 00000000..a9eb67e0
--- /dev/null
+++ b/packages/@luke-ui/react/src/styles/utilities.test.ts
@@ -0,0 +1,81 @@
+import { expect, expectTypeOf, test } from 'vite-plus/test';
+import type { SprinklesProps } from './utilities.css.js';
+import { createSprinkles } from './utilities.css.js';
+
+const responsiveLayout = {
+ alignItems: { medium: 'center', xsmall: 'stretch' },
+ blockSize: { large: '50vh', xsmall: 'auto' },
+ display: { medium: 'grid', xsmall: 'flex' },
+ flex: '1 1 auto',
+ flexBasis: '12rem',
+ flexDirection: 'column',
+ flexGrow: '1',
+ flexShrink: '0',
+ flexWrap: 'wrap',
+ gap: { medium: '600', xsmall: '200' },
+ gridColumn: '1 / -1',
+ gridColumnEnd: 'span 2',
+ gridColumnStart: '2',
+ gridRow: 'auto / span 2',
+ inlineSize: { medium: '50%', xsmall: '100%' },
+ insetBlockStart: '0',
+ insetInline: 'var(--luke-space-400)',
+ justifyContent: 'space-between',
+ marginBlock: '0',
+ marginInline: 'auto',
+ maxInlineSize: '64rem',
+ minBlockSize: '10rem',
+ overflow: 'auto',
+ padding: { small: '400', xsmall: '300' },
+ placeSelf: 'stretch',
+ position: 'sticky',
+ rowGap: '1000',
+} as const satisfies SprinklesProps;
+
+test('types the responsive layout-only surface', () => {
+ expectTypeOf(responsiveLayout).toExtend
();
+ expect(createSprinkles.properties).toContain('display');
+ expect(createSprinkles.properties).toContain('gridColumn');
+ expect(createSprinkles.properties).not.toContain('color');
+ expect(createSprinkles.properties).not.toContain('backgroundColor');
+ expect(createSprinkles.properties).not.toContain('fontSize');
+});
+
+const removedColor = {
+ // @ts-expect-error Direct colour is not part of Sprinkles.
+ color: 'danger',
+} satisfies SprinklesProps;
+
+const removedBackgroundColor = {
+ // @ts-expect-error Direct background colour is not part of Sprinkles.
+ backgroundColor: 'neutral',
+} satisfies SprinklesProps;
+
+const removedTypography = {
+ // @ts-expect-error Typography is not part of Sprinkles.
+ fontSize: '300',
+} satisfies SprinklesProps;
+
+const removedPseudoState = {
+ // @ts-expect-error Pseudo-state colour props are not part of Sprinkles.
+ color: { hover: 'danger' },
+} satisfies SprinklesProps;
+
+const invalidSpaceValue = {
+ // @ts-expect-error Spacing accepts only semantic space steps.
+ padding: '1rem',
+} satisfies SprinklesProps;
+
+const invalidResponsiveSpaceValue = {
+ padding: {
+ // @ts-expect-error Responsive spacing accepts only semantic space steps.
+ small: '1rem',
+ },
+} satisfies SprinklesProps;
+
+void removedColor;
+void removedBackgroundColor;
+void removedTypography;
+void removedPseudoState;
+void invalidSpaceValue;
+void invalidResponsiveSpaceValue;
diff --git a/packages/@luke-ui/react/src/styles/vars.css.ts b/packages/@luke-ui/react/src/styles/vars.css.ts
deleted file mode 100644
index b8bd0179..00000000
--- a/packages/@luke-ui/react/src/styles/vars.css.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { createTheme, createThemeContract } from '@vanilla-extract/css';
-import { toCssValue } from '../tokens/converters.js';
-import { tokenKeys } from '../tokens/groups.js';
-import { tokens } from '../tokens/index.js';
-import { classSelector, lukeUiClassNames } from './class-names.js';
-import { globalStyleInLayer } from './layered-style.css.js';
-import { layers } from './layers.css.js';
-
-type TokenGroup = {
- $type: string;
- [key: string]: { $value: unknown } | string;
-};
-
-type TokenGroupKey = Exclude;
-
-function toContract(group: T): Record, null> {
- const contract = {} as Record, null>;
- for (const key of tokenKeys(group)) {
- contract[key] = null;
- }
- return contract;
-}
-
-function toStringValues(group: T): Record, string> {
- const values = {} as Record, string>;
- for (const key of tokenKeys(group)) {
- const entry = group[key];
- if (entry && typeof entry === 'object' && '$value' in entry) {
- values[key] = toCssValue(group.$type, entry.$value);
- }
- }
- return values;
-}
-
-const contract = createThemeContract({
- backgroundColor: toContract(tokens.backgroundColor),
- borderColor: toContract(tokens.borderColor),
- borderRadius: toContract(tokens.borderRadius),
- borderWidth: toContract(tokens.borderWidth),
- boxShadow: toContract(tokens.boxShadow),
- controlSize: toContract(tokens.controlSize),
- fontFamily: toContract(tokens.fontFamily),
- fontSize: toContract(tokens.fontSize),
- fontWeight: toContract(tokens.fontWeight),
- foregroundColor: toContract(tokens.foregroundColor),
- iconSize: toContract(tokens.iconSize),
- lineHeight: toContract(tokens.lineHeight),
- motionDuration: toContract(tokens.motionDuration),
- motionEasing: toContract(tokens.motionEasing),
- space: toContract(tokens.space),
- themeColor: toContract(tokens.themeColor),
-});
-
-export const themeClass = createTheme(contract, {
- '@layer': layers.theme,
- backgroundColor: toStringValues(tokens.backgroundColor),
- borderColor: toStringValues(tokens.borderColor),
- borderRadius: toStringValues(tokens.borderRadius),
- borderWidth: toStringValues(tokens.borderWidth),
- boxShadow: toStringValues(tokens.boxShadow),
- controlSize: toStringValues(tokens.controlSize),
- fontFamily: toStringValues(tokens.fontFamily),
- fontSize: toStringValues(tokens.fontSize),
- fontWeight: toStringValues(tokens.fontWeight),
- foregroundColor: toStringValues(tokens.foregroundColor),
- iconSize: toStringValues(tokens.iconSize),
- lineHeight: toStringValues(tokens.lineHeight),
- motionDuration: toStringValues(tokens.motionDuration),
- motionEasing: toStringValues(tokens.motionEasing),
- space: toStringValues(tokens.space),
- themeColor: toStringValues(tokens.themeColor),
-});
-
-export const vars = {
- ...contract,
- backgroundColor: {
- ...contract.backgroundColor,
- default: contract.backgroundColor.neutral,
- disabled: contract.backgroundColor.neutralDisabled,
- hover: contract.backgroundColor.neutralHover,
- input: contract.backgroundColor.input,
- inputDisabled: contract.backgroundColor.inputDisabled,
- inverted: contract.backgroundColor.neutralInverted,
- pressed: contract.backgroundColor.neutralPressed,
- subtle: contract.backgroundColor.neutralSubtle,
- },
- border: {
- critical: contract.borderColor.inputCritical,
- default: contract.borderColor.neutralSubtle,
- input: contract.borderColor.input,
- strong: contract.borderColor.neutralBold,
- },
- boxShadow: contract.boxShadow,
- color: contract.foregroundColor,
- font: {
- family: { body: contract.fontFamily.sans, mono: contract.fontFamily.mono },
- lineHeight: contract.lineHeight,
- size: contract.fontSize,
- weight: contract.fontWeight,
- },
- foregroundColor: {
- ...contract.foregroundColor,
- disabled: contract.foregroundColor.neutralDisabled,
- inverse: contract.foregroundColor.neutralBoldInverted,
- primary: contract.foregroundColor.neutralBold,
- secondary: contract.foregroundColor.neutralSubtle,
- status: {
- caution: contract.foregroundColor.caution,
- critical: contract.foregroundColor.critical,
- informative: contract.foregroundColor.informative,
- positive: contract.foregroundColor.positive,
- },
- },
- iconSize: contract.iconSize,
- motion: {
- duration: contract.motionDuration,
- easing: contract.motionEasing,
- },
- themeColor: contract.themeColor,
-};
-
-const themeRootSelector = classSelector(lukeUiClassNames.themeRoot);
-
-globalStyleInLayer('theme', themeRootSelector, {
- accentColor: vars.themeColor.paletteThemePrimary500,
- color: vars.foregroundColor.primary,
- colorScheme: 'light',
- fontFamily: vars.font.family.body,
- fontSize: vars.font.size.standard,
- lineHeight: vars.font.lineHeight.loose,
-});
diff --git a/packages/@luke-ui/react/src/text-field/index.tsx b/packages/@luke-ui/react/src/text-field/index.tsx
index c568bec8..4ff8b722 100644
--- a/packages/@luke-ui/react/src/text-field/index.tsx
+++ b/packages/@luke-ui/react/src/text-field/index.tsx
@@ -7,20 +7,15 @@ import { TextField as RacTextField } from 'react-aria-components/TextField';
import type { FieldSlotProps } from '../field/compose-field.js';
import { composeField } from '../field/compose-field.js';
import { Field } from '../field/primitive/index.js';
+import type { DistributiveOmit } from '../types/distributive-omit.js';
import type { DocumentedInputProps } from '../types/documented-rac-props.js';
+import type { Prettify } from '../types/prettify.js';
import type { TextInputSize } from './primitive/index.js';
import { TextInput } from './primitive/index.js';
-/**
- * Props for the composed text field.
- *
- * @tier composed
- */
-export interface TextFieldProps
- extends
- Omit,
- DocumentedInputProps,
- FieldSlotProps {
+type _TextFieldOmit = DistributiveOmit;
+
+interface _TextFieldProps extends _TextFieldOmit, DocumentedInputProps, FieldSlotProps {
/** Element shown after the input value. */
adornmentEnd?: ReactNode;
/** Element shown before the input value. */
@@ -33,6 +28,13 @@ export interface TextFieldProps
size?: TextInputSize;
}
+/**
+ * Props for the composed text field.
+ *
+ * @tier composed
+ */
+export type TextFieldProps = Prettify<_TextFieldProps>;
+
/** Composes `TextInput` with label, description, and error slots. */
export function TextField(props: TextFieldProps): JSX.Element {
const [fieldSlotProps, restProps] = composeField(props);
diff --git a/packages/@luke-ui/react/src/text-field/primitive/index.tsx b/packages/@luke-ui/react/src/text-field/primitive/index.tsx
index 02f27e77..c8781904 100644
--- a/packages/@luke-ui/react/src/text-field/primitive/index.tsx
+++ b/packages/@luke-ui/react/src/text-field/primitive/index.tsx
@@ -5,6 +5,8 @@ import type { InputProps as RacInputProps } from 'react-aria-components/Input';
import { Input as RacInput } from 'react-aria-components/Input';
import { composeRenderProps } from 'react-aria-components/composeRenderProps';
import * as styles from '../../recipes/text-input.css.js';
+import type { DistributiveOmit } from '../../types/distributive-omit.js';
+import type { Prettify } from '../../types/prettify.js';
import { cx } from '../../utils/index.js';
interface TextInputVariantProps extends NonNullable {}
@@ -20,13 +22,8 @@ interface TextInputStyleProps {
/** Allowed `size` values for `TextInput`. */
export type TextInputSize = NonNullable;
-/**
- * Props for the primitive text input.
- *
- * @tier primitive
- */
-export interface TextInputProps
- extends Omit, TextInputStyleProps {
+type _TextInputOmit = DistributiveOmit;
+interface _TextInputProps extends _TextInputOmit, TextInputStyleProps {
/** Element shown at the end of the control. */
adornmentEnd?: ReactNode;
/** Element shown at the start of the control. */
@@ -37,6 +34,13 @@ export interface TextInputProps
inputClassName?: RacInputProps['className'];
}
+/**
+ * Props for the primitive text input.
+ *
+ * @tier primitive
+ */
+export type TextInputProps = Prettify<_TextInputProps>;
+
/** Styled text input with optional start/end adornments. */
export function TextInput(props: TextInputProps): JSX.Element {
const {
diff --git a/packages/@luke-ui/react/src/text/index.tsx b/packages/@luke-ui/react/src/text/index.tsx
index 5f489204..8e765bcb 100644
--- a/packages/@luke-ui/react/src/text/index.tsx
+++ b/packages/@luke-ui/react/src/text/index.tsx
@@ -1,5 +1,7 @@
import { Text as RacText } from 'react-aria-components/Text';
import * as styles from '../recipes/text.css.js';
+import type { DistributiveOmit } from '../types/distributive-omit.js';
+import type { Prettify } from '../types/prettify.js';
import { cx } from '../utils/index.js';
interface TextVariantProps extends NonNullable {}
@@ -64,13 +66,15 @@ interface TextStyleProps {
textWrap?: TextVariantProps['textWrap'];
}
+type _TextOmit = DistributiveOmit, 'color'>;
+interface _TextProps extends _TextOmit, TextStyleProps {}
+
/**
* Props for the primitive text component.
*
* @tier atom
*/
-export type TextProps = Omit, keyof TextStyleProps> &
- TextStyleProps;
+export type TextProps = Prettify<_TextProps>;
/** Styled text with a coordinated type scale and semantic colour controls. */
export function Text(props: TextProps) {
diff --git a/packages/@luke-ui/react/src/text/text.stories.tsx b/packages/@luke-ui/react/src/text/text.stories.tsx
index 80562be7..329340a7 100644
--- a/packages/@luke-ui/react/src/text/text.stories.tsx
+++ b/packages/@luke-ui/react/src/text/text.stories.tsx
@@ -5,7 +5,8 @@ import type { CSSProperties } from 'react';
import { expect } from 'storybook/test';
import preview from '../../.storybook/preview.js';
import { createSprinkles } from '../styles/index.js';
-import { vars } from '../theme.css.js';
+import { vars } from '../theme/index.js';
+import type { DistributiveOmit } from '../types/distributive-omit.js';
const meta = preview.meta({
component: Text,
@@ -14,7 +15,7 @@ const meta = preview.meta({
});
const panelStyle = {
- borderColor: vars.border.default,
+ borderColor: vars.color.border.decorative,
borderStyle: 'dashed',
borderWidth: 1,
maxInlineSize: '40rem',
@@ -33,10 +34,12 @@ const loremIpsum =
const importantIdentifier = 'budget-allocation-review-cycle-2026-06-05-eu-west-1-BA-109284';
const importantIdentifierSuffix = '-BA-109284';
-type MiddleTruncatedIdentifierProps = Omit & {
+type MiddleTruncatedIdentifierOmit = DistributiveOmit;
+
+interface MiddleTruncatedIdentifierProps extends MiddleTruncatedIdentifierOmit {
identifier: string;
suffix: string;
-};
+}
function MiddleTruncatedIdentifier(props: MiddleTruncatedIdentifierProps) {
const { identifier, suffix, ...textProps } = props;
@@ -52,7 +55,6 @@ function MiddleTruncatedIdentifier(props: MiddleTruncatedIdentifierProps) {
flexGrow: '1',
flexShrink: '1',
minInlineSize: '0',
- textOverflow: 'clip',
});
const fixed = createSprinkles({ flexBasis: 'auto', flexGrow: '0', flexShrink: '0' });
@@ -233,7 +235,7 @@ export const Truncate = meta.story({
const container = createSprinkles({
display: 'flex',
flexDirection: 'column',
- gap: 'medium',
+ gap: '400',
inlineSize: '100%',
maxInlineSize: '20rem',
minInlineSize: '0',
@@ -263,7 +265,7 @@ export const MiddleTruncation = meta.story({
const container = createSprinkles({
display: 'flex',
flexDirection: 'column',
- gap: 'medium',
+ gap: '400',
inlineSize: '100%',
maxInlineSize: '20rem',
minInlineSize: '0',
@@ -299,7 +301,7 @@ export const LineClamp = meta.story({
const container = createSprinkles({
display: 'flex',
flexDirection: 'column',
- gap: 'medium',
+ gap: '400',
inlineSize: '100%',
maxInlineSize: '20rem',
minInlineSize: '0',
diff --git a/packages/@luke-ui/react/src/theme.css.ts b/packages/@luke-ui/react/src/theme.css.ts
deleted file mode 100644
index 6682f75c..00000000
--- a/packages/@luke-ui/react/src/theme.css.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import './styles/index.css.js';
-
-export { themeClass, vars } from './styles/vars.css.js';
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 d737c6b2..c2ce98c8 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.test.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts
@@ -13,8 +13,13 @@ import {
import { elmoFoundation, machinedEdgeFoundation } from './foundations.js';
const pairs = flattenThemeContract();
-const isModePath = (path: string) =>
- path.startsWith('actionControlFinish.') || path.startsWith('color.') || path.startsWith('depth.');
+const isModePath = (path: string) => {
+ return (
+ path.startsWith('actionControlFinish.') ||
+ path.startsWith('color.') ||
+ path.startsWith('depth.')
+ );
+};
const modeVarNames = pairs.filter(([path]) => isModePath(path)).map(([, varName]) => varName);
const identityVarNames = pairs.filter(([path]) => !isModePath(path)).map(([, varName]) => varName);
@@ -71,14 +76,13 @@ describe('buildTheme output', () => {
});
it('declares every identity variable exactly once in the identity block', () => {
- const counts = identityVarNames.map((varName) => [
- varName,
- countOccurrences(blocks.identity, `${varName}: `),
- ]);
+ const counts = identityVarNames.map((varName) => {
+ return [varName, countOccurrences(blocks.identity, `${varName}: `)];
+ });
expect(counts).toEqual(identityVarNames.map((varName) => [varName, 1]));
- const modeCounts = modeVarNames.map((varName) =>
- countOccurrences(blocks.identity, `${varName}: `),
- );
+ const modeCounts = modeVarNames.map((varName) => {
+ return countOccurrences(blocks.identity, `${varName}: `);
+ });
expect(modeCounts).toEqual(modeVarNames.map(() => 0));
});
@@ -95,9 +99,9 @@ describe('buildTheme output', () => {
countOccurrences(block, `${varName}: `),
]);
expect(counts).toEqual(modeVarNames.map((varName) => [varName, 1]));
- const identityCounts = identityVarNames.map((varName) =>
- countOccurrences(block, `${varName}: `),
- );
+ const identityCounts = identityVarNames.map((varName) => {
+ return countOccurrences(block, `${varName}: `);
+ });
expect(identityCounts).toEqual(identityVarNames.map(() => 0));
}
});
@@ -325,11 +329,12 @@ describe('buildTheme contrast failures', () => {
},
name: 'bad-focus',
});
- const failure = error.failures.find(
- (candidate) =>
+ const failure = error.failures.find((candidate) => {
+ return (
candidate.foreground === 'color.border.focus' &&
- candidate.background === 'color.surface.canvas',
- );
+ candidate.background === 'color.surface.canvas'
+ );
+ });
expect(failure).toBeDefined();
expect(failure?.mode).toBe('light');
expect(failure?.required).toBe(3);
@@ -348,12 +353,13 @@ describe('buildTheme contrast failures', () => {
},
name: 'bad-dark-neutral',
});
- const failure = error.failures.find(
- (candidate) =>
+ const failure = error.failures.find((candidate) => {
+ return (
candidate.mode === 'dark' &&
candidate.foreground === 'color.text.primary' &&
- candidate.background.startsWith('color.surface.'),
- );
+ candidate.background.startsWith('color.surface.')
+ );
+ });
expect(failure).toBeDefined();
expect(failure?.required).toBe(4.5);
expect(error.message).toContain('dark: color.text.primary on color.surface.canvas');
@@ -453,9 +459,9 @@ describe('bundled themes meet WCAG 2.2 AA', () => {
it(`${foundation.name} generates the least-contrasting passing control borders`, () => {
const blocks = splitBlocks(buildTheme(foundation));
for (const block of [blocks.baseLight, blocks.mediaDark]) {
- const surfaces = ['canvas', 'resting', 'recessed'].map((surface) =>
- parseColor(extractValue(block, `--luke-color-surface-${surface}`)),
- );
+ const surfaces = ['canvas', 'resting', 'recessed'].map((surface) => {
+ return parseColor(extractValue(block, `--luke-color-surface-${surface}`));
+ });
const borderVarNames = [
'--luke-color-border-control',
...['accent', 'info', 'success', 'warning', 'danger'].map(
diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts
index 7f39974a..9e34b623 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.ts
@@ -72,11 +72,12 @@ export class ThemeContrastError extends Error {
super(
[
'Theme foundation fails WCAG 2.2 AA contrast:',
- ...failures.map(
- (failure) =>
+ ...failures.map((failure) => {
+ return (
`${failure.mode}: ${failure.foreground} on ${failure.background} — ` +
- `${failure.ratio.toFixed(2)}:1 < ${failure.required}:1`,
- ),
+ `${failure.ratio.toFixed(2)}:1 < ${failure.required}:1`
+ );
+ }),
].join('\n'),
);
this.failures = failures;
@@ -313,8 +314,9 @@ function buildModeColors(
// Disabled roles are exempt from contrast checks but must remain perceptibly disabled.
const disabledChroma = Math.min(neutral.c, 0.01);
- const disabledAt = (delta: number) =>
- gamutMapOklch({ c: disabledChroma, h: neutral.h, l: clampUnit(canvas.l + delta) });
+ const disabledAt = (delta: number) => {
+ return gamutMapOklch({ c: disabledChroma, h: neutral.h, l: clampUnit(canvas.l + delta) });
+ };
colors['color.surfaceDisabled'] = disabledAt(isLight ? -0.06 : 0.05);
colors['color.textDisabled'] = disabledAt(isLight ? -0.32 : 0.33);
colors['color.borderDisabled'] = disabledAt(isLight ? -0.12 : 0.14);
@@ -340,8 +342,12 @@ function buildModeColors(
h: neutral.h,
l: isLight ? 0.32 : 0.85,
});
- const neutralSolidAt = (delta: number) =>
- gamutMapOklch({ ...neutralSolid, l: clampUnit(neutralSolid.l + (isLight ? -delta : delta)) });
+ const neutralSolidAt = (delta: number) => {
+ return gamutMapOklch({
+ ...neutralSolid,
+ l: clampUnit(neutralSolid.l + (isLight ? -delta : delta)),
+ });
+ };
const neutralSolidHover = neutralSolidAt(0.05);
const neutralSolidPressed = neutralSolidAt(0.09);
const neutralSubtle = buildSubtleTrio(mode, canvas, neutral);
@@ -632,10 +638,13 @@ function assembleStylesheet(
): string {
const selector = `.${themeClassName(foundation.name)}`;
const pairs = flattenThemeContract();
- const isModePath = (path: string) =>
- path.startsWith('actionControlFinish.') ||
- path.startsWith('color.') ||
- path.startsWith('depth.');
+ const isModePath = (path: string) => {
+ return (
+ path.startsWith('actionControlFinish.') ||
+ path.startsWith('color.') ||
+ path.startsWith('depth.')
+ );
+ };
const identityPairs = pairs.filter(([path]) => !isModePath(path));
const modePairs = pairs.filter(([path]) => isModePath(path));
@@ -647,12 +656,13 @@ function assembleStylesheet(
// the system preference. The explicit attribute rules are specificity (0,2,0), so they beat the
// (0,1,0) media-query rule whether the attribute sits on the theme root, inside its subtree, or
// on an ancestor. Every scope sets native color-scheme, and the output is unlayered on purpose.
- const attributeSelectors = (attributeMode: ColorMode) =>
- [
+ const attributeSelectors = (attributeMode: ColorMode) => {
+ return [
`${selector}[data-color-mode='${attributeMode}'],`,
`${selector} [data-color-mode='${attributeMode}'],`,
`[data-color-mode='${attributeMode}'] ${selector} {`,
].join('\n');
+ };
return [
'/* Generated by buildTheme from @luke-ui/react. Do not edit. */',
diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx
index 52cde014..a1f8095b 100644
--- a/packages/@luke-ui/react/src/theme/index.tsx
+++ b/packages/@luke-ui/react/src/theme/index.tsx
@@ -1,16 +1,8 @@
import { lukeUiClassNames } from '../styles/class-names.js';
-import { themeClass } from '../theme.css.js';
import { cx } from '../utils/index.js';
-/** The vanilla-extract CSS class that applies the design-token theme to its subtree. */
-export { themeClass } from '../theme.css.js';
-
-/** Convenience class name combining the theme, theme-root, and CSS-reset root classes. Apply to your app's root element. */
-export const themeRootClassName = cx(
- themeClass,
- lukeUiClassNames.themeRoot,
- lukeUiClassNames.resetRoot,
-);
+/** Convenience class name combining the theme-root and CSS-reset classes. */
+export const themeRootClassName = cx(lukeUiClassNames.themeRoot, lukeUiClassNames.resetRoot);
/**
* Typed access to the semantic theme custom properties. Each path resolves to a stable global
diff --git a/packages/@luke-ui/react/src/theme/tokens.stories.tsx b/packages/@luke-ui/react/src/theme/tokens.stories.tsx
new file mode 100644
index 00000000..392d0bbe
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/tokens.stories.tsx
@@ -0,0 +1,970 @@
+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: 'Resting', value: vars.color.surface.resting },
+ { label: 'Floating', value: vars.color.surface.floating },
+ { label: 'Overlay', value: vars.color.surface.overlay },
+ { label: 'Disabled', value: vars.color.surfaceDisabled },
+ { 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.textDisabled },
+];
+
+const borderSamples: Array = [
+ { label: 'Decorative', value: vars.color.border.decorative },
+ { label: 'Control', value: vars.color.border.control },
+ { label: 'Focus', value: vars.color.border.focus },
+ { label: 'Disabled', value: vars.color.borderDisabled },
+];
+
+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: intentRoles(
+ vars.color.intent.info.border,
+ vars.color.intent.info.text,
+ vars.color.intent.info.onSolid,
+ vars.color.intent.info.surface.solid,
+ ),
+ surfaces: intentSurfaceSamples(vars.color.intent.info.surface),
+ },
+ {
+ key: 'success',
+ label: 'Success',
+ roles: intentRoles(
+ vars.color.intent.success.border,
+ vars.color.intent.success.text,
+ vars.color.intent.success.onSolid,
+ vars.color.intent.success.surface.solid,
+ ),
+ surfaces: intentSurfaceSamples(vars.color.intent.success.surface),
+ },
+ {
+ key: 'warning',
+ label: 'Warning',
+ roles: intentRoles(
+ vars.color.intent.warning.border,
+ vars.color.intent.warning.text,
+ vars.color.intent.warning.onSolid,
+ vars.color.intent.warning.surface.solid,
+ ),
+ surfaces: intentSurfaceSamples(vars.color.intent.warning.surface),
+ },
+ {
+ 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: 'Medium enter motion sample',
+ duration: vars.motion.duration.medium,
+ durationKey: 'medium',
+ easing: vars.motion.easing.enter,
+ easingKey: 'enter',
+ label: 'Medium / enter',
+ },
+ {
+ accessibleLabel: 'Slow exit motion sample',
+ duration: vars.motion.duration.slow,
+ durationKey: 'slow',
+ easing: vars.motion.easing.exit,
+ easingKey: 'exit',
+ label: 'Slow / exit',
+ },
+ {
+ accessibleLabel: 'Ambient standard motion sample',
+ duration: vars.motion.duration.ambient,
+ durationKey: 'ambient',
+ easing: vars.motion.easing.standard,
+ easingKey: 'standard',
+ label: 'Ambient / standard',
+ },
+];
+
+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 mediumMotion = canvas.getByRole('img', { name: 'Medium enter motion sample' });
+ await expect(getComputedStyle(mediumMotion).animationDuration).toBe('0.2s');
+
+ 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 (
+
+
+
+
+
+ {
+ const path = surfacePath(sample.label);
+ return tokenRow(
+ path,
+ ,
+ sample.value,
+ );
+ })}
+ />
+
+
+
+ {
+ const path =
+ sample.label === 'Disabled'
+ ? 'color.textDisabled'
+ : `color.text.${toKey(sample.label)}`;
+ return tokenRow(
+ path,
+ ,
+ sample.value,
+ );
+ })}
+ />
+
+
+
+ {
+ const path =
+ sample.label === 'Disabled'
+ ? 'color.borderDisabled'
+ : `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 (
+
+ );
+}
+
+function TokenTable({ caption, rows }: TokenTableProps) {
+ return (
+
+
+
+ {caption}
+
+
+
+
+
+
+
+
+
+ |
+ Preview
+ |
+
+ Semantic token
+ |
+
+ Resolved value
+ |
+
+ CSS variable
+ |
+
+
+
+ {rows.map((row) => (
+
+ | {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 },
+ ];
+}
+
+function customPropertyName(value: string): string {
+ const match = /^var\((--[^,)]+)/.exec(value);
+ return match?.[1] ?? value;
+}
+
+function surfacePath(label: string): string {
+ if (label === 'Disabled') return 'color.surfaceDisabled';
+ 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());
+}
diff --git a/packages/@luke-ui/react/src/tokens/converters.ts b/packages/@luke-ui/react/src/tokens/converters.ts
deleted file mode 100644
index 8404b2dc..00000000
--- a/packages/@luke-ui/react/src/tokens/converters.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { pxToRem } from '../utils/index.js';
-import type {
- ColorTokenValue,
- CubicBezierTokenValue,
- DimensionTokenValue,
- DurationTokenValue,
-} from './index.js';
-
-/** Converts a `DimensionTokenValue` to a rem string. */
-export function dimensionToRemString(value: DimensionTokenValue, base: number = 16): string {
- return value.unit === 'rem' ? `${value.value}rem` : pxToRem(value.value, base);
-}
-
-/** Converts a `DurationTokenValue` to a CSS time string. */
-export function durationToString(value: DurationTokenValue): string {
- return `${value.value}${value.unit}`;
-}
-
-/** Converts a `CubicBezierTokenValue` to a `cubic-bezier(...)` CSS string. */
-export function cubicBezierToString(value: CubicBezierTokenValue): string {
- return `cubic-bezier(${value[0]}, ${value[1]}, ${value[2]}, ${value[3]})`;
-}
-
-const functionLikeColorSpaces = new Set(['hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch']);
-
-/** Converts a `ColorTokenValue` to a CSS color string. */
-export function colorToCssString(value: ColorTokenValue): string {
- if (value.components.length === 0) {
- throw new Error(`Color token "${value.colorSpace}" has no components`);
- }
-
- const components = value.components.map((component) => formatNumber(component));
- const alphaSuffix =
- value.alpha === undefined || value.alpha === 1 ? '' : ` / ${formatNumber(value.alpha)}`;
-
- if (functionLikeColorSpaces.has(value.colorSpace)) {
- return `${value.colorSpace}(${components.join(' ')}${alphaSuffix})`;
- }
-
- return `color(${value.colorSpace} ${components.join(' ')}${alphaSuffix})`;
-}
-
-function formatNumber(value: number): string {
- if (!Number.isFinite(value)) {
- throw new Error(`Invalid numeric color component: ${value}`);
- }
-
- const normalized = Object.is(value, -0) ? 0 : value;
- return `${normalized}`;
-}
-
-export function isColorTokenValue(v: unknown): v is ColorTokenValue {
- return (
- typeof v === 'object' &&
- v !== null &&
- 'colorSpace' in v &&
- typeof v.colorSpace === 'string' &&
- 'components' in v &&
- Array.isArray(v.components)
- );
-}
-
-function isCubicBezierTokenValue(v: unknown): v is CubicBezierTokenValue {
- return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number');
-}
-
-function isDimensionTokenValue(v: unknown): v is DimensionTokenValue {
- return (
- typeof v === 'object' &&
- v !== null &&
- 'unit' in v &&
- (v.unit === 'px' || v.unit === 'rem') &&
- 'value' in v &&
- typeof v.value === 'number'
- );
-}
-
-function isDurationTokenValue(v: unknown): v is DurationTokenValue {
- return (
- typeof v === 'object' &&
- v !== null &&
- 'unit' in v &&
- (v.unit === 'ms' || v.unit === 's') &&
- 'value' in v &&
- typeof v.value === 'number'
- );
-}
-
-/**
- * Convert a token value to its CSS string form, dispatching on the token's
- * `$type`. Values whose shape does not match the declared type fall back to
- * `String(value)` so a malformed token never crashes the build.
- */
-export function toCssValue(type: string, value: unknown): string {
- switch (type) {
- case 'color':
- if (isColorTokenValue(value)) return colorToCssString(value);
- break;
- case 'cubicBezier':
- if (isCubicBezierTokenValue(value)) return cubicBezierToString(value);
- break;
- case 'dimension':
- if (isDimensionTokenValue(value)) return dimensionToRemString(value);
- break;
- case 'duration':
- if (isDurationTokenValue(value)) return durationToString(value);
- break;
- }
- return String(value);
-}
diff --git a/packages/@luke-ui/react/src/tokens/groups.ts b/packages/@luke-ui/react/src/tokens/groups.ts
deleted file mode 100644
index af7e8968..00000000
--- a/packages/@luke-ui/react/src/tokens/groups.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { DesignTokenGroup, TokenName } from './index.js';
-
-/** Constructs a typed `DesignTokenGroup` from a type string and a values record. */
-export function toTokenGroup>(
- type: TType,
- values: TValues,
-): DesignTokenGroup {
- const group = { $type: type } as DesignTokenGroup;
-
- for (const key in values) {
- (group as Record)[key] = {
- $value: values[key],
- };
- }
-
- return group;
-}
-
-/** Returns the token names from a `DesignTokenGroup`, excluding the `$type` key. */
-export function tokenKeys(
- group: TGroup,
-): Array> {
- return (Object.keys(group) as Array).filter(
- (key): key is TokenName => key !== '$type',
- );
-}
diff --git a/packages/@luke-ui/react/src/tokens/index.ts b/packages/@luke-ui/react/src/tokens/index.ts
deleted file mode 100644
index b22e9e2b..00000000
--- a/packages/@luke-ui/react/src/tokens/index.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import {
- backgroundColorTokenValues,
- borderColorTokenValues,
- foregroundColorTokenValues,
- themeColorTokenValues,
-} from '../../.generated/color-tokens.generated.js';
-import { toTokenGroup } from './groups.js';
-import { minMediaQueries } from './media-queries.js';
-import {
- borderRadiusValues,
- borderWidthValues,
- boxShadowValues,
- breakpointValues,
- controlSizeValues,
- fontFamilyValues,
- fontSizeValues,
- fontWeightValues,
- iconSizeValues,
- lineHeightValues,
- motionDurationValues,
- motionEasingValues,
- spaceValues,
-} from './values.js';
-
-/** Structured value for a dimension token, expressed in px or rem. */
-export interface DimensionTokenValue {
- unit: 'px' | 'rem';
- value: number;
-}
-
-/** Structured value for a duration token, expressed in ms or s. */
-export interface DurationTokenValue {
- unit: 'ms' | 's';
- value: number;
-}
-
-/** Structured value for a color token with optional alpha and color-space components. */
-export interface ColorTokenValue {
- alpha?: number;
- colorSpace: string;
- components: Array;
-}
-
-/** Structured value for a cubic-bezier easing token as a 4-number tuple. */
-export type CubicBezierTokenValue = readonly [number, number, number, number];
-
-/** A single design token wrapping a typed value. */
-export type DesignToken = {
- $value: TValue;
-};
-
-/** A group of design tokens sharing a common `$type`, keyed by token name. */
-export type DesignTokenGroup> = {
- $type: TType;
-} & {
- [Key in keyof TValues]: DesignToken;
-};
-
-/** Extracts the string token names from a `DesignTokenGroup`, excluding `$type`. */
-export type TokenName = Extract<
- Exclude,
- string
->;
-
-export {
- colorToCssString,
- cubicBezierToString,
- dimensionToRemString,
- durationToString,
- isColorTokenValue,
- toCssValue,
-} from './converters.js';
-export { tokenKeys, toTokenGroup } from './groups.js';
-export type { Breakpoint } from './media-queries.js';
-export { minMediaQueries } from './media-queries.js';
-export {
- borderRadiusValues,
- borderWidthValues,
- boxShadowValues,
- breakpointValues,
- controlSizeValues,
- fontFamilyValues,
- fontSizeValues,
- fontWeightValues,
- iconSizeValues,
- lineHeightValues,
- motionDurationValues,
- motionEasingValues,
- spaceValues,
-} from './values.js';
-
-/** The complete design-token object, grouped by category, ready to use in vanilla-extract themes. */
-export const tokens = {
- backgroundColor: toTokenGroup('color', backgroundColorTokenValues),
- borderColor: toTokenGroup('color', borderColorTokenValues),
- borderRadius: toTokenGroup('dimension', borderRadiusValues),
- borderWidth: toTokenGroup('dimension', borderWidthValues),
- boxShadow: toTokenGroup('string', boxShadowValues),
- breakpoints: toTokenGroup('dimension', breakpointValues),
- controlSize: toTokenGroup('dimension', controlSizeValues),
- fontFamily: toTokenGroup('fontFamily', fontFamilyValues),
- fontSize: toTokenGroup('dimension', fontSizeValues),
- fontWeight: toTokenGroup('fontWeight', fontWeightValues),
- foregroundColor: toTokenGroup('color', foregroundColorTokenValues),
- iconSize: toTokenGroup('dimension', iconSizeValues),
- lineHeight: toTokenGroup('number', lineHeightValues),
- mediaQueries: {
- min: toTokenGroup('mediaQuery', minMediaQueries),
- },
- motionDuration: toTokenGroup('duration', motionDurationValues),
- motionEasing: toTokenGroup('cubicBezier', motionEasingValues),
- space: toTokenGroup('dimension', spaceValues),
- themeColor: toTokenGroup('color', themeColorTokenValues),
-} as const;
-
-/** The full type of the `tokens` object. */
-export type Tokens = typeof tokens;
-/** Union of valid control-size token names. */
-export type ControlSizeToken = TokenName;
-/** Union of valid font-family token names. */
-export type FontFamilyToken = TokenName;
-/** Union of valid font-size token names. */
-export type FontSizeToken = TokenName;
-/** Union of valid font-weight token names. */
-export type FontWeightToken = TokenName;
-/** Union of valid foreground-color token names. */
-export type ForegroundColorToken = TokenName;
-/** Union of valid icon-size token names. */
-export type IconSizeToken = TokenName;
-/** Union of valid line-height token names. */
-export type LineHeightToken = TokenName;
diff --git a/packages/@luke-ui/react/src/tokens/media-queries.ts b/packages/@luke-ui/react/src/tokens/media-queries.ts
deleted file mode 100644
index fe8c5fdb..00000000
--- a/packages/@luke-ui/react/src/tokens/media-queries.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { typedKeys } from '../utils/index.js';
-import { dimensionToRemString } from './converters.js';
-import { breakpointValues } from './values.js';
-
-/** The available breakpoint names (e.g. `'small' | 'medium' | ...`). */
-export type Breakpoint = keyof typeof breakpointValues;
-
-/** Prebuilt `@media (width >= ...)` query strings keyed by breakpoint name. */
-export const minMediaQueries = Object.fromEntries(
- typedKeys(breakpointValues).map((breakpoint) => [
- breakpoint,
- `@media(width >= ${dimensionToRemString(breakpointValues[breakpoint])})`,
- ]),
-) as Record;
diff --git a/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx b/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx
deleted file mode 100644
index 29eae79d..00000000
--- a/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx
+++ /dev/null
@@ -1,953 +0,0 @@
-import { Heading } from '@luke-ui/react/heading';
-import { Text } from '@luke-ui/react/text';
-import type { ColorTokenValue } from '@luke-ui/react/tokens';
-import {
- colorToCssString,
- isColorTokenValue,
- toCssValue,
- tokenKeys,
- tokens,
-} from '@luke-ui/react/tokens';
-import ColorJs from 'colorjs.io';
-import type { CSSProperties } from 'react';
-import { expect } from 'storybook/test';
-import preview from '../../.storybook/preview.js';
-import { vars } from '../theme.css.js';
-
-type StoryTokenGroup = {
- $type: string;
- [key: string]: { $value: unknown } | string;
-};
-
-type TokenRow = {
- cssValue: string;
- groupName: string;
- path: string;
- type: string;
- value: string;
-};
-
-type ExampleRow = {
- cssValue: string;
- displayValue: string;
- path: string;
- tokenName: string;
-};
-
-type VarRow = {
- cssVar: string;
- path: string;
-};
-
-type ExampleMode =
- | 'background'
- | 'border'
- | 'borderWidth'
- | 'breakpoint'
- | 'controlSize'
- | 'duration'
- | 'easing'
- | 'fontFamily'
- | 'fontSize'
- | 'fontWeight'
- | 'foreground'
- | 'iconSize'
- | 'lineHeight'
- | 'mediaQuery'
- | 'radius'
- | 'shadow'
- | 'space';
-
-const tokenGroups = [
- ['backgroundColor', tokens.backgroundColor],
- ['borderColor', tokens.borderColor],
- ['borderRadius', tokens.borderRadius],
- ['borderWidth', tokens.borderWidth],
- ['boxShadow', tokens.boxShadow],
- ['breakpoints', tokens.breakpoints],
- ['controlSize', tokens.controlSize],
- ['fontFamily', tokens.fontFamily],
- ['fontSize', tokens.fontSize],
- ['fontWeight', tokens.fontWeight],
- ['foregroundColor', tokens.foregroundColor],
- ['iconSize', tokens.iconSize],
- ['lineHeight', tokens.lineHeight],
- ['mediaQueries.min', tokens.mediaQueries.min],
- ['motionDuration', tokens.motionDuration],
- ['motionEasing', tokens.motionEasing],
- ['space', tokens.space],
- ['themeColor', tokens.themeColor],
-] as const;
-
-const tokenRows = tokenGroups
- .flatMap(([groupName, group]) => getTokenRows(groupName, group))
- .sort((a, b) => a.path.localeCompare(b.path));
-
-const varRows = getVarRows(vars).sort((a, b) => a.path.localeCompare(b.path));
-
-const backgroundColorRows = getColorRows('backgroundColor', tokens.backgroundColor);
-const foregroundColorRows = getColorRows('foregroundColor', tokens.foregroundColor);
-const borderColorRows = getColorRows('borderColor', tokens.borderColor);
-const themeColorRows = getColorRows('themeColor', tokens.themeColor);
-
-const spaceRows = getDimensionRows('space', tokens.space);
-const shadowRows = getStringRows('boxShadow', tokens.boxShadow);
-const borderRadiusRows = getDimensionRows('borderRadius', tokens.borderRadius);
-const borderWidthRows = getDimensionRows('borderWidth', tokens.borderWidth);
-const fontSizeRows = getDimensionRows('fontSize', tokens.fontSize);
-const lineHeightRows = getNumberRows('lineHeight', tokens.lineHeight);
-const fontWeightRows = getNumberRows('fontWeight', tokens.fontWeight);
-const fontFamilyRows = getStringRows('fontFamily', tokens.fontFamily);
-const controlSizeRows = getDimensionRows('controlSize', tokens.controlSize);
-const iconSizeRows = getDimensionRows('iconSize', tokens.iconSize);
-const motionDurationRows = getDurationRows('motionDuration', tokens.motionDuration);
-const motionEasingRows = getCubicBezierRows('motionEasing', tokens.motionEasing);
-const breakpointRows = getDimensionRows('breakpoints', tokens.breakpoints);
-const mediaQueryRows = getStringRows('mediaQueries.min', tokens.mediaQueries.min);
-
-const meta = preview.meta({
- component: TokensVarsReference,
- tags: ['theme'],
- title: 'Theme/Tokens & Vars',
-});
-
-function css(style: TStyle): TStyle {
- return style;
-}
-
-function columnStyle(inlineSize: string): CSSProperties {
- return css({ inlineSize });
-}
-
-const stackStyle = css({
- display: 'flex',
- flexDirection: 'column',
- gap: '1.5rem',
- maxInlineSize: '100%',
-});
-
-const sectionStyle = css({
- display: 'flex',
- flexDirection: 'column',
- gap: '0.75rem',
-});
-
-const tableWrapStyle = css({
- borderColor: vars.border.default,
- borderRadius: vars.borderRadius.medium,
- borderStyle: 'solid',
- borderWidth: 1,
- inlineSize: '100%',
- maxInlineSize: '100%',
- overflow: 'auto',
-});
-
-const tokenReferenceTableStyle = css({
- borderCollapse: 'collapse',
- fontFamily: vars.font.family.mono,
- fontSize: vars.font.size.standard,
- inlineSize: '100%',
- minInlineSize: '82rem',
- tableLayout: 'fixed',
-});
-
-const varsReferenceTableStyle = css({
- borderCollapse: 'collapse',
- fontFamily: vars.font.family.mono,
- fontSize: vars.font.size.standard,
- inlineSize: '100%',
- minInlineSize: '64rem',
- tableLayout: 'fixed',
-});
-
-const headerCellStyle = css({
- backgroundColor: vars.backgroundColor.subtle,
- borderBlockEndColor: vars.border.default,
- borderBlockEndStyle: 'solid',
- borderBlockEndWidth: 1,
- fontWeight: vars.font.weight.bold,
- paddingBlock: vars.space.xsmall,
- paddingInline: vars.space.small,
- textAlign: 'start',
- whiteSpace: 'nowrap',
-});
-
-const cellStyle = css({
- borderBlockEndColor: vars.border.default,
- borderBlockEndStyle: 'solid',
- borderBlockEndWidth: 1,
- overflowWrap: 'anywhere',
- paddingBlock: vars.space.xsmall,
- paddingInline: vars.space.small,
- verticalAlign: 'top',
-});
-
-const headingStyle = css({
- fontFamily: vars.font.family.body,
- fontSize: vars.font.size.medium,
- fontWeight: vars.font.weight.bold,
- lineHeight: vars.font.lineHeight.tight,
- margin: 0,
-});
-
-const descriptionStyle = css({
- color: vars.foregroundColor.secondary,
- fontFamily: vars.font.family.body,
- margin: 0,
-});
-
-const previewSwatchStyle = css({
- blockSize: '1.5rem',
- borderColor: vars.border.default,
- borderRadius: vars.borderRadius.small,
- borderStyle: 'solid',
- borderWidth: 1,
- display: 'inline-block',
- inlineSize: '4rem',
-});
-
-const previewBoxStyle = css({
- backgroundColor: vars.backgroundColor.default,
- blockSize: '1.5rem',
- borderColor: vars.border.default,
- borderStyle: 'solid',
- borderWidth: 1,
- display: 'inline-block',
- inlineSize: '4rem',
-});
-
-const previewTextStyle = css({
- fontFamily: vars.font.family.body,
- fontSize: vars.font.size.standard,
- fontWeight: vars.font.weight.bold,
-});
-
-const mutedPreviewStyle = css({
- color: vars.foregroundColor.secondary,
-});
-
-const docsPageStyle = css({
- backgroundColor: vars.backgroundColor.default,
- borderColor: vars.border.default,
- borderRadius: vars.borderRadius.large,
- borderStyle: 'solid',
- borderWidth: 1,
- display: 'flex',
- flexDirection: 'column',
- gap: vars.space.large,
- paddingBlock: vars.space.large,
- paddingInline: vars.space.large,
-});
-
-const docsMainStyle = css({
- display: 'flex',
- flexDirection: 'column',
- gap: vars.space.large,
- minInlineSize: 0,
-});
-
-const tokenSectionStyle = css({
- display: 'flex',
- flexDirection: 'column',
- gap: vars.space.small,
-});
-
-const tokenTableWrapStyle = css({
- backgroundColor: vars.backgroundColor.default,
- borderColor: vars.border.default,
- borderRadius: vars.borderRadius.medium,
- borderStyle: 'solid',
- borderWidth: 1,
- overflow: 'auto',
-});
-
-const tokenTableStyle = css({
- borderCollapse: 'collapse',
- fontFamily: vars.font.family.body,
- fontSize: vars.font.size.standard,
- inlineSize: '100%',
- minInlineSize: '50rem',
- tableLayout: 'fixed',
-});
-
-const tokenTableHeaderCellStyle = css({
- backgroundColor: vars.backgroundColor.subtle,
- borderBlockEndColor: vars.border.default,
- borderBlockEndStyle: 'solid',
- borderBlockEndWidth: 1,
- fontWeight: vars.font.weight.bold,
- paddingBlock: vars.space.small,
- paddingInline: vars.space.small,
- textAlign: 'start',
-});
-
-const tokenTableCellStyle = css({
- borderBlockEndColor: vars.border.default,
- borderBlockEndStyle: 'solid',
- borderBlockEndWidth: 1,
- overflowWrap: 'anywhere',
- paddingBlock: vars.space.small,
- paddingInline: vars.space.small,
- verticalAlign: 'middle',
-});
-
-const examplesColExampleStyle = columnStyle('20rem');
-const examplesColTokenKeyStyle = columnStyle('16rem');
-const examplesColTokenValueStyle = columnStyle('14rem');
-
-const tokenRefColPathStyle = columnStyle('24rem');
-const tokenRefColTypeStyle = columnStyle('8rem');
-const tokenRefColRawStyle = columnStyle('24rem');
-const tokenRefColCssStyle = columnStyle('18rem');
-const tokenRefColPreviewStyle = columnStyle('8rem');
-
-const varsRefColPathStyle = columnStyle('24rem');
-const varsRefColValueStyle = columnStyle('28rem');
-const varsRefColPreviewStyle = columnStyle('10rem');
-
-const tokenPillStyle = css({
- backgroundColor: vars.backgroundColor.subtle,
- borderRadius: vars.borderRadius.small,
- display: 'inline-flex',
- fontFamily: vars.font.family.mono,
- fontSize: vars.font.size.standard,
- fontWeight: vars.font.weight.medium,
- paddingBlock: vars.space.xxsmall,
- paddingInline: vars.space.xsmall,
-});
-
-const tokenValueStyle = css({
- fontFamily: vars.font.family.mono,
- fontSize: vars.font.size.standard,
-});
-
-const exampleBarStyle = css({
- blockSize: '2.75rem',
- borderColor: vars.border.default,
- borderRadius: vars.borderRadius.small,
- borderStyle: 'solid',
- borderWidth: 1,
- inlineSize: '14rem',
- maxInlineSize: '100%',
-});
-
-const sampleTextStyle = css({
- color: vars.foregroundColor.primary,
- fontFamily: vars.font.family.body,
- fontSize: vars.font.size.standard,
- fontWeight: vars.font.weight.regular,
-});
-
-const motionTrackStyle = css({
- ...exampleBarStyle,
- alignItems: 'center',
- display: 'flex',
- paddingInline: vars.space.xsmall,
-});
-
-const motionRailStyle = css({
- backgroundColor: vars.backgroundColor.inputDisabled,
- blockSize: '1.25rem',
- borderColor: vars.border.default,
- borderRadius: vars.borderRadius.full,
- borderStyle: 'solid',
- borderWidth: 1,
- inlineSize: '100%',
- position: 'relative',
-});
-
-const motionDotBaseStyle = css({
- backgroundColor: vars.themeColor.paletteThemePrimary600,
- blockSize: '1rem',
- borderRadius: vars.borderRadius.full,
- boxShadow: `0 0 0 ${vars.space.xxsmall} ${vars.themeColor.paletteThemePrimary200}`,
- display: 'inline-block',
- insetBlockStart: '50%',
- position: 'absolute',
- transform: 'translateY(-50%)',
-});
-
-const motionPreviewDefaultDuration = '1200ms';
-const motionPreviewDefaultEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
-const motionDotInset = '0.25rem';
-const motionDotSize = '1rem';
-const motionPreviewAnimationName = 'luke-ui-motion-token-preview';
-
-function formatRawValue(value: unknown): string {
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
- return String(value);
- }
-
- return JSON.stringify(value);
-}
-
-function colorToDisplayValue(value: ColorTokenValue): string {
- if (value.colorSpace === 'srgb' && value.alpha === undefined) {
- try {
- const color = new ColorJs(colorToCssString(value));
- return color.toString({ collapse: false, format: 'hex' }).toUpperCase();
- } catch {
- return colorToCssString(value);
- }
- }
-
- return colorToCssString(value);
-}
-
-function getTokenValue(group: StoryTokenGroup, tokenName: string, groupName: string): unknown {
- const token = group[tokenName];
- if (token && typeof token === 'object' && '$value' in token) {
- return token.$value;
- }
-
- throw new TypeError(`Expected token value for ${groupName}.${tokenName}`);
-}
-
-function getTokenRows(groupName: string, group: StoryTokenGroup): Array {
- return tokenKeys(group).map((tokenName) => {
- const tokenValue = getTokenValue(group, tokenName, groupName);
- return {
- cssValue: toCssValue(group.$type, tokenValue),
- groupName,
- path: `${groupName}.${tokenName}`,
- type: group.$type,
- value: formatRawValue(tokenValue),
- };
- });
-}
-
-function getRows(
- groupName: string,
- group: StoryTokenGroup,
- toValues: (value: unknown) => { cssValue: string; displayValue: string },
-): Array {
- return tokenKeys(group).map((tokenName) => {
- const tokenValue = getTokenValue(group, tokenName, groupName);
- const values = toValues(tokenValue);
- return {
- cssValue: values.cssValue,
- displayValue: values.displayValue,
- path: `${groupName}.${tokenName}`,
- tokenName,
- };
- });
-}
-
-function getColorRows(groupName: string, group: StoryTokenGroup): Array {
- if (group.$type !== 'color') {
- return [];
- }
-
- return getRows(groupName, group, (value) => {
- if (!isColorTokenValue(value)) {
- return { cssValue: String(value), displayValue: String(value) };
- }
- return {
- cssValue: colorToCssString(value),
- displayValue: colorToDisplayValue(value),
- };
- });
-}
-
-function getDimensionRows(groupName: string, group: StoryTokenGroup): Array {
- if (group.$type !== 'dimension') {
- return [];
- }
-
- return getRows(groupName, group, (value) => {
- const cssValue = toCssValue('dimension', value);
- return { cssValue, displayValue: cssValue };
- });
-}
-
-function getDurationRows(groupName: string, group: StoryTokenGroup): Array {
- if (group.$type !== 'duration') {
- return [];
- }
-
- return getRows(groupName, group, (value) => {
- const cssValue = toCssValue('duration', value);
- return { cssValue, displayValue: cssValue };
- });
-}
-
-function getCubicBezierRows(groupName: string, group: StoryTokenGroup): Array {
- if (group.$type !== 'cubicBezier') {
- return [];
- }
-
- return getRows(groupName, group, (value) => {
- const cssValue = toCssValue('cubicBezier', value);
- return { cssValue, displayValue: cssValue };
- });
-}
-
-function getNumberRows(groupName: string, group: StoryTokenGroup): Array {
- if (group.$type !== 'number' && group.$type !== 'fontWeight') {
- return [];
- }
-
- return getRows(groupName, group, (value) => {
- const stringValue = String(value);
- return { cssValue: stringValue, displayValue: stringValue };
- });
-}
-
-function getStringRows(groupName: string, group: StoryTokenGroup): Array {
- return getRows(groupName, group, (value) => {
- const stringValue = String(value);
- return { cssValue: stringValue, displayValue: stringValue };
- });
-}
-
-function getVarRows(node: unknown, path: Array = []): Array {
- if (typeof node === 'string') {
- return node.startsWith('var(--') ? [{ cssVar: node, path: path.join('.') }] : [];
- }
-
- if (!node || typeof node !== 'object') {
- return [];
- }
-
- return Object.entries(node).flatMap(([key, value]) => getVarRows(value, [...path, key]));
-}
-
-function TokenPreview({ row }: { row: TokenRow }) {
- if (row.type === 'color') {
- return ;
- }
-
- if (row.groupName === 'borderRadius') {
- return (
-
- );
- }
-
- if (row.groupName === 'boxShadow') {
- return ;
- }
-
- if (row.groupName === 'borderWidth') {
- return (
-
- );
- }
-
- return -;
-}
-
-function VarPreview({ row }: { row: VarRow }) {
- if (row.path.includes('foregroundColor')) {
- return Aa;
- }
-
- if (row.path.includes('backgroundColor') || row.path.includes('themeColor')) {
- return ;
- }
-
- if (row.path.includes('borderColor') || row.path.startsWith('border.')) {
- return (
-
- );
- }
-
- if (row.path.includes('boxShadow')) {
- return ;
- }
-
- if (row.path.includes('borderRadius')) {
- return (
-
- );
- }
-
- return -;
-}
-
-function SharedPreviewBox({ size }: { size: string }) {
- return (
-
- );
-}
-
-function SizePreview({ size }: { size: string }) {
- return (
-
-
-
- );
-}
-
-function MotionPreview({ duration, easing }: { duration: string; easing: string }) {
- return (
-
- );
-}
-
-function RenderExamplePreview({ mode, row }: { mode: ExampleMode; row: ExampleRow }) {
- switch (mode) {
- case 'foreground':
- return (
-
- Aa
-
- );
- case 'border':
- return ;
- case 'background':
- return ;
- case 'space': {
- const boxSize = vars.space.large;
- return (
-
- );
- }
- case 'shadow':
- return ;
- case 'radius':
- return ;
- case 'borderWidth':
- return ;
- case 'fontSize':
- return Sample text;
- case 'lineHeight':
- return (
-
- Line one
-
- Line two
-
- );
- case 'fontWeight':
- return Weight;
- case 'fontFamily':
- return (
- Sphinx of black quartz
- );
- case 'controlSize':
- case 'iconSize':
- return ;
- case 'breakpoint':
- return {`min-width >= ${row.displayValue}`};
- case 'duration':
- return ;
- case 'easing':
- return ;
- case 'mediaQuery':
- return {row.displayValue};
- default:
- return -;
- }
-}
-
-function TokenExamplesTable({ mode, rows }: { mode: ExampleMode; rows: Array }) {
- return (
-
-
-
-
-
-
-
-
-
-
- | Example |
- Token key |
- Token value |
-
-
-
- {rows.map((row) => (
-
- |
-
- |
-
- {row.tokenName}
- |
-
- {row.displayValue}
- |
-
- ))}
-
-
-
-
- );
-}
-
-function TokensVarsReference() {
- return (
-
-
- Tokens
-
- Design-token source values, computed CSS value, and a visual preview.
-
-
-
-
-
-
-
-
-
-
-
-
- | Path |
- Type |
- $value |
- CSS value |
- Preview |
-
-
-
- {tokenRows.map((row) => (
-
- | {row.path} |
- {row.type} |
- {row.value} |
- {row.cssValue} |
-
-
- |
-
- ))}
-
-
-
-
-
- CSS Variables
-
- Flattened paths from `theme.vars`, including a quick visual preview.
-
-
-
-
-
-
-
-
-
-
- | Path |
- Variable |
- Preview |
-
-
-
- {varRows.map((row) => (
-
- | {row.path} |
- {row.cssVar} |
-
-
- |
-
- ))}
-
-
-
-
-
- );
-}
-
-type TokenTypeStoryConfig = {
- description?: string;
- mode: ExampleMode;
- rows: Array;
- title: string;
-};
-
-function TokenTypeStory({
- description = 'A visual reference for this token type.',
- mode,
- rows,
- title,
-}: TokenTypeStoryConfig) {
- return (
-
-
- {title}
-
- {description}
-
-
-
-
-
-
- );
-}
-
-type StoryCanvas = {
- getByRole: (role: string, options: { name: string }) => unknown;
-};
-
-const tokenTypeStoryConfigs = {
- backgroundColor: { mode: 'background', rows: backgroundColorRows, title: 'Background color' },
- borderColor: { mode: 'border', rows: borderColorRows, title: 'Border color' },
- borderRadius: { mode: 'radius', rows: borderRadiusRows, title: 'Border radius' },
- borderWidth: { mode: 'borderWidth', rows: borderWidthRows, title: 'Border width' },
- boxShadow: { mode: 'shadow', rows: shadowRows, title: 'Box shadow' },
- breakpoints: { mode: 'breakpoint', rows: breakpointRows, title: 'Breakpoints' },
- color: { mode: 'foreground', rows: foregroundColorRows, title: 'Color' },
- controlSize: { mode: 'controlSize', rows: controlSizeRows, title: 'Control size' },
- fontFamily: { mode: 'fontFamily', rows: fontFamilyRows, title: 'Font family' },
- fontSize: { mode: 'fontSize', rows: fontSizeRows, title: 'Font size' },
- fontWeight: { mode: 'fontWeight', rows: fontWeightRows, title: 'Font weight' },
- iconSize: { mode: 'iconSize', rows: iconSizeRows, title: 'Icon size' },
- lineHeight: { mode: 'lineHeight', rows: lineHeightRows, title: 'Line height' },
- mediaQueries: { mode: 'mediaQuery', rows: mediaQueryRows, title: 'Media queries' },
- motionDuration: { mode: 'duration', rows: motionDurationRows, title: 'Motion duration' },
- motionEasing: { mode: 'easing', rows: motionEasingRows, title: 'Motion easing' },
- space: { mode: 'space', rows: spaceRows, title: 'Space' },
- themeColor: { mode: 'background', rows: themeColorRows, title: 'Theme color' },
-} satisfies Record;
-
-async function expectHeadingInStory(canvas: StoryCanvas, heading: string) {
- await expect(canvas.getByRole('heading', { name: heading })).toBeInTheDocument();
-}
-
-function RenderTokenTypeStory(props: TokenTypeStoryConfig) {
- return ;
-}
-
-function tokenTypeStory(config: TokenTypeStoryConfig) {
- return {
- play: async ({ canvas }: { canvas: StoryCanvas }) => {
- await expectHeadingInStory(canvas, config.title);
- },
- render: () => ,
- };
-}
-
-export const BackgroundColor = meta.story(tokenTypeStory(tokenTypeStoryConfigs.backgroundColor));
-
-export const Color = meta.story(tokenTypeStory(tokenTypeStoryConfigs.color));
-
-export const BorderColor = meta.story(tokenTypeStory(tokenTypeStoryConfigs.borderColor));
-
-export const ThemeColor = meta.story(tokenTypeStory(tokenTypeStoryConfigs.themeColor));
-
-export const Space = meta.story(tokenTypeStory(tokenTypeStoryConfigs.space));
-
-export const BoxShadow = meta.story(tokenTypeStory(tokenTypeStoryConfigs.boxShadow));
-
-export const BorderRadius = meta.story(tokenTypeStory(tokenTypeStoryConfigs.borderRadius));
-
-export const BorderWidth = meta.story(tokenTypeStory(tokenTypeStoryConfigs.borderWidth));
-
-export const FontSize = meta.story(tokenTypeStory(tokenTypeStoryConfigs.fontSize));
-
-export const LineHeight = meta.story(tokenTypeStory(tokenTypeStoryConfigs.lineHeight));
-
-export const FontWeight = meta.story(tokenTypeStory(tokenTypeStoryConfigs.fontWeight));
-
-export const FontFamily = meta.story(tokenTypeStory(tokenTypeStoryConfigs.fontFamily));
-
-export const ControlSize = meta.story(tokenTypeStory(tokenTypeStoryConfigs.controlSize));
-
-export const IconSize = meta.story(tokenTypeStory(tokenTypeStoryConfigs.iconSize));
-
-export const MotionDuration = meta.story(tokenTypeStory(tokenTypeStoryConfigs.motionDuration));
-
-export const MotionEasing = meta.story(tokenTypeStory(tokenTypeStoryConfigs.motionEasing));
-
-export const Breakpoints = meta.story(tokenTypeStory(tokenTypeStoryConfigs.breakpoints));
-
-export const MediaQueries = meta.story(tokenTypeStory(tokenTypeStoryConfigs.mediaQueries));
-
-/**
- * Reference page for design tokens and the generated theme `vars` contract.
- */
-export const Reference = meta.story({
- play: async ({ canvas }) => {
- await expectHeadingInStory(canvas, 'Tokens');
- await expectHeadingInStory(canvas, 'CSS Variables');
- },
- render: () => ,
-});
diff --git a/packages/@luke-ui/react/src/tokens/values.ts b/packages/@luke-ui/react/src/tokens/values.ts
deleted file mode 100644
index ca32f9a5..00000000
--- a/packages/@luke-ui/react/src/tokens/values.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import type { CubicBezierTokenValue, DimensionTokenValue, DurationTokenValue } from './index.js';
-
-/** Token values for responsive breakpoints. */
-export const breakpointValues = {
- xsmall: { unit: 'px', value: 0 },
- small: { unit: 'px', value: 640 },
- medium: { unit: 'px', value: 768 },
- large: { unit: 'px', value: 1024 },
- xlarge: { unit: 'px', value: 1280 },
- xxlarge: { unit: 'px', value: 1536 },
-} as const satisfies Record;
-
-/** Token values for border radius. */
-export const borderRadiusValues = {
- none: { unit: 'px', value: 0 },
- small: { unit: 'px', value: 2 },
- medium: { unit: 'px', value: 4 },
- large: { unit: 'px', value: 8 },
- xlarge: { unit: 'px', value: 16 },
- full: { unit: 'px', value: 9999 },
-} as const satisfies Record;
-
-/** Token values for border width. */
-export const borderWidthValues = {
- thin: { unit: 'px', value: 1 },
- thick: { unit: 'px', value: 2 },
-} as const satisfies Record;
-
-/** Token values for box shadow. */
-export const boxShadowValues = {
- none: 'none',
- xsmall: '0px 1px 2px rgba(0, 0, 0, 0.2)',
- small: '0px 2px 4px rgba(0, 0, 0, 0.2)',
- medium: '0px 2px 8px rgba(0, 0, 0, 0.2)',
- large: '0px 4px 16px rgba(0, 0, 0, 0.2)',
- xlarge: '-8px 8px 32px rgba(0, 0, 0, 0.2)',
-} as const;
-
-/** Token values for control (interactive element) sizes. */
-export const controlSizeValues = {
- small: { unit: 'px', value: 32 },
- medium: { unit: 'px', value: 40 },
-} as const satisfies Record;
-
-/** Token values for font family. */
-export const fontFamilyValues = {
- sans: "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'",
- mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
-} as const;
-
-/** Token values for font size. */
-export const fontSizeValues = {
- xxsmall: { unit: 'px', value: 10 },
- xsmall: { unit: 'px', value: 12 },
- small: { unit: 'px', value: 14 },
- standard: { unit: 'px', value: 16 },
- medium: { unit: 'px', value: 20 },
- large: { unit: 'px', value: 25 },
- xlarge: { unit: 'px', value: 31 },
- xxlarge: { unit: 'px', value: 39 },
- h1: { unit: 'px', value: 39 },
- h2: { unit: 'px', value: 31 },
- h3: { unit: 'px', value: 25 },
- h4: { unit: 'px', value: 20 },
- h5: { unit: 'px', value: 16 },
- h6: { unit: 'px', value: 14 },
-} as const satisfies Record;
-
-/** Token values for font weight. */
-export const fontWeightValues = {
- regular: 400,
- medium: 500,
- bold: 700,
-} as const;
-
-/** Token values for icon size. */
-export const iconSizeValues = {
- xsmall: { unit: 'px', value: 16 },
- small: { unit: 'px', value: 20 },
- medium: { unit: 'px', value: 24 },
- large: { unit: 'px', value: 32 },
-} as const satisfies Record;
-
-/** Token values for line height. */
-export const lineHeightValues = {
- nospace: 1,
- tight: 1.25,
- loose: 1.5,
-} as const;
-
-/** Token values for motion (animation) duration. */
-export const motionDurationValues = {
- quick: { unit: 'ms', value: 120 },
- fast: { unit: 'ms', value: 150 },
- slow: { unit: 's', value: 1.2 },
- slower: { unit: 's', value: 2 },
-} as const satisfies Record;
-
-/** Token values for motion easing curves. */
-export const motionEasingValues = {
- emphasized: [0.42, 0, 0.58, 1],
- exit: [0, 0, 0.58, 1],
- linear: [0, 0, 1, 1],
- standard: [0.4, 0, 0.2, 1],
-} as const satisfies Record;
-
-/** Token values for spacing. */
-export const spaceValues = {
- none: { unit: 'px', value: 0 },
- xxsmall: { unit: 'px', value: 4 },
- xsmall: { unit: 'px', value: 8 },
- small: { unit: 'px', value: 12 },
- medium: { unit: 'px', value: 16 },
- large: { unit: 'px', value: 24 },
- xlarge: { unit: 'px', value: 32 },
- xxlarge: { unit: 'px', value: 40 },
-} as const satisfies Record;
diff --git a/packages/@luke-ui/react/src/types/icon-size.ts b/packages/@luke-ui/react/src/types/icon-size.ts
new file mode 100644
index 00000000..5c1b6a50
--- /dev/null
+++ b/packages/@luke-ui/react/src/types/icon-size.ts
@@ -0,0 +1,2 @@
+/** Semantic icon-size steps shared by icon-sized components. */
+export type IconSize = 'xsmall' | 'small' | 'medium' | 'large';
diff --git a/packages/@luke-ui/react/src/types/prettify.ts b/packages/@luke-ui/react/src/types/prettify.ts
new file mode 100644
index 00000000..1f2318e4
--- /dev/null
+++ b/packages/@luke-ui/react/src/types/prettify.ts
@@ -0,0 +1,6 @@
+/**
+ * Expands an object type into a more readable representation.
+ */
+export type Prettify = {
+ [K in keyof T]: T[K];
+};
diff --git a/packages/@luke-ui/react/src/types/public-props.test.ts b/packages/@luke-ui/react/src/types/public-props.test.ts
new file mode 100644
index 00000000..abb98ca7
--- /dev/null
+++ b/packages/@luke-ui/react/src/types/public-props.test.ts
@@ -0,0 +1,47 @@
+import { expectTypeOf, test } from 'vite-plus/test';
+import type { ButtonProps as PrimitiveButtonProps } from '../button/primitive/index.js';
+import type { LinkProps } from '../link/index.js';
+import type { TextProps } from '../text/index.js';
+import type { DistributiveOmit } from './distributive-omit.js';
+
+type ExampleUnion =
+ | { kind: 'button'; onPress: () => void; slot?: string }
+ | { href: string; kind: 'link'; slot?: string };
+
+const buttonProps = {
+ children: ({ isPending }) => (isPending ? 'Saving' : 'Save'),
+ className: ({ isPressed }) => (isPressed ? 'pressed' : 'resting'),
+ onPress: (event) => event.continuePropagation(),
+} satisfies PrimitiveButtonProps;
+
+const linkProps = {
+ children: 'Account',
+ className: ({ isHovered }) => (isHovered ? 'hovered' : 'resting'),
+ href: '/account',
+} satisfies LinkProps;
+
+const textProps = {
+ children: 'Account summary',
+ elementType: 'span',
+ id: 'account-summary-label',
+ onClick: () => undefined,
+} satisfies TextProps;
+
+// Type assertions are compile-time only.
+// oxlint-disable-next-line vitest/expect-expect
+test('preserves React Aria render props and native DOM props', () => {
+ expectTypeOf(buttonProps).toExtend();
+ expectTypeOf(linkProps).toExtend();
+ expectTypeOf(textProps).toExtend();
+});
+
+// Type assertions are compile-time only.
+// oxlint-disable-next-line vitest/expect-expect
+test('distributes over unions and rejects unknown omit keys', () => {
+ expectTypeOf>().toEqualTypeOf<
+ { kind: 'button'; onPress: () => void } | { href: string; kind: 'link' }
+ >();
+
+ // @ts-expect-error Unknown keys must not silently pass through DistributiveOmit.
+ expectTypeOf>();
+});
diff --git a/packages/@luke-ui/react/vitest.config.ts b/packages/@luke-ui/react/vitest.config.ts
index ad57728f..d6c75842 100644
--- a/packages/@luke-ui/react/vitest.config.ts
+++ b/packages/@luke-ui/react/vitest.config.ts
@@ -78,11 +78,12 @@ export default defineConfig({
enabled: true,
expect: {
toMatchScreenshot: {
- resolveScreenshotPath: ({ arg, ext, root }) =>
- path.join(
+ resolveScreenshotPath: ({ arg, ext, root }) => {
+ return path.join(
process.env.VISUAL_CAPTURE_DIR ?? path.join(root, '.visual-captures'),
`${arg}${ext}`,
- ),
+ );
+ },
comparatorName: 'pixelmatch',
comparatorOptions: {
// Baselines are captured on the same OS/browser as CI, and
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 325bcbe3..35d18723 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -111,9 +111,6 @@ catalogs:
babel-plugin-react-compiler:
specifier: ^1.0.0
version: 1.0.0
- colorjs.io:
- specifier: ^0.6.1
- version: 0.6.1
csstype:
specifier: ^3.2.3
version: 3.2.3
@@ -462,9 +459,6 @@ importers:
babel-plugin-react-compiler:
specifier: 'catalog:'
version: 1.0.0
- colorjs.io:
- specifier: 'catalog:'
- version: 0.6.1
node-html-parser:
specifier: 'catalog:'
version: 8.0.4
@@ -4915,9 +4909,6 @@ packages:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
- colorjs.io@0.6.1:
- resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==}
-
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -12939,8 +12930,6 @@ snapshots:
color-convert: 3.1.3
color-string: 2.1.4
- colorjs.io@0.6.1: {}
-
comma-separated-tokens@2.0.3: {}
commander@10.0.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 1ffcff32..6a3bc5bd 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -81,7 +81,6 @@ catalog:
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
babel-plugin-react-compiler: ^1.0.0
- colorjs.io: ^0.6.1
csstype: ^3.2.3
eslint-plugin-react-hooks: ^7.1.1
fumadocs-core: ^16.10.7