From 19d134ca4e7859910bf5b23f5d2470dc0c1a68e6 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Mon, 13 Jul 2026 10:48:36 +1000 Subject: [PATCH] Build typed theme compiler and bundled themes (#109) --- apps/docs/src/lib/story-wrapper.tsx | 6 +- docs/STYLING.md | 35 + packages/@luke-ui/react/package.json | 11 +- .../@luke-ui/react/scripts/build-themes.ts | 28 + .../react/src/button/button.stories.tsx | 2 +- .../@luke-ui/react/src/icon/icon.stories.tsx | 2 +- .../react/src/styles/utilities.stories.tsx | 2 +- .../@luke-ui/react/src/text/text.stories.tsx | 2 +- .../react/src/theme/build-theme.test.ts | 333 ++++++++ .../@luke-ui/react/src/theme/build-theme.ts | 795 ++++++++++++++++++ .../@luke-ui/react/src/theme/color.test.ts | 54 ++ packages/@luke-ui/react/src/theme/color.ts | 161 ++++ .../@luke-ui/react/src/theme/contract.css.ts | 13 + .../@luke-ui/react/src/theme/contract.test.ts | 53 ++ packages/@luke-ui/react/src/theme/contract.ts | 170 ++++ .../@luke-ui/react/src/theme/foundation.ts | 176 ++++ .../@luke-ui/react/src/theme/foundations.ts | 77 ++ packages/@luke-ui/react/src/theme/index.tsx | 34 +- .../@luke-ui/react/src/themes/index.test.ts | 25 + packages/@luke-ui/react/src/themes/index.ts | 14 + .../react/src/tokens/tokens-vars.stories.tsx | 2 +- packages/@luke-ui/react/vite.config.ts | 9 +- turbo.json | 2 +- 23 files changed, 1990 insertions(+), 16 deletions(-) create mode 100644 packages/@luke-ui/react/scripts/build-themes.ts create mode 100644 packages/@luke-ui/react/src/theme/build-theme.test.ts create mode 100644 packages/@luke-ui/react/src/theme/build-theme.ts create mode 100644 packages/@luke-ui/react/src/theme/color.test.ts create mode 100644 packages/@luke-ui/react/src/theme/color.ts create mode 100644 packages/@luke-ui/react/src/theme/contract.css.ts create mode 100644 packages/@luke-ui/react/src/theme/contract.test.ts create mode 100644 packages/@luke-ui/react/src/theme/contract.ts create mode 100644 packages/@luke-ui/react/src/theme/foundation.ts create mode 100644 packages/@luke-ui/react/src/theme/foundations.ts create mode 100644 packages/@luke-ui/react/src/themes/index.test.ts create mode 100644 packages/@luke-ui/react/src/themes/index.ts 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"], -- 2.51.2