diff --git a/apps/docs/src/lib/story-wrapper.tsx b/apps/docs/src/lib/story-wrapper.tsx
index 867191c2..95e69e6e 100644
--- a/apps/docs/src/lib/story-wrapper.tsx
+++ b/apps/docs/src/lib/story-wrapper.tsx
@@ -1,6 +1,5 @@
import { IconSpritesheetProvider } from '@luke-ui/react/icon';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
-import { vars } from '@luke-ui/react/theme';
import type { ReactNode } from 'react';
type StoryWrapperProps = { children: ReactNode };
@@ -10,8 +9,9 @@ export function StoryWrapper({ children }: StoryWrapperProps) {
` or a subtree root. Importing
+one theme never pulls in the other.
+
+Without `data-color-mode`, a themed subtree follows `prefers-color-scheme`. Setting
+`data-color-mode="light"` or `data-color-mode="dark"` on the theme root, an ancestor, or any element
+inside the subtree forces that mode, and nested scopes can override it. Every scope also sets native
+`color-scheme` so form controls and scrollbars agree.
+
+Components still consume the legacy tokens until #96 migrates them to the new contract.
## Cascade layers
diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json
index 5315a594..85809aae 100644
--- a/packages/@luke-ui/react/package.json
+++ b/packages/@luke-ui/react/package.json
@@ -10,7 +10,8 @@
"type": "module",
"sideEffects": [
"**/*.css.ts",
- "./dist/stylesheet.css"
+ "./dist/stylesheet.css",
+ "./dist/themes/*.css"
],
"exports": {
"./button": "./dist/button/index.js",
@@ -34,11 +35,14 @@
"./text-field": "./dist/text-field/index.js",
"./text-field/primitive": "./dist/text-field/primitive/index.js",
"./theme": "./dist/theme/index.js",
+ "./themes": "./dist/themes/index.js",
"./tokens": "./dist/tokens/index.js",
"./utils": "./dist/utils/index.js",
"./package.json": "./package.json",
"./stylesheet.css": "./dist/stylesheet.css",
- "./spritesheet.svg": "./dist/spritesheet.svg"
+ "./spritesheet.svg": "./dist/spritesheet.svg",
+ "./themes/machined-edge.css": "./dist/themes/machined-edge.css",
+ "./themes/elmo.css": "./dist/themes/elmo.css"
},
"publishConfig": {
"access": "public"
@@ -61,9 +65,10 @@
"fix:lint": "vp lint . --type-aware --fix",
"fix:unsafe": "vp lint . --type-aware --fix-dangerously",
"generate": "pnpm run generate:assets",
- "generate:assets": "pnpm run generate:icons && pnpm run generate:color-tokens",
+ "generate:assets": "pnpm run generate:icons && pnpm run generate:color-tokens && pnpm run generate:themes",
"generate:color-tokens": "node scripts/generate-color-tokens.ts && vp fmt .generated/color-tokens.generated.ts --write",
"generate:icons": "tsx scripts/build-icons.ts",
+ "generate:themes": "tsx scripts/build-themes.ts",
"test": "pnpm run test:unit && pnpm run test:browser && pnpm run test:storybook && pnpm run test:visual",
"test:browser": "vp test run --project=browser",
"test:storybook": "vp test run --project=storybook",
diff --git a/packages/@luke-ui/react/scripts/build-themes.ts b/packages/@luke-ui/react/scripts/build-themes.ts
new file mode 100644
index 00000000..6394c690
--- /dev/null
+++ b/packages/@luke-ui/react/scripts/build-themes.ts
@@ -0,0 +1,28 @@
+#!/usr/bin/env tsx
+
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { buildTheme } from '../src/theme/build-theme.js';
+import { elmoFoundation, machinedEdgeFoundation } from '../src/theme/foundations.js';
+
+const foundations = [machinedEdgeFoundation, elmoFoundation];
+
+async function main() {
+ await Promise.all(
+ foundations.map(async (foundation) => {
+ const outputPath = fileURLToPath(
+ new URL(`../dist/themes/${foundation.name}.css`, import.meta.url),
+ );
+ await mkdir(dirname(outputPath), { recursive: true });
+ await writeFile(outputPath, buildTheme(foundation), 'utf8');
+ process.stdout.write(`Generated dist/themes/${foundation.name}.css\n`);
+ }),
+ );
+}
+
+main().catch((err) => {
+ const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
+ process.stderr.write(`Failed to build themes: ${message}\n`);
+ process.exit(1);
+});
diff --git a/packages/@luke-ui/react/src/button/button.stories.tsx b/packages/@luke-ui/react/src/button/button.stories.tsx
index 8f4fa90c..823112d5 100644
--- a/packages/@luke-ui/react/src/button/button.stories.tsx
+++ b/packages/@luke-ui/react/src/button/button.stories.tsx
@@ -1,9 +1,9 @@
import type { ButtonProps } from '@luke-ui/react/button';
import { Button } from '@luke-ui/react/button';
import { Icon } from '@luke-ui/react/icon';
-import { vars } from '@luke-ui/react/theme';
import type { CSSProperties } from 'react';
import preview from '../../.storybook/preview.js';
+import { vars } from '../theme.css.js';
const meta = preview.meta({
component: Button,
diff --git a/packages/@luke-ui/react/src/icon/icon.stories.tsx b/packages/@luke-ui/react/src/icon/icon.stories.tsx
index 00b8444b..f3c1c3c8 100644
--- a/packages/@luke-ui/react/src/icon/icon.stories.tsx
+++ b/packages/@luke-ui/react/src/icon/icon.stories.tsx
@@ -1,11 +1,11 @@
import { Button } from '@luke-ui/react/button';
import type { IconProps } from '@luke-ui/react/icon';
import { createIcon, Icon, iconNames } from '@luke-ui/react/icon';
-import { vars } from '@luke-ui/react/theme';
import { tokenKeys, tokens } from '@luke-ui/react/tokens';
import type { CSSProperties } from 'react';
import { expect } from 'storybook/test';
import preview from '../../.storybook/preview.js';
+import { vars } from '../theme.css.js';
const meta = preview.meta({
component: Icon,
diff --git a/packages/@luke-ui/react/src/styles/utilities.stories.tsx b/packages/@luke-ui/react/src/styles/utilities.stories.tsx
index 7e0cfc19..b3048ac2 100644
--- a/packages/@luke-ui/react/src/styles/utilities.stories.tsx
+++ b/packages/@luke-ui/react/src/styles/utilities.stories.tsx
@@ -1,9 +1,9 @@
import { Button } from '@luke-ui/react/button';
import { createSprinkles } from '@luke-ui/react/styles';
-import { vars } from '@luke-ui/react/theme';
import { mergeProps } from '@luke-ui/react/utils';
import type { CSSProperties } from 'react';
import preview from '../../.storybook/preview.js';
+import { vars } from '../theme.css.js';
const meta = preview.meta({
title: 'Foundation/Utilities',
diff --git a/packages/@luke-ui/react/src/text/text.stories.tsx b/packages/@luke-ui/react/src/text/text.stories.tsx
index 0d60ba99..3ea916c3 100644
--- a/packages/@luke-ui/react/src/text/text.stories.tsx
+++ b/packages/@luke-ui/react/src/text/text.stories.tsx
@@ -1,12 +1,12 @@
import type { TextProps } from '@luke-ui/react/text';
import { Text } from '@luke-ui/react/text';
-import { vars } from '@luke-ui/react/theme';
import { tokenKeys, tokens } from '@luke-ui/react/tokens';
import { mergeProps } from '@luke-ui/react/utils';
import type { CSSProperties } from 'react';
import { expect } from 'storybook/test';
import preview from '../../.storybook/preview.js';
import { createSprinkles } from '../styles/index.js';
+import { vars } from '../theme.css.js';
const meta = preview.meta({
component: Text,
diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts
new file mode 100644
index 00000000..7a2eaf38
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts
@@ -0,0 +1,333 @@
+import { describe, expect, it } from 'vite-plus/test';
+import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js';
+import { buildTheme, ThemeContrastError, themeClassName } from './build-theme.js';
+import { contrastRatio, parseColor } from './color.js';
+import { flattenThemeContract } from './contract.js';
+import type { ThemeFoundation } from './foundation.js';
+import {
+ defaultFontWeights,
+ defaultRadius,
+ defaultSourceColors,
+ deriveConcentricRadius,
+} from './foundation.js';
+import { elmoFoundation, machinedEdgeFoundation } from './foundations.js';
+
+const pairs = flattenThemeContract();
+const isModePath = (path: string) => 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);
+
+/**
+ * Splits the generated stylesheet into its five rule blocks: identity, base light, media-query
+ * dark, explicit light, and explicit dark.
+ */
+function splitBlocks(css: string) {
+ const blocks = css.split('\n\n').filter((block) => block.trim() !== '');
+ if (blocks.length !== 5) throw new Error(`expected 5 rule blocks, found ${blocks.length}`);
+ const [identity, baseLight, mediaDark, explicitLight, explicitDark] = blocks;
+ if (
+ identity === undefined ||
+ baseLight === undefined ||
+ mediaDark === undefined ||
+ explicitLight === undefined ||
+ explicitDark === undefined
+ ) {
+ throw new Error('expected every generated theme rule block to be defined');
+ }
+ return { baseLight, explicitDark, explicitLight, identity, mediaDark };
+}
+
+function countOccurrences(text: string, needle: string): number {
+ return text.split(needle).length - 1;
+}
+
+function extractValue(block: string, varName: string): string {
+ const match = new RegExp(`${varName}: ([^;]+);`).exec(block);
+ if (match === null || match[1] === undefined) {
+ throw new Error(`missing ${varName} in block`);
+ }
+ return match[1];
+}
+
+describe('buildTheme output', () => {
+ const css = buildTheme(machinedEdgeFoundation);
+ const blocks = splitBlocks(css);
+
+ it('emits the identity class, colour schemes, and all mode scoping rules', () => {
+ expect(css).toContain('.luke-ui-theme-machined-edge {');
+ expect(css).toContain('@media (prefers-color-scheme: dark) {');
+ expect(blocks.baseLight).toContain('color-scheme: light;');
+ expect(blocks.mediaDark).toContain('color-scheme: dark;');
+ for (const mode of ['light', 'dark']) {
+ expect(css).toContain(`.luke-ui-theme-machined-edge[data-color-mode='${mode}'],`);
+ expect(css).toContain(`.luke-ui-theme-machined-edge [data-color-mode='${mode}'],`);
+ expect(css).toContain(`[data-color-mode='${mode}'] .luke-ui-theme-machined-edge {`);
+ }
+ });
+
+ it('declares every identity variable exactly once in the identity block', () => {
+ const counts = identityVarNames.map((varName) => [
+ varName,
+ countOccurrences(blocks.identity, `${varName}: `),
+ ]);
+ expect(counts).toEqual(identityVarNames.map((varName) => [varName, 1]));
+ const modeCounts = modeVarNames.map((varName) =>
+ countOccurrences(blocks.identity, `${varName}: `),
+ );
+ expect(modeCounts).toEqual(modeVarNames.map(() => 0));
+ });
+
+ it('declares every colour and depth variable exactly once per mode block', () => {
+ const modeBlocks = [
+ blocks.baseLight,
+ blocks.mediaDark,
+ blocks.explicitLight,
+ blocks.explicitDark,
+ ];
+ for (const block of modeBlocks) {
+ const counts = modeVarNames.map((varName) => [
+ varName,
+ countOccurrences(block, `${varName}: `),
+ ]);
+ expect(counts).toEqual(modeVarNames.map((varName) => [varName, 1]));
+ const identityCounts = identityVarNames.map((varName) =>
+ countOccurrences(block, `${varName}: `),
+ );
+ expect(identityCounts).toEqual(identityVarNames.map(() => 0));
+ }
+ });
+
+ it('emits every colour value in OKLCH', () => {
+ const colorVarNames = pairs
+ .filter(([path]) => path.startsWith('color.'))
+ .map(([, varName]) => varName);
+ for (const block of [blocks.baseLight, blocks.mediaDark]) {
+ const nonOklch = colorVarNames.filter(
+ (varName) => !extractValue(block, varName).startsWith('oklch('),
+ );
+ expect(nonOklch).toEqual([]);
+ }
+ });
+
+ it('uses the stable kebab-case variable names', () => {
+ expect(css).toContain('--luke-color-intent-danger-surface-solid-hover');
+ expect(css).toContain('--luke-color-surface-disabled');
+ expect(css).toContain('--luke-color-intent-accent-text-hover');
+ expect(css).toContain('--luke-depth-raised');
+ expect(css).toContain('--luke-space-100:');
+ expect(css).toContain('--luke-control-size-small');
+ expect(css).toContain('--luke-motion-easing-standard');
+ expect(css).toContain('--luke-font-weight-body');
+ expect(css).toContain('--luke-font-100-font-size: 12px');
+ expect(css).toContain('--luke-font-300-line-height: 24px');
+ expect(css).toContain('--luke-font-900-letter-spacing: -0.025em');
+ expect(css).toContain('--luke-icon-size-xsmall: 16px');
+ expect(css).toContain('--luke-icon-size-large: 32px');
+ });
+});
+
+describe('concentric corners', () => {
+ it('derives the outer radius from semantic inner-radius and gap values', () => {
+ expect(deriveConcentricRadius('var(--luke-radius-control)', 'var(--luke-space-200)')).toBe(
+ 'calc(var(--luke-radius-control) + var(--luke-space-200))',
+ );
+ });
+});
+
+describe('buildTheme defaults', () => {
+ const minimalFoundation: ThemeFoundation = {
+ dark: machinedEdgeFoundation.dark,
+ light: machinedEdgeFoundation.light,
+ name: 'minimal-check',
+ };
+
+ it('fills omitted optional fields with the documented defaults', () => {
+ const explicitFoundation: ThemeFoundation = {
+ dark: {
+ color: { ...minimalFoundation.dark.color, ...defaultSourceColors.dark },
+ material: minimalFoundation.dark.material,
+ },
+ light: {
+ color: { ...minimalFoundation.light.color, ...defaultSourceColors.light },
+ material: minimalFoundation.light.material,
+ },
+ name: 'minimal-check',
+ radius: { ...defaultRadius },
+ typography: { fontFamily: 'inter', fontWeight: { ...defaultFontWeights } },
+ };
+ const css = buildTheme(minimalFoundation);
+ expect(css).toBe(buildTheme(explicitFoundation));
+ for (const varName of modeVarNames) {
+ expect(css).toContain(`${varName}: `);
+ }
+ expect(css).toContain('--luke-color-border-focus: oklch(');
+ });
+});
+
+describe('buildTheme independent modes', () => {
+ it('derives each mode from its own sources rather than inverting light', () => {
+ const greenPurpleFoundation: ThemeFoundation = {
+ dark: {
+ ...machinedEdgeFoundation.dark,
+ color: { ...machinedEdgeFoundation.dark.color, accent: 'oklch(0.75 0.12 300)' },
+ },
+ light: {
+ ...machinedEdgeFoundation.light,
+ color: { ...machinedEdgeFoundation.light.color, accent: 'oklch(0.5 0.13 150)' },
+ },
+ name: 'green-purple',
+ };
+ const blocks = splitBlocks(buildTheme(greenPurpleFoundation));
+ const solidVar = '--luke-color-intent-accent-surface-solid';
+ const lightSolid = parseColor(extractValue(blocks.baseLight, solidVar));
+ const darkSolid = parseColor(extractValue(blocks.mediaDark, solidVar));
+ expect(lightSolid.h).toBeCloseTo(150, 0);
+ expect(darkSolid.h).toBeCloseTo(300, 0);
+ expect(extractValue(blocks.baseLight, solidVar)).not.toBe(
+ extractValue(blocks.mediaDark, solidVar),
+ );
+ });
+});
+
+describe('buildTheme contrast failures', () => {
+ function buildFailures(foundation: ThemeFoundation): ThemeContrastError {
+ const caught = (() => {
+ try {
+ buildTheme(foundation);
+ return null;
+ } catch (error) {
+ return error;
+ }
+ })();
+ if (caught instanceof ThemeContrastError) return caught;
+ throw new Error('expected buildTheme to throw ThemeContrastError');
+ }
+
+ it('rejects a low-contrast focus colour, naming mode, pair, and required ratio', () => {
+ const error = buildFailures({
+ ...machinedEdgeFoundation,
+ light: {
+ ...machinedEdgeFoundation.light,
+ color: { ...machinedEdgeFoundation.light.color, focus: '#c5d9ff' },
+ },
+ name: 'bad-focus',
+ });
+ const failure = error.failures.find(
+ (candidate) =>
+ candidate.foreground === 'color.border.focus' &&
+ candidate.background === 'color.surface.canvas',
+ );
+ expect(failure).toBeDefined();
+ expect(failure?.mode).toBe('light');
+ expect(failure?.required).toBe(3);
+ expect(failure?.ratio).toBeLessThan(3);
+ expect(error.message).toMatch(
+ /light: color\.border\.focus on color\.surface\.canvas — \d+\.\d\d:1 < 3:1/,
+ );
+ });
+
+ it('rejects a light dark-mode neutral through the text lightness windows', () => {
+ const error = buildFailures({
+ ...machinedEdgeFoundation,
+ dark: {
+ ...machinedEdgeFoundation.dark,
+ color: { ...machinedEdgeFoundation.dark.color, neutral: '#9a9a9a' },
+ },
+ name: 'bad-dark-neutral',
+ });
+ const failure = error.failures.find(
+ (candidate) =>
+ candidate.mode === 'dark' &&
+ candidate.foreground === 'color.text.primary' &&
+ 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');
+ });
+
+ it('rejects a mid-lightness accent that no onSolid colour can sit on', () => {
+ const error = buildFailures({
+ ...machinedEdgeFoundation,
+ light: {
+ ...machinedEdgeFoundation.light,
+ color: { ...machinedEdgeFoundation.light.color, accent: '#7a7a7a' },
+ },
+ name: 'bad-accent',
+ });
+ const onSolidFailures = error.failures.filter(
+ (candidate) => candidate.foreground === 'color.intent.accent.onSolid',
+ );
+ expect(onSolidFailures.length).toBeGreaterThan(0);
+ expect(onSolidFailures[0]?.background).toMatch(/^color\.intent\.accent\.surface\.solid/);
+ expect(error.message).toContain('light: color.intent.accent.onSolid');
+ });
+
+ it('aggregates every failing pair into one error', () => {
+ const error = buildFailures({
+ ...machinedEdgeFoundation,
+ light: {
+ ...machinedEdgeFoundation.light,
+ color: {
+ ...machinedEdgeFoundation.light.color,
+ accent: '#7a7a7a',
+ focus: '#c5d9ff',
+ },
+ },
+ name: 'bad-both',
+ });
+ const foregrounds = new Set(error.failures.map((failure) => failure.foreground));
+ expect(foregrounds.has('color.border.focus')).toBe(true);
+ expect(foregrounds.has('color.intent.accent.onSolid')).toBe(true);
+ expect(error.failures.length).toBeGreaterThan(2);
+ expect(error.message.split('\n').length).toBe(error.failures.length + 1);
+ });
+});
+
+describe('bundled themes meet WCAG 2.2 AA', () => {
+ const surfaceVarNames = [
+ '--luke-color-surface-canvas',
+ '--luke-color-surface-resting',
+ '--luke-color-surface-recessed',
+ '--luke-color-surface-floating',
+ '--luke-color-surface-overlay',
+ ];
+
+ for (const foundation of [machinedEdgeFoundation, elmoFoundation]) {
+ it(`${foundation.name} passes recomputed text and border contrast in both modes`, () => {
+ const blocks = splitBlocks(buildTheme(foundation));
+ for (const block of [blocks.baseLight, blocks.mediaDark]) {
+ const textPrimary = parseColor(extractValue(block, '--luke-color-text-primary'));
+ const borderControl = parseColor(extractValue(block, '--luke-color-border-control'));
+ const canvas = parseColor(extractValue(block, '--luke-color-surface-canvas'));
+ for (const varName of surfaceVarNames) {
+ const surface = parseColor(extractValue(block, varName));
+ expect(contrastRatio(textPrimary, surface)).toBeGreaterThanOrEqual(4.5);
+ }
+ expect(contrastRatio(borderControl, canvas)).toBeGreaterThanOrEqual(3);
+ }
+ });
+ }
+});
+
+describe('bundled theme identity', () => {
+ it('exports class-name constants that match the emitted identity classes', () => {
+ expect(machinedEdgeThemeClassName).toBe('luke-ui-theme-machined-edge');
+ expect(elmoThemeClassName).toBe('luke-ui-theme-elmo');
+ expect(buildTheme(machinedEdgeFoundation)).toContain(`.${machinedEdgeThemeClassName} {`);
+ expect(buildTheme(elmoFoundation)).toContain(`.${elmoThemeClassName} {`);
+ });
+
+ it('keeps the bundled themes isolated from each other', () => {
+ expect(buildTheme(elmoFoundation)).not.toContain(machinedEdgeThemeClassName);
+ expect(buildTheme(machinedEdgeFoundation)).not.toContain(elmoThemeClassName);
+ });
+
+ it('rejects theme names that are not kebab-case', () => {
+ expect(() => themeClassName('Machined Edge')).toThrow(/kebab-case/);
+ expect(() => themeClassName('-leading')).toThrow(/kebab-case/);
+ expect(() => themeClassName('double--hyphen')).toThrow(/kebab-case/);
+ expect(() => themeClassName('9lives')).toThrow(/kebab-case/);
+ expect(themeClassName('machined-edge')).toBe('luke-ui-theme-machined-edge');
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts
new file mode 100644
index 00000000..6104383e
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/build-theme.ts
@@ -0,0 +1,795 @@
+import type { Oklch } from './color.js';
+import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js';
+import { flattenThemeContract } from './contract.js';
+import type {
+ ThemeFoundation,
+ ThemeMaterialProfile,
+ ThemeModeFoundation,
+ ThemeSourceColors,
+} from './foundation.js';
+import {
+ defaultFontFamily,
+ defaultFontWeights,
+ defaultRadius,
+ defaultSourceColors,
+ themeFontFamilyStacks,
+} from './foundation.js';
+
+/**
+ * Compiles a theme foundation into a complete static stylesheet.
+ *
+ * Pure and Node-compatible: no vanilla-extract, no DOM, and deterministic output. Returns
+ * stylesheet text containing the theme identity class plus both colour-mode blocks, selected by
+ * `data-color-mode` with `prefers-color-scheme` as the fallback. Throws {@link ThemeContrastError}
+ * naming the mode and token pair when any generated pair misses WCAG 2.2 AA (4.5:1 for text pairs,
+ * 3:1 for non-text UI pairs). Colours are computed and emitted in OKLCH.
+ */
+export function buildTheme(foundation: ThemeFoundation): string {
+ validateFoundation(foundation);
+ const light = buildModeValues('light', foundation.light);
+ const dark = buildModeValues('dark', foundation.dark);
+ const failures = [...light.failures, ...dark.failures];
+ if (failures.length > 0) throw new ThemeContrastError(failures);
+ return assembleStylesheet(foundation, light.values, dark.values);
+}
+
+/**
+ * Returns the identity class for a theme name, `luke-ui-theme-${name}`. Throws when the name is
+ * not kebab-case.
+ */
+export function themeClassName(name: string): string {
+ if (!THEME_NAME_PATTERN.test(name)) {
+ throw new Error(
+ `Theme name "${name}" must be kebab-case: lowercase letters and digits separated by ` +
+ 'single hyphens, starting with a letter.',
+ );
+ }
+ return `luke-ui-theme-${name}`;
+}
+
+/** One WCAG contrast failure recorded while generating a theme. */
+export interface ThemeContrastFailure {
+ /** The colour mode the pair was generated for. */
+ mode: 'light' | 'dark';
+ /** Token path of the foreground colour, for example `color.text.primary`. */
+ foreground: string;
+ /** Token path of the background colour, for example `color.surface.floating`. */
+ background: string;
+ /** The contrast ratio achieved by the best attempt. */
+ ratio: number;
+ /** The WCAG 2.2 AA ratio the pair must reach. */
+ required: number;
+}
+
+/**
+ * Thrown by `buildTheme` when generated colours miss WCAG 2.2 AA contrast. Aggregates every
+ * failing mode-and-pair before throwing, one per message line.
+ */
+export class ThemeContrastError extends Error {
+ /** Every failing pair across both modes. */
+ readonly failures: Array
;
+
+ constructor(failures: Array) {
+ super(
+ [
+ 'Theme foundation fails WCAG 2.2 AA contrast:',
+ ...failures.map(
+ (failure) =>
+ `${failure.mode}: ${failure.foreground} on ${failure.background} — ` +
+ `${failure.ratio.toFixed(2)}:1 < ${failure.required}:1`,
+ ),
+ ].join('\n'),
+ );
+ this.failures = failures;
+ this.name = 'ThemeContrastError';
+ }
+}
+
+type ColorMode = 'light' | 'dark';
+
+const THEME_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
+const TEXT_RATIO = 4.5;
+const UI_RATIO = 3;
+// Solve slightly past the required ratio so 4-decimal OKLCH emission cannot round a passing pair
+// below the WCAG threshold.
+const RATIO_HEADROOM = 0.05;
+
+const INTENT_NAMES = ['neutral', 'accent', 'info', 'success', 'warning', 'danger'] as const;
+const FULL_KIT_INTENTS = ['accent', 'info', 'success', 'warning', 'danger'] as const;
+const SOURCE_COLOR_FIELDS = [
+ 'neutral',
+ 'accent',
+ 'info',
+ 'success',
+ 'warning',
+ 'danger',
+ 'focus',
+] as const;
+
+const SPACE_VALUES = {
+ 100: '4px',
+ 200: '8px',
+ 300: '12px',
+ 400: '16px',
+ 600: '24px',
+ 800: '32px',
+ 1000: '40px',
+ 1200: '48px',
+ 1600: '64px',
+} as const;
+
+const MOTION_VALUES = {
+ 'motion.duration.ambient': '800ms',
+ 'motion.duration.fast': '120ms',
+ 'motion.duration.medium': '200ms',
+ 'motion.duration.slow': '300ms',
+ 'motion.easing.enter': 'cubic-bezier(0, 0, 0, 1)',
+ 'motion.easing.exit': 'cubic-bezier(0.3, 0, 1, 1)',
+ 'motion.easing.standard': 'cubic-bezier(0, 0, 0.4, 1)',
+} as const;
+
+const FONT_VALUES = {
+ 'font.100.fontSize': '12px',
+ 'font.100.letterSpacing': '0.0025em',
+ 'font.100.lineHeight': '16px',
+ 'font.200.fontSize': '14px',
+ 'font.200.letterSpacing': '0',
+ 'font.200.lineHeight': '20px',
+ 'font.300.fontSize': '16px',
+ 'font.300.letterSpacing': '0',
+ 'font.300.lineHeight': '24px',
+ 'font.400.fontSize': '18px',
+ 'font.400.letterSpacing': '-0.0025em',
+ 'font.400.lineHeight': '26px',
+ 'font.500.fontSize': '20px',
+ 'font.500.letterSpacing': '-0.005em',
+ 'font.500.lineHeight': '28px',
+ 'font.600.fontSize': '24px',
+ 'font.600.letterSpacing': '-0.00625em',
+ 'font.600.lineHeight': '30px',
+ 'font.700.fontSize': '28px',
+ 'font.700.letterSpacing': '-0.0075em',
+ 'font.700.lineHeight': '36px',
+ 'font.800.fontSize': '35px',
+ 'font.800.letterSpacing': '-0.01em',
+ 'font.800.lineHeight': '40px',
+ 'font.900.fontSize': '60px',
+ 'font.900.letterSpacing': '-0.025em',
+ 'font.900.lineHeight': '60px',
+} as const;
+
+const ICON_SIZE_VALUES = {
+ 'iconSize.large': '32px',
+ 'iconSize.medium': '24px',
+ 'iconSize.small': '20px',
+ 'iconSize.xsmall': '16px',
+} as const;
+
+interface LightnessWindows {
+ borderControl: [number, number];
+ intentBorder: [number, number];
+ intentText: [number, number];
+ textPrimary: [number, number];
+ textSecondary: [number, number];
+}
+
+// Windows encode mode character: dark-mode text must stay light and light-mode text must stay
+// dark, so an unworkable source colour becomes an honest contrast failure at the window edge
+// instead of an off-character colour.
+const LIGHTNESS_WINDOWS: Record = {
+ dark: {
+ borderControl: [0.5, 0.8],
+ intentBorder: [0.5, 0.85],
+ intentText: [0.62, 0.92],
+ textPrimary: [0.8, 0.98],
+ textSecondary: [0.68, 0.88],
+ },
+ light: {
+ borderControl: [0.35, 0.62],
+ intentBorder: [0.38, 0.62],
+ intentText: [0.25, 0.56],
+ textPrimary: [0.1, 0.35],
+ textSecondary: [0.3, 0.52],
+ },
+};
+
+interface ModeValues {
+ failures: Array;
+ values: Record;
+}
+
+function buildModeValues(mode: ColorMode, modeFoundation: ThemeModeFoundation): ModeValues {
+ const colors = buildModeColors(mode, modeFoundation);
+ const failures = validateContrast(mode, colors);
+ const values: Record = {};
+ for (const [path, color] of Object.entries(colors)) {
+ values[path] = formatOklch(color);
+ }
+ Object.assign(values, buildDepthValues(modeFoundation.material));
+ return { failures, values };
+}
+
+function buildModeColors(
+ mode: ColorMode,
+ modeFoundation: ThemeModeFoundation,
+): Record {
+ const isLight = mode === 'light';
+ const windows = LIGHTNESS_WINDOWS[mode];
+ const source = resolveSourceColors(mode, modeFoundation.color);
+ const neutral = source.neutral;
+ const neutralChroma = Math.min(neutral.c, 0.02);
+
+ const canvas = gamutMapOklch({ ...neutral, c: Math.min(neutral.c, 0.015) });
+ const surfaceAt = (delta: number) => gamutMapOklch({ ...canvas, l: clampUnit(canvas.l + delta) });
+ const surfaces = isLight
+ ? {
+ canvas,
+ floating: surfaceAt(0.012),
+ overlay: surfaceAt(0.015),
+ recessed: surfaceAt(-0.035),
+ resting: surfaceAt(0.012),
+ }
+ : {
+ canvas,
+ floating: surfaceAt(0.07),
+ overlay: surfaceAt(0.09),
+ recessed: surfaceAt(-0.025),
+ resting: surfaceAt(0.04),
+ };
+ const allSurfaces = [
+ surfaces.canvas,
+ surfaces.resting,
+ surfaces.recessed,
+ surfaces.floating,
+ surfaces.overlay,
+ ];
+ const baseSurfaces = [surfaces.canvas, surfaces.resting, surfaces.recessed];
+
+ const colors: Record = {
+ 'color.surface.canvas': surfaces.canvas,
+ 'color.surface.resting': surfaces.resting,
+ 'color.surface.recessed': surfaces.recessed,
+ 'color.surface.floating': surfaces.floating,
+ 'color.surface.overlay': surfaces.overlay,
+ };
+
+ colors['color.text.primary'] = solveLightness({
+ backgrounds: allSurfaces,
+ chroma: neutralChroma,
+ hue: neutral.h,
+ mode,
+ ratio: TEXT_RATIO,
+ startLightness: midpoint(windows.textPrimary),
+ window: windows.textPrimary,
+ });
+ colors['color.text.secondary'] = solveLightness({
+ backgrounds: allSurfaces,
+ chroma: neutralChroma,
+ hue: neutral.h,
+ mode,
+ ratio: TEXT_RATIO,
+ startLightness: midpoint(windows.textSecondary),
+ window: windows.textSecondary,
+ });
+ colors['color.border.control'] = solveLightness({
+ backgrounds: baseSurfaces,
+ chroma: neutralChroma,
+ hue: neutral.h,
+ mode,
+ ratio: UI_RATIO,
+ startLightness: midpoint(windows.borderControl),
+ window: windows.borderControl,
+ });
+ colors['color.border.decorative'] = gamutMapOklch({
+ c: neutralChroma,
+ h: neutral.h,
+ l: clampUnit(canvas.l + (isLight ? -0.08 : 0.1)),
+ });
+ colors['color.border.focus'] = source.focus;
+
+ // 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) });
+ 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);
+
+ for (const intent of FULL_KIT_INTENTS) {
+ const intentSource = source[intent];
+ const kit = buildIntentKit(mode, canvas, baseSurfaces, intentSource, windows);
+ for (const [key, value] of Object.entries(kit)) {
+ colors[`color.intent.${intent}.${key}`] = value;
+ }
+ }
+
+ const accentText = colors['color.intent.accent.text'];
+ if (accentText !== undefined) {
+ colors['color.intent.accent.textHover'] = gamutMapOklch({
+ ...accentText,
+ l: clampUnit(accentText.l + (isLight ? -0.06 : 0.06)),
+ });
+ }
+
+ const neutralSolid = gamutMapOklch({
+ c: neutralChroma,
+ h: neutral.h,
+ l: isLight ? 0.32 : 0.85,
+ });
+ const neutralSolidAt = (delta: number) =>
+ 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);
+ colors['color.intent.neutral.surface.subtle'] = neutralSubtle.subtle;
+ colors['color.intent.neutral.surface.subtleHover'] = neutralSubtle.subtleHover;
+ colors['color.intent.neutral.surface.subtlePressed'] = neutralSubtle.subtlePressed;
+ colors['color.intent.neutral.surface.solid'] = neutralSolid;
+ colors['color.intent.neutral.surface.solidHover'] = neutralSolidHover;
+ colors['color.intent.neutral.surface.solidPressed'] = neutralSolidPressed;
+ colors['color.intent.neutral.onSolid'] = chooseOnSolid(neutral.h, [
+ neutralSolid,
+ neutralSolidHover,
+ neutralSolidPressed,
+ ]);
+
+ return colors;
+}
+
+interface IntentKit {
+ border: Oklch;
+ onSolid: Oklch;
+ 'surface.solid': Oklch;
+ 'surface.solidHover': Oklch;
+ 'surface.solidPressed': Oklch;
+ 'surface.subtle': Oklch;
+ 'surface.subtleHover': Oklch;
+ 'surface.subtlePressed': Oklch;
+ text: Oklch;
+}
+
+function buildIntentKit(
+ mode: ColorMode,
+ canvas: Oklch,
+ baseSurfaces: Array,
+ source: Oklch,
+ windows: LightnessWindows,
+): IntentKit {
+ const isLight = mode === 'light';
+ const hoverDirection = isLight ? -1 : 1;
+ const solid = source;
+ const solidHover = gamutMapOklch({ ...solid, l: clampUnit(solid.l + 0.05 * hoverDirection) });
+ const solidPressed = gamutMapOklch({ ...solid, l: clampUnit(solid.l + 0.09 * hoverDirection) });
+ const subtle = buildSubtleTrio(mode, canvas, source);
+ const text = solveLightness({
+ backgrounds: [...baseSurfaces, subtle.subtle, subtle.subtleHover, subtle.subtlePressed],
+ chroma: Math.min(source.c, 0.13),
+ hue: source.h,
+ mode,
+ ratio: TEXT_RATIO,
+ startLightness: source.l,
+ window: windows.intentText,
+ });
+ const border = solveLightness({
+ backgrounds: baseSurfaces,
+ chroma: Math.min(source.c, 0.12),
+ hue: source.h,
+ mode,
+ ratio: UI_RATIO,
+ startLightness: source.l,
+ window: windows.intentBorder,
+ });
+ return {
+ border,
+ onSolid: chooseOnSolid(source.h, [solid, solidHover, solidPressed]),
+ 'surface.solid': solid,
+ 'surface.solidHover': solidHover,
+ 'surface.solidPressed': solidPressed,
+ 'surface.subtle': subtle.subtle,
+ 'surface.subtleHover': subtle.subtleHover,
+ 'surface.subtlePressed': subtle.subtlePressed,
+ text,
+ };
+}
+
+function buildSubtleTrio(
+ mode: ColorMode,
+ canvas: Oklch,
+ source: Oklch,
+): { subtle: Oklch; subtleHover: Oklch; subtlePressed: Oklch } {
+ const isLight = mode === 'light';
+ const chroma = Math.min(0.35 * source.c, 0.06);
+ const at = (delta: number) =>
+ gamutMapOklch({ c: chroma, h: source.h, l: clampUnit(canvas.l + delta) });
+ return isLight
+ ? { subtle: at(-0.045), subtleHover: at(-0.07), subtlePressed: at(-0.1) }
+ : { subtle: at(0.06), subtleHover: at(0.09), subtlePressed: at(0.12) };
+}
+
+function chooseOnSolid(hue: number, solids: Array): Oklch {
+ const nearWhite = gamutMapOklch({ c: 0, h: hue, l: 0.985 });
+ const nearBlack = gamutMapOklch({ c: 0.01, h: hue, l: 0.18 });
+ const whiteMinimum = minimumRatio(nearWhite, solids);
+ const blackMinimum = minimumRatio(nearBlack, solids);
+ if (whiteMinimum >= TEXT_RATIO + RATIO_HEADROOM) return nearWhite;
+ if (blackMinimum >= TEXT_RATIO + RATIO_HEADROOM) return nearBlack;
+ return whiteMinimum >= blackMinimum ? nearWhite : nearBlack;
+}
+
+interface LightnessSolveRequest {
+ backgrounds: Array;
+ chroma: number;
+ hue: number;
+ mode: ColorMode;
+ ratio: number;
+ startLightness: number;
+ window: [number, number];
+}
+
+/**
+ * Finds a lightness inside the window that satisfies every contrast constraint, moving as little
+ * as possible from the start lightness. When even the window's high-contrast edge fails, returns
+ * the edge colour so the validation matrix records the achieved ratio.
+ */
+function solveLightness(request: LightnessSolveRequest): Oklch {
+ const [low, high] = request.window;
+ const makeColor = (l: number) => gamutMapOklch({ c: request.chroma, h: request.hue, l });
+ const target = request.ratio + RATIO_HEADROOM;
+ const passes = (l: number) => minimumRatio(makeColor(l), request.backgrounds) >= target;
+ const start = clamp(request.startLightness, low, high);
+ if (passes(start)) return makeColor(start);
+ // Contrast improves toward darker lightness in light mode and lighter lightness in dark mode.
+ const edge = request.mode === 'light' ? low : high;
+ if (!passes(edge)) return makeColor(edge);
+ let passing = edge;
+ let failing = start;
+ for (let iteration = 0; iteration < 30; iteration++) {
+ const mid = (passing + failing) / 2;
+ if (passes(mid)) {
+ passing = mid;
+ } else {
+ failing = mid;
+ }
+ }
+ return makeColor(passing);
+}
+
+function minimumRatio(foreground: Oklch, backgrounds: Array): number {
+ return Math.min(...backgrounds.map((background) => contrastRatio(foreground, background)));
+}
+
+function resolveSourceColors(
+ mode: ColorMode,
+ colors: ThemeSourceColors,
+): Record<(typeof SOURCE_COLOR_FIELDS)[number], Oklch> {
+ const defaults = defaultSourceColors[mode];
+ const resolve = (value: string) => gamutMapOklch(parseColor(value));
+ return {
+ accent: resolve(colors.accent),
+ danger: resolve(colors.danger ?? defaults.danger),
+ focus: resolve(colors.focus ?? defaults.focus),
+ info: resolve(colors.info ?? defaults.info),
+ neutral: resolve(colors.neutral),
+ success: resolve(colors.success ?? defaults.success),
+ warning: resolve(colors.warning ?? defaults.warning),
+ };
+}
+
+function validateContrast(
+ mode: ColorMode,
+ colors: Record,
+): Array {
+ const failures: Array = [];
+ const colorAt = (path: string): Oklch => {
+ const value = colors[path];
+ if (value === undefined) throw new Error(`buildTheme did not generate "${path}"`);
+ return value;
+ };
+ const check = (foreground: string, background: string, required: number) => {
+ const ratio = contrastRatio(colorAt(foreground), colorAt(background));
+ if (ratio < required) failures.push({ background, foreground, mode, ratio, required });
+ };
+
+ const surfacePaths = ['canvas', 'resting', 'recessed', 'floating', 'overlay'].map(
+ (surface) => `color.surface.${surface}`,
+ );
+ const intentBackgroundPaths = (intent: string) => [
+ 'color.surface.canvas',
+ 'color.surface.resting',
+ 'color.surface.recessed',
+ `color.intent.${intent}.surface.subtle`,
+ `color.intent.${intent}.surface.subtleHover`,
+ `color.intent.${intent}.surface.subtlePressed`,
+ ];
+ const basePaths = ['color.surface.canvas', 'color.surface.resting', 'color.surface.recessed'];
+
+ for (const text of ['color.text.primary', 'color.text.secondary']) {
+ for (const surface of surfacePaths) check(text, surface, TEXT_RATIO);
+ }
+ for (const intent of FULL_KIT_INTENTS) {
+ for (const background of intentBackgroundPaths(intent)) {
+ check(`color.intent.${intent}.text`, background, TEXT_RATIO);
+ }
+ }
+ for (const background of intentBackgroundPaths('accent')) {
+ check('color.intent.accent.textHover', background, TEXT_RATIO);
+ }
+ for (const intent of INTENT_NAMES) {
+ for (const state of ['solid', 'solidHover', 'solidPressed']) {
+ check(
+ `color.intent.${intent}.onSolid`,
+ `color.intent.${intent}.surface.${state}`,
+ TEXT_RATIO,
+ );
+ }
+ }
+ for (const background of basePaths) check('color.border.control', background, UI_RATIO);
+ for (const background of basePaths.slice(0, 2)) check('color.border.focus', background, UI_RATIO);
+ for (const intent of FULL_KIT_INTENTS) {
+ for (const background of basePaths)
+ check(`color.intent.${intent}.border`, background, UI_RATIO);
+ }
+
+ return failures;
+}
+
+function buildDepthValues(material: ThemeMaterialProfile): Record {
+ const shadowColor = gamutMapOklch(parseColor(material.shadowColor));
+ const white: Oklch = { c: 0, h: 0, l: 1 };
+ const blurScale = material.blur === 'sharp' ? 0.75 : 1.5;
+
+ const highlight = shadowLayer({
+ alpha: material.highlightStrength,
+ color: white,
+ inset: true,
+ offsetY: 1,
+ });
+ const ring = shadowLayer({
+ alpha: material.edgeStrength,
+ color: shadowColor,
+ inset: true,
+ spread: 1,
+ });
+ const lowerEdge =
+ material.lowerEdgeDepth > 0
+ ? shadowLayer({
+ alpha: material.edgeStrength,
+ color: shadowColor,
+ inset: true,
+ offsetY: -material.lowerEdgeDepth,
+ })
+ : null;
+ const exterior = (offsetY: number, blur: number, alphaBase: number) =>
+ shadowLayer({
+ alpha: alphaBase * material.shadowStrength,
+ blur: blur * blurScale,
+ color: shadowColor,
+ offsetY,
+ });
+ const innerTopShadow = shadowLayer({
+ alpha: 0.5 * material.shadowStrength,
+ blur: 2 * blurScale,
+ color: shadowColor,
+ inset: true,
+ offsetY: 1,
+ });
+
+ return {
+ 'depth.recessed': composeShadow([innerTopShadow, ring]),
+ 'depth.resting': composeShadow([highlight, ring, lowerEdge, exterior(1, 2, 0.35)]),
+ 'depth.raised': composeShadow([
+ highlight,
+ ring,
+ lowerEdge,
+ exterior(2, 4, 0.5),
+ exterior(1, 2, 0.35),
+ ]),
+ 'depth.floating': composeShadow([ring, exterior(4, 12, 0.7), exterior(2, 4, 0.4)]),
+ 'depth.overlay': composeShadow([ring, exterior(12, 32, 0.9), exterior(4, 12, 0.5)]),
+ };
+}
+
+interface ShadowLayer {
+ alpha: number;
+ blur?: number;
+ color: Oklch;
+ inset?: boolean;
+ offsetY?: number;
+ spread?: number;
+}
+
+function shadowLayer(layer: ShadowLayer): string | null {
+ if (layer.alpha <= 0) return null;
+ const parts = [
+ ...(layer.inset === true ? ['inset'] : []),
+ '0',
+ pixels(layer.offsetY ?? 0),
+ pixels(layer.blur ?? 0),
+ ...(layer.spread !== undefined ? [pixels(layer.spread)] : []),
+ formatOklch(layer.color, Math.min(layer.alpha, 1)),
+ ];
+ return parts.join(' ');
+}
+
+function composeShadow(layers: Array): string {
+ const present = layers.filter((layer) => layer !== null);
+ return present.length === 0 ? 'none' : present.join(', ');
+}
+
+function pixels(value: number): string {
+ if (value === 0) return '0';
+ return `${Number(value.toFixed(2)).toString()}px`;
+}
+
+function validateFoundation(foundation: ThemeFoundation): void {
+ const issues: Array = [];
+ try {
+ themeClassName(foundation.name);
+ } catch (error) {
+ issues.push(`name: ${errorMessage(error)}`);
+ }
+ for (const mode of ['light', 'dark'] as const) {
+ const modeFoundation = foundation[mode];
+ for (const field of SOURCE_COLOR_FIELDS) {
+ const value = modeFoundation.color[field];
+ if (value === undefined) continue;
+ try {
+ parseColor(value);
+ } catch (error) {
+ issues.push(`${mode}.color.${field}: ${errorMessage(error)}`);
+ }
+ }
+ const material = modeFoundation.material;
+ for (const field of ['highlightStrength', 'edgeStrength', 'shadowStrength'] as const) {
+ const value = material[field];
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
+ issues.push(`${mode}.material.${field}: must be a number between 0 and 1`);
+ }
+ }
+ if (!Number.isFinite(material.lowerEdgeDepth) || material.lowerEdgeDepth < 0) {
+ issues.push(`${mode}.material.lowerEdgeDepth: must be a number of pixels, 0 or greater`);
+ }
+ try {
+ parseColor(material.shadowColor);
+ } catch (error) {
+ issues.push(`${mode}.material.shadowColor: ${errorMessage(error)}`);
+ }
+ }
+ const fontFamily = foundation.typography?.fontFamily;
+ if (fontFamily !== undefined && !(fontFamily in themeFontFamilyStacks)) {
+ issues.push(`typography.fontFamily: "${fontFamily}" is not a curated font-family choice`);
+ }
+ const fontWeight = foundation.typography?.fontWeight;
+ if (fontWeight !== undefined) {
+ for (const role of ['body', 'label', 'heading', 'emphasis'] as const) {
+ const value = fontWeight[role];
+ if (value === undefined) continue;
+ if (!Number.isFinite(value) || value < 1 || value > 1000) {
+ issues.push(`typography.fontWeight.${role}: must be a number between 1 and 1000`);
+ }
+ }
+ }
+ if (foundation.radius !== undefined) {
+ for (const role of ['detail', 'control', 'surface', 'overlay'] as const) {
+ const value = foundation.radius[role];
+ if (value === undefined) continue;
+ if (!Number.isFinite(value) || value < 0) {
+ issues.push(`radius.${role}: must be a number of pixels, 0 or greater`);
+ }
+ }
+ }
+ if (issues.length > 0) {
+ throw new Error(`Invalid theme foundation:\n${issues.join('\n')}`);
+ }
+}
+
+function assembleStylesheet(
+ foundation: ThemeFoundation,
+ lightValues: Record,
+ darkValues: Record,
+): string {
+ const selector = `.${themeClassName(foundation.name)}`;
+ const pairs = flattenThemeContract();
+ const isModePath = (path: string) => path.startsWith('color.') || path.startsWith('depth.');
+ const identityPairs = pairs.filter(([path]) => !isModePath(path));
+ const modePairs = pairs.filter(([path]) => isModePath(path));
+
+ const identityDeclarations = declarations(identityPairs, buildIdentityValues(foundation));
+ const lightDeclarations = ['color-scheme: light;', ...declarations(modePairs, lightValues)];
+ const darkDeclarations = ['color-scheme: dark;', ...declarations(modePairs, darkValues)];
+
+ // Without data-color-mode, the base light rule plus the prefers-color-scheme media query follow
+ // 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) =>
+ [
+ `${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. */',
+ `${selector} {`,
+ ...identityDeclarations.map(indent),
+ '}',
+ '',
+ `${selector} {`,
+ ...lightDeclarations.map(indent),
+ '}',
+ '',
+ '@media (prefers-color-scheme: dark) {',
+ indent(`${selector} {`),
+ ...darkDeclarations.map(indent).map(indent),
+ indent('}'),
+ '}',
+ '',
+ attributeSelectors('light'),
+ ...lightDeclarations.map(indent),
+ '}',
+ '',
+ attributeSelectors('dark'),
+ ...darkDeclarations.map(indent),
+ '}',
+ '',
+ ].join('\n');
+}
+
+function buildIdentityValues(foundation: ThemeFoundation): Record {
+ const fontFamily = foundation.typography?.fontFamily ?? defaultFontFamily;
+ const fontWeight = foundation.typography?.fontWeight;
+ const radius = foundation.radius;
+ const values: Record = {
+ 'controlSize.medium': '40px',
+ 'controlSize.small': '32px',
+ ...FONT_VALUES,
+ 'font.family': themeFontFamilyStacks[fontFamily],
+ 'font.weight.body': String(fontWeight?.body ?? defaultFontWeights.body),
+ 'font.weight.emphasis': String(fontWeight?.emphasis ?? defaultFontWeights.emphasis),
+ 'font.weight.heading': String(fontWeight?.heading ?? defaultFontWeights.heading),
+ 'font.weight.label': String(fontWeight?.label ?? defaultFontWeights.label),
+ 'radius.control': `${radius?.control ?? defaultRadius.control}px`,
+ 'radius.detail': `${radius?.detail ?? defaultRadius.detail}px`,
+ 'radius.full': '9999px',
+ 'radius.overlay': `${radius?.overlay ?? defaultRadius.overlay}px`,
+ 'radius.surface': `${radius?.surface ?? defaultRadius.surface}px`,
+ ...ICON_SIZE_VALUES,
+ ...MOTION_VALUES,
+ };
+ for (const [step, value] of Object.entries(SPACE_VALUES)) {
+ values[`space.${step}`] = value;
+ }
+ return values;
+}
+
+function declarations(
+ pairs: Array<[path: string, varName: string]>,
+ values: Record,
+): Array {
+ return pairs.map(([path, varName]) => {
+ const value = values[path];
+ if (value === undefined) throw new Error(`buildTheme did not generate a value for "${path}"`);
+ return `${varName}: ${value};`;
+ });
+}
+
+function indent(line: string): string {
+ return `\t${line}`;
+}
+
+function midpoint([low, high]: [number, number]): number {
+ return (low + high) / 2;
+}
+
+function clamp(value: number, low: number, high: number): number {
+ return Math.min(high, Math.max(low, value));
+}
+
+function clampUnit(value: number): number {
+ return clamp(value, 0, 1);
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/packages/@luke-ui/react/src/theme/color.test.ts b/packages/@luke-ui/react/src/theme/color.test.ts
new file mode 100644
index 00000000..2c2a3141
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/color.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vite-plus/test';
+import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js';
+
+describe('parseColor', () => {
+ it('round-trips a hex colour through OKLCH formatting', () => {
+ const parsed = parseColor('#0160ae');
+ const reparsed = parseColor(formatOklch(parsed));
+ expect(reparsed.l).toBeCloseTo(parsed.l, 3);
+ expect(reparsed.c).toBeCloseTo(parsed.c, 3);
+ expect(reparsed.h).toBeCloseTo(parsed.h, 1);
+ });
+
+ it('parses shorthand hex and oklch percentages', () => {
+ expect(parseColor('#fff').l).toBeCloseTo(1, 5);
+ expect(parseColor('oklch(50% 0.1 200)').l).toBeCloseTo(0.5, 5);
+ expect(parseColor('oklch(0.5 0.1 200)').h).toBeCloseTo(200, 5);
+ });
+
+ it('rejects malformed colours', () => {
+ expect(() => parseColor('#ffff')).toThrow(/cannot parse colour/);
+ expect(() => parseColor('rgb(0, 0, 0)')).toThrow(/cannot parse colour/);
+ expect(() => parseColor('oklch(1.5 0.1 200)')).toThrow(/lightness/);
+ expect(() => parseColor('oklch(0.5 0.1 200 / 0.5)')).toThrow(/cannot parse colour/);
+ });
+});
+
+describe('contrastRatio', () => {
+ it('measures white on black as 21:1', () => {
+ expect(contrastRatio(parseColor('#ffffff'), parseColor('#000000'))).toBeCloseTo(21, 5);
+ });
+
+ it('is symmetric', () => {
+ const blue = parseColor('#0160ae');
+ const white = parseColor('#ffffff');
+ expect(contrastRatio(blue, white)).toBeCloseTo(contrastRatio(white, blue), 10);
+ });
+});
+
+describe('gamutMapOklch', () => {
+ it('reduces chroma until the colour fits in sRGB while preserving lightness and hue', () => {
+ const outOfGamut = { c: 0.4, h: 150, l: 0.6 };
+ const mapped = gamutMapOklch(outOfGamut);
+ expect(mapped.l).toBe(0.6);
+ expect(mapped.h).toBe(150);
+ expect(mapped.c).toBeLessThan(0.4);
+ expect(mapped.c).toBeGreaterThan(0);
+ });
+
+ it('leaves in-gamut colours unchanged and clamps extreme lightness', () => {
+ const inGamut = { c: 0.05, h: 30, l: 0.5 };
+ expect(gamutMapOklch(inGamut)).toEqual(inGamut);
+ expect(gamutMapOklch({ c: 0.2, h: 30, l: 1.2 })).toEqual({ c: 0, h: 30, l: 1 });
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/color.ts b/packages/@luke-ui/react/src/theme/color.ts
new file mode 100644
index 00000000..93488cf6
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/color.ts
@@ -0,0 +1,161 @@
+/**
+ * Colour math for the theme compiler. Self-contained OKLCH/sRGB conversions, CSS-style sRGB gamut
+ * mapping, and WCAG 2.2 contrast so `buildTheme` stays dependency-free and Node-compatible.
+ */
+
+/** A colour in the OKLCH colour space. */
+export interface Oklch {
+ /** Perceptual lightness, 0 to 1. */
+ l: number;
+ /** Chroma, 0 or greater. */
+ c: number;
+ /** Hue angle in degrees, normalised to 0 to 360. */
+ h: number;
+}
+
+/** Parses a `#rgb`, `#rrggbb`, or `oklch( )` colour string into OKLCH. */
+export function parseColor(input: string): Oklch {
+ const trimmed = input.trim();
+ if (HEX_PATTERN.test(trimmed)) {
+ const [r, g, b] = parseHex(trimmed);
+ return linearSrgbToOklch([srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]);
+ }
+ const match = OKLCH_PATTERN.exec(trimmed);
+ if (match !== null) {
+ const [, lightnessText, percentSign, chromaText, hueText] = match;
+ const rawLightness = Number(lightnessText);
+ const l = percentSign === '%' ? rawLightness / 100 : rawLightness;
+ const c = Number(chromaText);
+ const h = Number(hueText);
+ if (l < 0 || l > 1) {
+ throw new Error(`cannot parse colour "${input}"; oklch lightness must be 0-1 or 0%-100%`);
+ }
+ return { c, h: normalizeHue(h), l };
+ }
+ throw new Error(`cannot parse colour "${input}"; expected #rgb, #rrggbb, or oklch( )`);
+}
+
+/**
+ * WCAG 2.2 contrast ratio between two colours, computed on their sRGB-gamut-mapped equivalents.
+ */
+export function contrastRatio(a: Oklch, b: Oklch): number {
+ const luminanceA = relativeLuminance(a);
+ const luminanceB = relativeLuminance(b);
+ const lighter = Math.max(luminanceA, luminanceB);
+ const darker = Math.min(luminanceA, luminanceB);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+/**
+ * Maps a colour into the sRGB gamut the way CSS does: clamp lightness, then binary-search chroma
+ * down while preserving lightness and hue.
+ */
+export function gamutMapOklch(color: Oklch): Oklch {
+ const h = normalizeHue(color.h);
+ if (color.l <= 0) return { c: 0, h, l: 0 };
+ if (color.l >= 1) return { c: 0, h, l: 1 };
+ const candidate = { c: Math.max(color.c, 0), h, l: color.l };
+ if (isInSrgbGamut(candidate)) return candidate;
+ let inGamutChroma = 0;
+ let outOfGamutChroma = candidate.c;
+ for (let iteration = 0; iteration < 32; iteration++) {
+ const mid = (inGamutChroma + outOfGamutChroma) / 2;
+ if (isInSrgbGamut({ c: mid, h, l: color.l })) {
+ inGamutChroma = mid;
+ } else {
+ outOfGamutChroma = mid;
+ }
+ }
+ return { c: inGamutChroma, h, l: color.l };
+}
+
+/** Formats an OKLCH colour as a CSS `oklch()` value, with an optional alpha channel. */
+export function formatOklch(color: Oklch, alpha?: number): string {
+ const l = trimNumber(color.l, 4);
+ const c = trimNumber(color.c, 4);
+ const h = trimNumber(normalizeHue(color.h), 2);
+ if (alpha === undefined || alpha >= 1) return `oklch(${l} ${c} ${h})`;
+ return `oklch(${l} ${c} ${h} / ${trimNumber(alpha, 3)})`;
+}
+
+/** WCAG 2.2 relative luminance of the colour's sRGB-gamut-mapped equivalent. */
+function relativeLuminance(color: Oklch): number {
+ const [unclampedR, unclampedG, unclampedB] = oklchToLinearSrgb(gamutMapOklch(color));
+ const r = clampUnit(unclampedR);
+ const g = clampUnit(unclampedG);
+ const b = clampUnit(unclampedB);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+type SrgbTriple = [number, number, number];
+
+const HEX_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
+const OKLCH_PATTERN = /^oklch\(\s*(\d*\.?\d+)(%?)\s+(\d*\.?\d+)\s+(\d*\.?\d+)(?:deg)?\s*\)$/i;
+const GAMUT_EPSILON = 0.000001;
+
+function parseHex(hex: string): SrgbTriple {
+ const digits = hex.slice(1);
+ const expanded = digits.length === 3 ? digits.replace(/./g, (digit) => digit + digit) : digits;
+ return [
+ Number.parseInt(expanded.slice(0, 2), 16) / 255,
+ Number.parseInt(expanded.slice(2, 4), 16) / 255,
+ Number.parseInt(expanded.slice(4, 6), 16) / 255,
+ ];
+}
+
+function normalizeHue(hue: number): number {
+ if (!Number.isFinite(hue)) return 0;
+ const wrapped = hue % 360;
+ return wrapped < 0 ? wrapped + 360 : wrapped;
+}
+
+function clampUnit(value: number): number {
+ return Math.min(1, Math.max(0, value));
+}
+
+function trimNumber(value: number, digits: number): string {
+ return Number(value.toFixed(digits)).toString();
+}
+
+function srgbToLinear(channel: number): number {
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
+}
+
+function isInSrgbGamut(color: Oklch): boolean {
+ return oklchToLinearSrgb(color).every(
+ (channel) => channel >= -GAMUT_EPSILON && channel <= 1 + GAMUT_EPSILON,
+ );
+}
+
+function oklchToLinearSrgb(color: Oklch): SrgbTriple {
+ const hueRadians = (normalizeHue(color.h) * Math.PI) / 180;
+ const labA = color.c * Math.cos(hueRadians);
+ const labB = color.c * Math.sin(hueRadians);
+ const lCubeRoot = color.l + 0.3963377774 * labA + 0.2158037573 * labB;
+ const mCubeRoot = color.l - 0.1055613458 * labA - 0.0638541728 * labB;
+ const sCubeRoot = color.l - 0.0894841775 * labA - 1.291485548 * labB;
+ const lCone = lCubeRoot ** 3;
+ const mCone = mCubeRoot ** 3;
+ const sCone = sCubeRoot ** 3;
+ return [
+ 4.0767416621 * lCone - 3.3077115913 * mCone + 0.2309699292 * sCone,
+ -1.2684380046 * lCone + 2.6097574011 * mCone - 0.3413193965 * sCone,
+ -0.0041960863 * lCone - 0.7034186147 * mCone + 1.707614701 * sCone,
+ ];
+}
+
+function linearSrgbToOklch(rgb: SrgbTriple): Oklch {
+ const [r, g, b] = rgb;
+ const lCone = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
+ const mCone = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
+ const sCone = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
+ const lCubeRoot = Math.cbrt(lCone);
+ const mCubeRoot = Math.cbrt(mCone);
+ const sCubeRoot = Math.cbrt(sCone);
+ const l = 0.2104542553 * lCubeRoot + 0.793617785 * mCubeRoot - 0.0040720468 * sCubeRoot;
+ const labA = 1.9779984951 * lCubeRoot - 2.428592205 * mCubeRoot + 0.4505937099 * sCubeRoot;
+ const labB = 0.0259040371 * lCubeRoot + 0.7827717662 * mCubeRoot - 0.808675766 * sCubeRoot;
+ const c = Math.hypot(labA, labB);
+ const h = c < 0.000001 ? 0 : normalizeHue((Math.atan2(labB, labA) * 180) / Math.PI);
+ return { c, h, l };
+}
diff --git a/packages/@luke-ui/react/src/theme/contract.css.ts b/packages/@luke-ui/react/src/theme/contract.css.ts
new file mode 100644
index 00000000..32fdbef0
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/contract.css.ts
@@ -0,0 +1,13 @@
+import { createGlobalThemeContract } from '@vanilla-extract/css';
+import { kebabCaseSegment, themeContractTree } from './contract.js';
+
+/**
+ * Typed access to the semantic theme custom properties. Each path resolves to a stable global
+ * `--luke-*` variable reference, for example `vars.color.intent.danger.surface.solidHover` is
+ * `var(--luke-color-intent-danger-surface-solid-hover)`. Emits no CSS; values are supplied by a
+ * theme stylesheet built with `buildTheme`.
+ */
+export const vars = createGlobalThemeContract(
+ themeContractTree,
+ (_value, path) => `luke-${path.map(kebabCaseSegment).join('-')}`,
+);
diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts
new file mode 100644
index 00000000..b3bf8dd2
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/contract.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vite-plus/test';
+import { vars } from './contract.css.js';
+import { flattenThemeContract } from './contract.js';
+
+function countLeaves(node: unknown): number {
+ if (typeof node === 'string') return 1;
+ if (!isRecord(node)) throw new Error('expected a nested theme contract object');
+
+ let count = 0;
+ for (const value of Object.values(node)) count += countLeaves(value);
+
+ return count;
+}
+
+function resolvePath(node: unknown, path: string): unknown {
+ let value = node;
+ for (const segment of path.split('.')) {
+ if (!isRecord(value)) throw new Error(`expected an object before "${segment}" in "${path}"`);
+ value = value[segment];
+ }
+
+ return value;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+describe('theme contract', () => {
+ it('resolves every typed path to its stable global variable', () => {
+ const resolved = flattenThemeContract().map(([path]) => resolvePath(vars, path));
+ expect(resolved).toEqual(flattenThemeContract().map(([, varName]) => `var(${varName})`));
+ });
+
+ it('has no typed paths beyond the flattened contract', () => {
+ expect(countLeaves(vars)).toBe(flattenThemeContract().length);
+ });
+
+ it('exposes composite font steps and the carried-forward icon-size scale', () => {
+ expect(vars.font[100]).toEqual({
+ fontSize: 'var(--luke-font-100-font-size)',
+ letterSpacing: 'var(--luke-font-100-letter-spacing)',
+ lineHeight: 'var(--luke-font-100-line-height)',
+ });
+ expect(vars.font[900].fontSize).toBe('var(--luke-font-900-font-size)');
+ expect(vars.iconSize).toEqual({
+ large: 'var(--luke-icon-size-large)',
+ medium: 'var(--luke-icon-size-medium)',
+ small: 'var(--luke-icon-size-small)',
+ xsmall: 'var(--luke-icon-size-xsmall)',
+ });
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts
new file mode 100644
index 00000000..661dc090
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/contract.ts
@@ -0,0 +1,170 @@
+/**
+ * The semantic token tree shared by the vanilla-extract contract and `buildTheme`, so typed paths
+ * and emitted CSS variable names can never diverge. Leaves are `null`; every path maps to one
+ * stable `--luke-*` custom property.
+ */
+export const themeContractTree = {
+ color: {
+ surface: { canvas: null, resting: null, recessed: null, floating: null, overlay: null },
+ surfaceDisabled: null,
+ text: { primary: null, secondary: null },
+ textDisabled: null,
+ border: { decorative: null, control: null, focus: null },
+ borderDisabled: null,
+ intent: {
+ neutral: {
+ surface: {
+ subtle: null,
+ subtleHover: null,
+ subtlePressed: null,
+ solid: null,
+ solidHover: null,
+ solidPressed: null,
+ },
+ onSolid: null,
+ },
+ accent: {
+ surface: {
+ subtle: null,
+ subtleHover: null,
+ subtlePressed: null,
+ solid: null,
+ solidHover: null,
+ solidPressed: null,
+ },
+ border: null,
+ text: null,
+ textHover: null,
+ onSolid: null,
+ },
+ info: {
+ surface: {
+ subtle: null,
+ subtleHover: null,
+ subtlePressed: null,
+ solid: null,
+ solidHover: null,
+ solidPressed: null,
+ },
+ border: null,
+ text: null,
+ onSolid: null,
+ },
+ success: {
+ surface: {
+ subtle: null,
+ subtleHover: null,
+ subtlePressed: null,
+ solid: null,
+ solidHover: null,
+ solidPressed: null,
+ },
+ border: null,
+ text: null,
+ onSolid: null,
+ },
+ warning: {
+ surface: {
+ subtle: null,
+ subtleHover: null,
+ subtlePressed: null,
+ solid: null,
+ solidHover: null,
+ solidPressed: null,
+ },
+ border: null,
+ text: null,
+ onSolid: null,
+ },
+ danger: {
+ surface: {
+ subtle: null,
+ subtleHover: null,
+ subtlePressed: null,
+ solid: null,
+ solidHover: null,
+ solidPressed: null,
+ },
+ border: null,
+ text: null,
+ onSolid: null,
+ },
+ },
+ },
+ depth: { recessed: null, resting: null, raised: null, floating: null, overlay: null },
+ font: {
+ 100: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 200: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 300: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 400: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 500: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 600: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 700: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 800: { fontSize: null, letterSpacing: null, lineHeight: null },
+ 900: { fontSize: null, letterSpacing: null, lineHeight: null },
+ family: null,
+ weight: { body: null, label: null, heading: null, emphasis: null },
+ },
+ radius: { detail: null, control: null, surface: null, overlay: null, full: null },
+ space: {
+ 100: null,
+ 200: null,
+ 300: null,
+ 400: null,
+ 600: null,
+ 800: null,
+ 1000: null,
+ 1200: null,
+ 1600: null,
+ },
+ controlSize: { small: null, medium: null },
+ iconSize: { xsmall: null, small: null, medium: null, large: null },
+ motion: {
+ duration: { fast: null, medium: null, slow: null, ambient: null },
+ easing: { standard: null, enter: null, exit: null },
+ },
+};
+
+/**
+ * Flattens the semantic token tree into `[path, varName]` pairs, in tree order, for example
+ * `['color.intent.danger.surface.solidHover', '--luke-color-intent-danger-surface-solid-hover']`.
+ */
+export function flattenThemeContract(): Array<[path: string, varName: string]> {
+ const pairs: Array<[string, string]> = [];
+ visitContractNode(themeContractTree, [], pairs);
+ return pairs;
+}
+
+/**
+ * Kebab-cases one camelCase path segment, for example `solidHover` becomes `solid-hover`. Joining
+ * kebab-cased segments with `-` under the `luke-` prefix yields the CSS variable name.
+ */
+export function kebabCaseSegment(segment: string): string {
+ return segment.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
+}
+
+function visitContractNode(
+ node: Record,
+ segments: Array,
+ pairs: Array<[string, string]>,
+): void {
+ for (const [key, value] of Object.entries(node)) {
+ const path = [...segments, key];
+ if (value === null) {
+ pairs.push([path.join('.'), themeVarName(path)]);
+ continue;
+ }
+ if (!isContractNode(value)) {
+ throw new Error(`Theme contract node "${path.join('.')}" must be an object or null`);
+ }
+ visitContractNode(value, path, pairs);
+ }
+}
+
+function isContractNode(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function themeVarName(segments: Array): string {
+ return `--luke-${segments.map(kebabCaseSegment).join('-')}`;
+}
diff --git a/packages/@luke-ui/react/src/theme/foundation.ts b/packages/@luke-ui/react/src/theme/foundation.ts
new file mode 100644
index 00000000..349bb0f4
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/foundation.ts
@@ -0,0 +1,176 @@
+/**
+ * The typed theme-foundation contract accepted by `buildTheme`, plus the curated defaults Luke UI
+ * applies when optional foundation fields are omitted.
+ */
+
+/**
+ * The complete input for one theme. A foundation is the minimal authored surface: Luke UI
+ * generates the full semantic token contract from it.
+ */
+export interface ThemeFoundation {
+ /**
+ * Kebab-case theme identity, for example `'machined-edge'`. The theme's identity class is
+ * `luke-ui-theme-${name}`.
+ */
+ name: string;
+ /** The light colour-mode foundation. */
+ light: ThemeModeFoundation;
+ /**
+ * The dark colour-mode foundation. Dark is authored independently and is never derived from
+ * light.
+ */
+ dark: ThemeModeFoundation;
+ /** Typography choices shared by both modes. */
+ typography?: {
+ /**
+ * Curated Capsize-compatible font-family choice. Applications load non-system font files
+ * themselves.
+ * @default 'inter'
+ */
+ fontFamily?: 'inter' | 'apple-system' | 'dm-sans';
+ /** Font weights for the four theme-controlled weight roles. */
+ fontWeight?: {
+ /**
+ * Weight for body text.
+ * @default 400
+ */
+ body?: number;
+ /**
+ * Weight for control labels and other dense UI text.
+ * @default 500
+ */
+ label?: number;
+ /**
+ * Weight for headings.
+ * @default 600
+ */
+ heading?: number;
+ /**
+ * Weight for emphasised inline text.
+ * @default 700
+ */
+ emphasis?: number;
+ };
+ };
+ /** Corner radii in pixels, shared by both modes. `radius.full` is fixed at 9999px. */
+ radius?: {
+ /**
+ * Radius for checkbox boxes, tags, badges, and compact details.
+ * @default 4
+ */
+ detail?: number;
+ /**
+ * Radius for buttons, fields, selects, and other controls.
+ * @default 8
+ */
+ control?: number;
+ /**
+ * Radius for cards, popovers, and menus.
+ * @default 12
+ */
+ surface?: number;
+ /**
+ * Radius for dialogs, sheets, and large overlays.
+ * @default 16
+ */
+ overlay?: number;
+ };
+}
+
+/** The per-mode authored inputs: source colours and a material profile. */
+export interface ThemeModeFoundation {
+ /** Source colours the semantic colour contract is generated from. */
+ color: ThemeSourceColors;
+ /** The visible physical material properties the depth ladder is generated from. */
+ material: ThemeMaterialProfile;
+}
+
+/**
+ * Source colours for one mode. Values accept `#rgb`, `#rrggbb`, or `oklch( )` with
+ * lightness as a 0-1 number or a percentage, and no alpha channel.
+ */
+export interface ThemeSourceColors {
+ /** Required. Anchors the surface, text, and border ramps; this is the canvas colour. */
+ neutral: string;
+ /** Required. The brand or interaction accent colour. */
+ accent: string;
+ /** Informational intent colour. Defaults to an accessible Luke UI blue for the mode. */
+ info?: string;
+ /** Success intent colour. Defaults to an accessible Luke UI green for the mode. */
+ success?: string;
+ /** Warning intent colour. Defaults to an accessible Luke UI amber for the mode. */
+ warning?: string;
+ /** Danger intent colour. Defaults to an accessible Luke UI red for the mode. */
+ danger?: string;
+ /**
+ * Keyboard-focus ring colour, used verbatim after gamut mapping. Defaults to an accessible
+ * Luke UI blue for the mode.
+ */
+ focus?: string;
+}
+
+/** Visible physical material properties for one mode. */
+export interface ThemeMaterialProfile {
+ /** Alpha of the top inner highlight line, 0 to 1. */
+ highlightStrength: number;
+ /** Alpha of the inset perimeter ring, 0 to 1. */
+ edgeStrength: number;
+ /** Colour of exterior and inset shadows. */
+ shadowColor: string;
+ /** Scale applied to exterior shadow alphas, 0 to 1. */
+ shadowStrength: number;
+ /** Blur character; scales shadow blur radii. */
+ blur: 'sharp' | 'soft';
+ /** Depth of the inset lower edge in pixels. */
+ lowerEdgeDepth: number;
+}
+
+/** Curated Capsize-compatible font stacks for each font-family choice. */
+export const themeFontFamilyStacks = {
+ 'apple-system': "-apple-system, BlinkMacSystemFont, system-ui, 'Segoe UI', sans-serif",
+ 'dm-sans': "'DM Sans', system-ui, sans-serif",
+ inter: "'Inter', system-ui, sans-serif",
+} as const;
+
+/** Default font-family choice applied when `typography.fontFamily` is omitted. */
+export const defaultFontFamily = 'inter';
+
+/** Default weights for the four weight roles. */
+export const defaultFontWeights = { body: 400, emphasis: 700, heading: 600, label: 500 } as const;
+
+/** Default corner radii in pixels. */
+export const defaultRadius = { control: 8, detail: 4, overlay: 16, surface: 12 } as const;
+
+/**
+ * Derives a concentric outer corner from an inner radius and the gap between the two edges.
+ * Pass semantic variable references such as `vars.radius.control` and `vars.space[200]` so the
+ * result follows the active theme.
+ */
+export function deriveConcentricRadius(innerRadius: string, gap: string): string {
+ return `calc(${innerRadius} + ${gap})`;
+}
+
+/**
+ * Mode-aware defaults for the optional source colours: info blue, success green, warning amber,
+ * danger red, and focus blue, chosen to pass the build-time contrast gates on near-white and
+ * near-black canvases.
+ */
+export const defaultSourceColors: Record<
+ 'light' | 'dark',
+ Required>
+> = {
+ dark: {
+ danger: 'oklch(0.72 0.16 25)',
+ focus: 'oklch(0.72 0.13 255)',
+ info: 'oklch(0.72 0.13 255)',
+ success: 'oklch(0.74 0.13 150)',
+ warning: 'oklch(0.78 0.13 80)',
+ },
+ light: {
+ danger: 'oklch(0.52 0.18 27)',
+ focus: 'oklch(0.55 0.17 255)',
+ info: 'oklch(0.52 0.16 255)',
+ success: 'oklch(0.5 0.13 150)',
+ warning: 'oklch(0.72 0.14 75)',
+ },
+};
diff --git a/packages/@luke-ui/react/src/theme/foundations.ts b/packages/@luke-ui/react/src/theme/foundations.ts
new file mode 100644
index 00000000..3ec3d879
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/foundations.ts
@@ -0,0 +1,77 @@
+import type { ThemeFoundation } from './foundation.js';
+
+/**
+ * Foundation for Machined edge, the default bundled theme: teal accent, cool low-chroma tinted
+ * light surfaces, lighter chromatic dark surfaces, and a compact tactile material.
+ */
+export const machinedEdgeFoundation: ThemeFoundation = {
+ dark: {
+ color: {
+ accent: 'oklch(0.75 0.1 200)',
+ neutral: 'oklch(0.25 0.015 210)',
+ },
+ material: {
+ blur: 'sharp',
+ edgeStrength: 0.55,
+ highlightStrength: 0.14,
+ lowerEdgeDepth: 2,
+ shadowColor: 'oklch(0.05 0.01 220)',
+ shadowStrength: 0.55,
+ },
+ },
+ light: {
+ color: {
+ accent: 'oklch(0.52 0.11 200)',
+ neutral: 'oklch(0.975 0.008 210)',
+ },
+ material: {
+ blur: 'sharp',
+ edgeStrength: 0.45,
+ highlightStrength: 0.9,
+ lowerEdgeDepth: 2,
+ shadowColor: 'oklch(0.3 0.03 220)',
+ shadowStrength: 0.22,
+ },
+ },
+ name: 'machined-edge',
+};
+
+/**
+ * Foundation for ELMO, the materially minimal bundled theme. Its light mode approximates the flat,
+ * hairline-bordered Luke UI look with the blue `#0160ae`-family accent; its dark mode is net-new.
+ */
+export const elmoFoundation: ThemeFoundation = {
+ dark: {
+ color: {
+ accent: 'oklch(0.7 0.11 250)',
+ neutral: 'oklch(0.22 0.01 250)',
+ },
+ material: {
+ blur: 'soft',
+ edgeStrength: 0.45,
+ highlightStrength: 0.05,
+ lowerEdgeDepth: 0,
+ shadowColor: 'oklch(0.12 0.01 250)',
+ shadowStrength: 0.35,
+ },
+ },
+ light: {
+ color: {
+ accent: '#0160ae',
+ danger: '#c0262e',
+ info: '#1d39c4',
+ neutral: '#ffffff',
+ success: '#306317',
+ warning: '#d89614',
+ },
+ material: {
+ blur: 'soft',
+ edgeStrength: 0.35,
+ highlightStrength: 0,
+ lowerEdgeDepth: 0,
+ shadowColor: 'oklch(0.2 0.01 250)',
+ shadowStrength: 0.1,
+ },
+ },
+ name: 'elmo',
+};
diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx
index 7567d79f..5a047136 100644
--- a/packages/@luke-ui/react/src/theme/index.tsx
+++ b/packages/@luke-ui/react/src/theme/index.tsx
@@ -3,8 +3,7 @@ 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. */
-/** The CSS custom-property contract (vars) generated from the design tokens. Use to reference token values in vanilla-extract styles. */
-export { themeClass, vars } from '../theme.css.js';
+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(
@@ -12,3 +11,34 @@ export const themeRootClassName = cx(
lukeUiClassNames.themeRoot,
lukeUiClassNames.resetRoot,
);
+
+/**
+ * Typed access to the semantic theme custom properties. Each path resolves to a stable global
+ * `--luke-*` variable reference, for example `vars.color.intent.danger.surface.solidHover`.
+ */
+export { vars } from './contract.css.js';
+
+/**
+ * `buildTheme(foundation)` compiles a typed theme foundation into complete stylesheet text: pure
+ * and Node-compatible, containing the theme identity class plus both colour-mode blocks. It throws
+ * {@link ThemeContrastError} naming the mode and token pair when a generated pair misses WCAG 2.2
+ * AA (4.5:1 for text, 3:1 for non-text UI). Colours are computed and emitted in OKLCH.
+ *
+ * `themeClassName(name)` returns the identity class for a theme name. `ThemeContrastError` carries
+ * every failing mode-and-pair in its `failures` array.
+ */
+export { buildTheme, ThemeContrastError, themeClassName } from './build-theme.js';
+
+/** One WCAG contrast failure recorded on a {@link ThemeContrastError}. */
+export type { ThemeContrastFailure } from './build-theme.js';
+
+/** The typed theme-foundation contract accepted by `buildTheme`. */
+export type {
+ ThemeFoundation,
+ ThemeMaterialProfile,
+ ThemeModeFoundation,
+ ThemeSourceColors,
+} from './foundation.js';
+
+/** Derives a concentric outer corner from an inner radius plus the intervening gap. */
+export { deriveConcentricRadius } from './foundation.js';
diff --git a/packages/@luke-ui/react/src/themes/index.test.ts b/packages/@luke-ui/react/src/themes/index.test.ts
new file mode 100644
index 00000000..3538f62c
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/index.test.ts
@@ -0,0 +1,25 @@
+import { readFile } from 'node:fs/promises';
+import { describe, expect, it } from 'vite-plus/test';
+import packageJson from '../../package.json' with { type: 'json' };
+import { elmoThemeClassName, machinedEdgeThemeClassName } from './index.js';
+
+const themeArtifacts = {
+ elmo: new URL('../../dist/themes/elmo.css', import.meta.url),
+ 'machined-edge': new URL('../../dist/themes/machined-edge.css', import.meta.url),
+} as const;
+
+describe('bundled theme artifacts', () => {
+ it('exports each generated stylesheet as an independent package entrypoint', async () => {
+ const elmoCss = await readFile(themeArtifacts.elmo, 'utf8');
+ const machinedEdgeCss = await readFile(themeArtifacts['machined-edge'], 'utf8');
+
+ expect(packageJson.exports['./themes/elmo.css']).toBe('./dist/themes/elmo.css');
+ expect(packageJson.exports['./themes/machined-edge.css']).toBe(
+ './dist/themes/machined-edge.css',
+ );
+ expect(elmoCss).toContain(`.${elmoThemeClassName} {`);
+ expect(elmoCss).not.toContain(machinedEdgeThemeClassName);
+ expect(machinedEdgeCss).toContain(`.${machinedEdgeThemeClassName} {`);
+ expect(machinedEdgeCss).not.toContain(elmoThemeClassName);
+ });
+});
diff --git a/packages/@luke-ui/react/src/themes/index.ts b/packages/@luke-ui/react/src/themes/index.ts
new file mode 100644
index 00000000..d3c37d3a
--- /dev/null
+++ b/packages/@luke-ui/react/src/themes/index.ts
@@ -0,0 +1,14 @@
+import { themeClassName } from '../theme/build-theme.js';
+import { elmoFoundation, machinedEdgeFoundation } from '../theme/foundations.js';
+
+/**
+ * Identity class for the Machined edge theme, the Luke UI default. Apply it to `` or a
+ * subtree root together with the `@luke-ui/react/themes/machined-edge.css` stylesheet.
+ */
+export const machinedEdgeThemeClassName = themeClassName(machinedEdgeFoundation.name);
+
+/**
+ * Identity class for the ELMO theme. Apply it to `` or a subtree root together with the
+ * `@luke-ui/react/themes/elmo.css` stylesheet.
+ */
+export const elmoThemeClassName = themeClassName(elmoFoundation.name);
diff --git a/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx b/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx
index 7ad75fcd..28ea5bff 100644
--- a/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx
+++ b/packages/@luke-ui/react/src/tokens/tokens-vars.stories.tsx
@@ -1,6 +1,5 @@
import { Heading } from '@luke-ui/react/heading';
import { Text } from '@luke-ui/react/text';
-import { vars } from '@luke-ui/react/theme';
import type { ColorTokenValue } from '@luke-ui/react/tokens';
import {
colorToCssString,
@@ -13,6 +12,7 @@ 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;
diff --git a/packages/@luke-ui/react/vite.config.ts b/packages/@luke-ui/react/vite.config.ts
index fb18fccd..70396589 100644
--- a/packages/@luke-ui/react/vite.config.ts
+++ b/packages/@luke-ui/react/vite.config.ts
@@ -9,8 +9,13 @@ import packageJson from './package.json' with { type: 'json' };
const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url));
const distDir = fileURLToPath(new URL('dist/', import.meta.url));
-const preservedDistFiles = new Set(['spritesheet.svg', 'docs']);
-const assetExports = ['./stylesheet.css', './spritesheet.svg'];
+const preservedDistFiles = new Set(['spritesheet.svg', 'docs', 'themes']);
+const assetExports = [
+ './stylesheet.css',
+ './spritesheet.svg',
+ './themes/machined-edge.css',
+ './themes/elmo.css',
+];
async function cleanDistExceptPreservedFiles() {
let entries: Array;
diff --git a/turbo.json b/turbo.json
index 65fdc576..e3a283b6 100644
--- a/turbo.json
+++ b/turbo.json
@@ -111,7 +111,7 @@
"!**/*.test.tsx",
"!**/*.stories.tsx"
],
- "outputs": [".generated/**", "dist/spritesheet.svg", "src/routeTree.gen.ts"]
+ "outputs": [".generated/**", "dist/spritesheet.svg", "dist/themes/**", "src/routeTree.gen.ts"]
},
"test": {
"dependsOn": ["^build", "generate"],