From 98978f088758e85fa6b3db431c7cc7e4b2c4b85b Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 16 Aug 2026 22:40:05 +1000 Subject: [PATCH] Resolve source colours once, share one lightness grid, and declare mode families (#445) --- docs/STYLING.md | 16 ++- docs/THEME_COLOUR_GENERATION.md | 17 ++- .../react/src/theme/__fixtures__/theme-css.ts | 7 + .../react/src/theme/build-theme.test.ts | 12 +- .../@luke-ui/react/src/theme/build-theme.ts | 67 ++++------ .../@luke-ui/react/src/theme/contract.test.ts | 49 +++++-- packages/@luke-ui/react/src/theme/contract.ts | 75 ++++++++++- .../react/src/theme/contrast-policy.ts | 2 +- .../src/theme/contrast-validation.test.ts | 10 +- .../react/src/theme/contrast-validation.ts | 32 +++-- .../react/src/theme/control-border.ts | 25 ++-- .../react/src/theme/define-theme.test.ts | 54 ++++++-- .../@luke-ui/react/src/theme/define-theme.ts | 120 +++++++++--------- .../react/src/theme/extend-theme.test.ts | 41 +++--- .../@luke-ui/react/src/theme/foundation.ts | 49 +++---- .../src/theme/lightness-candidates.test.ts | 95 ++++++++++++++ .../react/src/theme/lightness-candidates.ts | 46 +++++++ .../@luke-ui/react/src/theme/path-record.ts | 17 +++ packages/@luke-ui/react/src/theme/scale.ts | 7 +- .../react/src/theme/semantic-map.test.ts | 24 +--- .../@luke-ui/react/src/theme/semantic-map.ts | 107 +++++++++------- .../react/src/theme/stylesheet.test.ts | 33 ++--- .../@luke-ui/react/src/theme/stylesheet.ts | 97 ++++++++------ .../@luke-ui/react/src/theme/token-values.ts | 48 ++++--- .../src/theme/validate-foundation.test.ts | 18 +++ .../react/src/theme/validate-foundation.ts | 27 +++- 26 files changed, 736 insertions(+), 359 deletions(-) create mode 100644 packages/@luke-ui/react/src/theme/lightness-candidates.test.ts create mode 100644 packages/@luke-ui/react/src/theme/lightness-candidates.ts create mode 100644 packages/@luke-ui/react/src/theme/path-record.ts diff --git a/docs/STYLING.md b/docs/STYLING.md index 7cf4dcc3..23cbb1b9 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -51,17 +51,23 @@ with no class and no JS required. Neither step injects styles at runtime. `ModalOverlay`, `Modal`, and `Dialog` for the combobox tray. `use-is-mobile-device.ts` reads the device screen width, not the viewport width, to decide when a combobox switches to it. - `styles/`: layout utilities, most exported from `@luke-ui/react/styles`. -- `theme/contract.ts`: the semantic token tree, its `--luke-*` variable naming, and the source-owned - `typeStyles` typography keys. +- `theme/contract.ts`: the semantic token tree, the mode-family declaration, `--luke-*` variable + naming, and the source-owned `typeStyles` typography keys. +- `theme/path-record.ts`: the typed `[path, value]` record constructor value producers use so + `Object.fromEntries` cannot hide a missing contract path. - `theme/contract.css.ts`: the typed `vars` contract, built by walking the semantic token tree directly so it stays source-owned and free of styling-engine types. - `theme/define-theme.ts`: the public `defineTheme(input)` authoring util, its typed `ThemeInput`, - and the curated defaults it applies for omitted materials and scrim. -- `theme/foundation.ts`: the internal typed theme-foundation shape `defineTheme` normalises into and - the curated colour, radius, and typography defaults. + and the one resolution of curated defaults (source colours, materials, radius, scrim) into the + internal foundation. +- `theme/foundation.ts`: the internal typed theme-foundation shape `defineTheme` normalises into, + with generator source colours as OKLCH and CSS-text values such as scrim as strings, plus the + curated colour, radius, and typography defaults. - `theme/color.ts`: OKLCH colour math, sRGB gamut mapping, and WCAG contrast. - `theme/contrast-policy.ts`: the WCAG ratios, solver headroom and search step, and the canonical semantic role list the generator, the compiler's validation matrix, and the semantic map all read. +- `theme/lightness-candidates.ts`: the shared lightness grid the accent pre-conditioner, + solid-anchor search, and control-border solver walk. - `theme/scale.ts`: the private 12-step family generator (`generateFamily`), including the constrained step-9 solid-anchor search and `passesOnSolidGate`, the on-solid accessibility gate. Every semantic role's solid clears 4.5:1 against on-solid text. diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index c2b6b387..bf7d648a 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -8,8 +8,9 @@ solver. Read [STYLING.md](STYLING.md) first for the theme module layout. Per colour mode, `compileTheme` (in `build-theme.ts`): -1. Resolves the source colours and the canvas anchor (`background`, split from `neutral`'s - hue/chroma character — see `define-theme.ts`). +1. Takes the already-resolved source colours and canvas anchor (`background`, split from `neutral`'s + hue/chroma character in `define-theme.ts`). Source colours cross the foundation as OKLCH values; + `defineTheme` applies defaults and parses authoring strings once before `buildTheme` runs. 2. Generates six private 12-step OKLCH families (`neutral`, `accent`, `info`, `success`, `warning`, `danger`) with `scale.ts`'s `generateFamily`. Each family carries steps 1-12 plus a `contrast` on-solid colour. Every role publishes the same background, foreground, on-solid, and border @@ -116,13 +117,15 @@ copy. That gives two guarantees: The pre-conditioner is necessary because its adaptation band is deliberately wider than the generator's tone-faithful window. It can rescue accents, such as a mid-lightness red, that the -generator alone would report as unsatisfiable. +generator alone would report as unsatisfiable. Both searches walk the same lightness candidate grid, +so a lightness the pre-conditioner accepts is one the solid-anchor search will visit too. `contrast-policy.ts` declares the shared thresholds: the 4.5 text ratio, the 3:1 non-text ratio, the -search headroom, and the search step. It also declares `SEMANTIC_ROLES`, the one canonical role list -used by family generation, the semantic map, and the validation matrix. Previously, separate role -lists allowed a role added only to the map to emit an ungated colour, while a role added only to the -compiler threw an internal error. One list makes both sides move together. +search headroom, and the search step. `lightness-candidates.ts` is the one grid those searches walk. +It also declares `SEMANTIC_ROLES`, the one canonical role list used by family generation, the +semantic map, and the validation matrix. Previously, separate role lists allowed a role added only +to the map to emit an ungated colour, while a role added only to the compiler threw an internal +error. One list makes both sides move together. ## `loadingSkeleton` diff --git a/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts b/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts index 3ca2ca73..1399ca38 100644 --- a/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts +++ b/packages/@luke-ui/react/src/theme/__fixtures__/theme-css.ts @@ -5,6 +5,8 @@ * NOT imported by production code. */ +import type { Oklch } from '../color.js'; +import { gamutMapOklch, parseColor } from '../color.js'; import { normalizeTheme } from '../define-theme.js'; import { paperTheme } from '../foundations/paper.js'; import { tactileTheme } from '../foundations/tactile.js'; @@ -14,6 +16,11 @@ import { tactileTheme } from '../foundations/tactile.js'; export const tactileFoundation = normalizeTheme(tactileTheme); export const paperFoundation = normalizeTheme(paperTheme); +/** Parses an authoring colour string the same way `defineTheme` resolves a source colour. */ +export function resolvedColor(input: string): Oklch { + return gamutMapOklch(parseColor(input)); +} + /** * Splits the generated stylesheet into its five rule blocks: identity, base light, media-query * dark, explicit light, and explicit dark. diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index b43f4988..2377a3b4 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vite-plus/test'; import { extractValue, paperFoundation, + resolvedColor, splitBlocks, tactileFoundation, } from './__fixtures__/theme-css.js'; @@ -15,11 +16,11 @@ describe('buildTheme independent modes', () => { const greenPurpleFoundation: ThemeFoundation = { dark: { ...tactileFoundation.dark, - color: { ...tactileFoundation.dark.color, accent: 'oklch(0.75 0.12 300)' }, + color: { ...tactileFoundation.dark.color, accent: resolvedColor('oklch(0.75 0.12 300)') }, }, light: { ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, accent: 'oklch(0.5 0.13 150)' }, + color: { ...tactileFoundation.light.color, accent: resolvedColor('oklch(0.5 0.13 150)') }, }, name: 'green-purple', }; @@ -59,7 +60,7 @@ describe('buildTheme generation failures', () => { ...tactileFoundation, light: { ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, warning: 'oklch(0.62 0.19 27)' }, + color: { ...tactileFoundation.light.color, warning: resolvedColor('oklch(0.62 0.19 27)') }, }, name: 'bad-warning', }); @@ -88,7 +89,10 @@ describe('buildTheme generation failures', () => { ...tactileFoundation, light: { ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, accent: 'oklch(0.62 0.19 27)' }, + color: { + ...tactileFoundation.light.color, + accent: resolvedColor('oklch(0.62 0.19 27)'), + }, }, name: 'bad-accent', }); diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 181c5513..31a55ef2 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -1,5 +1,4 @@ -import type { Oklch } from './color.js'; -import { gamutMapOklch, parseColor } from './color.js'; +import type { ModePath } from './contract.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; import type { ThemeContrastFailure } from './contrast-validation.js'; import { validateContrast } from './contrast-validation.js'; @@ -13,13 +12,7 @@ import type { import type { GeneratedSurfaces } from './elevation.js'; import { generateSurfaces } from './elevation.js'; import type { ThemeInheritance } from './extend-theme.js'; -import type { - SOURCE_COLOR_FIELDS, - ThemeFoundation, - ThemeModeFoundation, - ThemeSourceColors, -} from './foundation.js'; -import { defaultSourceColors } from './foundation.js'; +import type { ThemeFoundation, ThemeModeFoundation } from './foundation.js'; import type { FamilyRole, ScaleFamily } from './scale.js'; import { generateFamilyWithDiagnostics, ScaleGenerationError } from './scale.js'; import type { SemanticColorValues } from './semantic-map.js'; @@ -30,10 +23,10 @@ import { validateFoundation } from './validate-foundation.js'; /** * Compiles a theme foundation into a complete static stylesheet plus its {@link ThemeDiagnostics}. * - * Per mode: resolves the source colours and canvas anchor, generates the six private scale families - * (neutral / accent / info / success / warning / danger), derives the mode-aware elevation surfaces, - * applies the one default semantic mapping onto the colour contract, and runs the full WCAG 2.2 - * validation matrix — which stays authoritative for text and on-solid pairs. + * Per mode: takes the already-resolved source colours and canvas anchor, generates the six private + * scale families (neutral / accent / info / success / warning / danger), derives the mode-aware + * elevation surfaces, applies the one default semantic mapping onto the colour contract, and runs + * the full WCAG 2.2 validation matrix — which stays authoritative for text and on-solid pairs. * * Pure and Node-compatible: no DOM and deterministic output. Throws {@link ThemeGenerationError} * when a role that must guarantee on-solid contrast cannot reach an accessible solid (an inaccessible @@ -150,19 +143,23 @@ type ColorMode = 'light' | 'dark'; interface ModeValues { diagnostics: ThemeModeDiagnostics; failures: Array; - values: Record; + values: Record; } function buildModeValues(mode: ColorMode, modeFoundation: ThemeModeFoundation): ModeValues { const { colorValues, familyDiagnostics, surfaces } = buildModeColors(mode, modeFoundation); const { checks, failures } = validateContrast(mode, colorValues); - const values: Record = { ...colorValues }; - for (const [name, value] of Object.entries(modeFoundation.depth)) { - values[`depth.${name}`] = value; - } - for (const [name, value] of Object.entries(modeFoundation.actionControlFinish)) { - values[`actionControlFinish.${name}`] = value; - } + const values: Record = { + ...colorValues, + 'actionControlFinish.raised': modeFoundation.actionControlFinish.raised, + 'actionControlFinish.recessed': modeFoundation.actionControlFinish.recessed, + 'actionControlFinish.resting': modeFoundation.actionControlFinish.resting, + 'depth.floating': modeFoundation.depth.floating, + 'depth.overlay': modeFoundation.depth.overlay, + 'depth.raised': modeFoundation.depth.raised, + 'depth.recessed': modeFoundation.depth.recessed, + 'depth.resting': modeFoundation.depth.resting, + }; return { diagnostics: { contrastChecks: checks, families: familyDiagnostics, mode, surfaces }, failures, @@ -177,13 +174,13 @@ interface ModeColors { } /** - * Runs the v2 colour pipeline for one mode: resolve source colours and the canvas anchor, generate - * the six scale families, derive the elevation surfaces, and apply the semantic map. Rethrows a - * scale-level {@link ScaleGenerationError} as a {@link ThemeGenerationError} carrying the families it - * had already resolved. + * Runs the v2 colour pipeline for one mode: take the resolved source colours and canvas anchor, + * generate the six scale families, derive the elevation surfaces, and apply the semantic map. + * Rethrows a scale-level {@link ScaleGenerationError} as a {@link ThemeGenerationError} carrying + * the families it had already resolved. */ function buildModeColors(mode: ColorMode, modeFoundation: ThemeModeFoundation): ModeColors { - const source = resolveSourceColors(mode, modeFoundation.color); + const source = modeFoundation.color; // The canvas anchor drives every family's ramp and the elevation surfaces alike, so a family's // subtle steps always ramp away from the same background the surfaces sit on. const canvasAnchor = source.background; @@ -230,21 +227,3 @@ function buildModeColors(mode: ColorMode, modeFoundation: ThemeModeFoundation): }); return { colorValues, familyDiagnostics, surfaces }; } - -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), - background: resolve(colors.background), - 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), - }; -} diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts index 8b7f9c83..17c57217 100644 --- a/packages/@luke-ui/react/src/theme/contract.test.ts +++ b/packages/@luke-ui/react/src/theme/contract.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vite-plus/test'; import { vars } from './contract.css.js'; -import { flattenThemeContract, spaceScale, themeContractTree, typeStyles } from './contract.js'; +import { + flattenThemeContract, + modeFamilies, + partitionContractPairs, + spaceScale, + themeContractTree, + typeStyles, +} from './contract.js'; +import type { IdentityPath, ModePath } from './contract.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; import { FONT_METRIC_SCALE } from './font-metric-scale.js'; @@ -43,19 +51,22 @@ describe('theme contract', () => { // spelled out here rather than re-derived through `themeVarName` (which would only restate the // kebab-casing the contract already applied). `on-solid` is the one name a naive reading gets // wrong. Comparing the whole set, not a sample, also catches a seventh role or a stray leaf. + const leaf = (path: ModePath, varName: string): [ModePath, string] => [path, varName]; const expected = SEMANTIC_ROLES.flatMap((role) => [ - [`color.border.${role}`, `--luke-color-border-${role}`], - ...['subtle', 'solid'].flatMap((prominence) => { - return ['rest', 'hover', 'pressed'].map((state) => [ - `color.background.${role}.${prominence}.${state}`, - `--luke-color-background-${role}-${prominence}-${state}`, - ]); + leaf(`color.border.${role}`, `--luke-color-border-${role}`), + ...(['subtle', 'solid'] as const).flatMap((prominence) => { + return (['rest', 'hover', 'pressed'] as const).map((state) => { + return leaf( + `color.background.${role}.${prominence}.${state}`, + `--luke-color-background-${role}-${prominence}-${state}`, + ); + }); }), - [`color.foreground.${role}.rest`, `--luke-color-foreground-${role}-rest`], - [`color.foreground.${role}.hover`, `--luke-color-foreground-${role}-hover`], - [`color.foreground.${role}.onSolid`, `--luke-color-foreground-${role}-on-solid`], + leaf(`color.foreground.${role}.rest`, `--luke-color-foreground-${role}-rest`), + leaf(`color.foreground.${role}.hover`, `--luke-color-foreground-${role}-hover`), + leaf(`color.foreground.${role}.onSolid`, `--luke-color-foreground-${role}-on-solid`), ]); - const rolePaths = new Set(expected.map(([path]) => path)); + const rolePaths = new Set(expected.map(([path]) => path)); const emitted = flattenThemeContract().filter(([path]) => { return ( path.startsWith('color.background.') || @@ -71,6 +82,22 @@ describe('theme contract', () => { expect([...emitted].sort(byPath)).toEqual([...expected].sort(byPath)); }); + it('partitions identity and mode paths from the declared mode families', () => { + expect(modeFamilies).toEqual(['actionControlFinish', 'color', 'depth']); + const pairs = flattenThemeContract(); + const { identityPairs, modePairs } = partitionContractPairs(pairs); + expect(identityPairs.length + modePairs.length).toBe(pairs.length); + + const modePaths: Array = modePairs.map(([path]) => path); + const identityPaths: Array = identityPairs.map(([path]) => path); + for (const path of modePaths) { + expect(modeFamilies).toContain(path.split('.')[0]); + } + for (const path of identityPaths) { + expect(modeFamilies).not.toContain(path.split('.')[0]); + } + }); + it('keeps typeStyles as the single source of truth for the font contract keys', () => { const fontStepKeys = Object.keys(themeContractTree.font).filter((key) => { return key !== 'family' && key !== 'weight'; diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index e39b46ce..54b8a96f 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -278,12 +278,79 @@ export const themeContractTree = { }, }; +/** + * Top-level contract families that vary by colour mode. Identity families are the rest of + * {@link themeContractTree}. This one list drives the mode-family type, {@link ModePath}, + * {@link IdentityPath}, and stylesheet partitioning. + */ +export const modeFamilies = [ + 'actionControlFinish', + 'color', + 'depth', +] as const satisfies ReadonlyArray; + +/** A top-level contract family that varies by colour mode. */ +type ModeFamily = (typeof modeFamilies)[number]; + +/** A top-level contract family that belongs to the theme identity, not a colour mode. */ +type IdentityFamily = Exclude; + +type JoinPath = Prefix extends '' + ? Key + : `${Prefix}.${Key}`; + +type ContractPaths = { + [K in keyof T & string]: T[K] extends null + ? JoinPath + : ContractPaths>; +}[keyof T & string]; + +/** Dotted path of a mode-owned contract leaf, for example `'color.text.primary'`. */ +export type ModePath = { + [Family in ModeFamily]: ContractPaths<(typeof themeContractTree)[Family], Family>; +}[ModeFamily]; + +/** Dotted path of an identity-owned contract leaf, for example `'radius.control'`. */ +export type IdentityPath = { + [Family in IdentityFamily]: ContractPaths<(typeof themeContractTree)[Family], Family>; +}[IdentityFamily]; + +/** Dotted path of any contract leaf. */ +export type ContractPath = IdentityPath | ModePath; + +const modeFamilySet: ReadonlySet = new Set(modeFamilies); + +function isModeFamily(family: string): family is ModeFamily { + return modeFamilySet.has(family); +} + +function familyOf(path: string): string { + const separator = path.indexOf('.'); + return separator === -1 ? path : path.slice(0, separator); +} + +/** + * Splits flattened contract pairs into identity and mode groups using {@link modeFamilies}. + */ +export function partitionContractPairs(pairs: ReadonlyArray): { + identityPairs: Array<[IdentityPath, string]>; + modePairs: Array<[ModePath, string]>; +} { + const identityPairs: Array<[IdentityPath, string]> = []; + const modePairs: Array<[ModePath, string]> = []; + for (const [path, varName] of pairs) { + if (isModeFamily(familyOf(path))) modePairs.push([path as ModePath, varName]); + else identityPairs.push([path as IdentityPath, varName]); + } + return { identityPairs, modePairs }; +} + /** * Flattens the semantic token tree into `[path, varName]` pairs, in tree order, for example * `['color.background.danger.solid.hover', '--luke-color-background-danger-solid-hover']`. */ -export function flattenThemeContract(): Array<[path: string, varName: string]> { - const pairs: Array<[string, string]> = []; +export function flattenThemeContract(): Array<[path: ContractPath, varName: string]> { + const pairs: Array<[ContractPath, string]> = []; visitContractNode(themeContractTree, [], pairs); return pairs; } @@ -299,12 +366,12 @@ function kebabCaseSegment(segment: string): string { function visitContractNode( node: Record, segments: Array, - pairs: Array<[string, string]>, + pairs: Array<[ContractPath, string]>, ): void { for (const [key, value] of Object.entries(node)) { const path = [...segments, key]; if (value === null) { - pairs.push([path.join('.'), themeVarName(path)]); + pairs.push([path.join('.') as ContractPath, themeVarName(path)]); continue; } if (!isContractNode(value)) { diff --git a/packages/@luke-ui/react/src/theme/contrast-policy.ts b/packages/@luke-ui/react/src/theme/contrast-policy.ts index 048f1681..486edd22 100644 --- a/packages/@luke-ui/react/src/theme/contrast-policy.ts +++ b/packages/@luke-ui/react/src/theme/contrast-policy.ts @@ -3,7 +3,7 @@ * solves for or validates contrast reads its thresholds from here rather than restating them: the * scale generator's on-solid gate (`scale.ts`), the accent pre-conditioner (`define-theme.ts`), and * the build-time validation matrix (`contrast-validation.ts`) and `border.control` solver - * (`control-border.ts`). + * (`control-border.ts`). The lightness grid those searches walk lives in `lightness-candidates.ts`. * * The one semantic role list lives here too, because it decides both which contract leaves the * semantic map emits (`semantic-map.ts`) and which pairs the validation matrix gates diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts index a640a248..122fa53f 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { paperFoundation, tactileFoundation } from './__fixtures__/theme-css.js'; +import { paperFoundation, resolvedColor, tactileFoundation } from './__fixtures__/theme-css.js'; import { buildTheme, compileTheme, ThemeContrastError } from './build-theme.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; import type { ThemeFoundation } from './foundation.js'; @@ -23,7 +23,7 @@ describe('buildTheme contrast failures', () => { ...tactileFoundation, light: { ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, focus: '#c5d9ff' }, + color: { ...tactileFoundation.light.color, focus: resolvedColor('#c5d9ff') }, }, name: 'bad-focus', }); @@ -50,7 +50,7 @@ describe('buildTheme contrast failures', () => { ...tactileFoundation, dark: { ...tactileFoundation.dark, - color: { ...tactileFoundation.dark.color, background: 'oklch(0.9 0 0)' }, + color: { ...tactileFoundation.dark.color, background: resolvedColor('oklch(0.9 0 0)') }, }, name: 'bad-dark-canvas', }); @@ -71,11 +71,11 @@ describe('buildTheme contrast failures', () => { ...tactileFoundation, dark: { ...tactileFoundation.dark, - color: { ...tactileFoundation.dark.color, background: 'oklch(0.9 0 0)' }, + color: { ...tactileFoundation.dark.color, background: resolvedColor('oklch(0.9 0 0)') }, }, light: { ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, focus: '#c5d9ff' }, + color: { ...tactileFoundation.light.color, focus: resolvedColor('#c5d9ff') }, }, name: 'bad-both', }); diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.ts b/packages/@luke-ui/react/src/theme/contrast-validation.ts index bbd32912..e2f43062 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.ts @@ -31,6 +31,8 @@ interface ValidationResult { failures: Array; } +type ColorPath = keyof SemanticColorValues; + /** * Runs the full semantic validation matrix over the emitted (rounded) colour values: 92 hard checks * and 12 advisory checks per mode. Every pair is recorded as a {@link ContrastCheck}, and the hard @@ -59,12 +61,12 @@ export function validateContrast( ): ValidationResult { const failures: Array = []; const checks: Array = []; - const colorAt = (path: string): Oklch => { + const colorAt = (path: ColorPath): Oklch => { const value = colorValues[path]; if (value === undefined) throw new Error(`buildTheme did not generate "${path}"`); return parseColor(value); }; - const check = (foreground: string, background: string, required: number, hard: boolean) => { + const check = (foreground: ColorPath, background: ColorPath, required: number, hard: boolean) => { const ratio = contrastRatio(colorAt(foreground), colorAt(background)); const passes = ratio >= required; // `hard` is recorded on the check itself, so tooling reads the compiler's own decision rather @@ -74,28 +76,34 @@ export function validateContrast( }; // v2 validates only against surfaces consumers can reference (the hidden `resting` rung is gone). - const surfacePaths = ['canvas', 'recessed', 'floating', 'overlay'].map( - (surface) => `color.surface.${surface}`, - ); - const basePaths = ['color.surface.canvas', 'color.surface.recessed']; + const surfacePaths = [ + 'color.surface.canvas', + 'color.surface.recessed', + 'color.surface.floating', + 'color.surface.overlay', + ] as const satisfies ReadonlyArray; + const basePaths = [ + 'color.surface.canvas', + 'color.surface.recessed', + ] as const satisfies ReadonlyArray; // Functional text vs every mapped elevation surface: 8 checks. - for (const text of ['color.text.primary', 'color.text.secondary']) { + for (const text of ['color.text.primary', 'color.text.secondary'] as const) { for (const surface of surfacePaths) check(text, surface, TEXT_RATIO, true); } // Per role: both foregrounds vs the base surfaces and that role's own subtle ramp (60 checks), and // the on-solid foreground vs its solid ramp (18). The scale generator already guarantees on-solid; // this revalidates it on the emitted, rounded values. for (const role of SEMANTIC_ROLES) { - const subtleBackgrounds = ['rest', 'hover', 'pressed'].map( - (state) => `color.background.${role}.subtle.${state}`, - ); - for (const state of ['rest', 'hover']) { + const subtleBackgrounds = (['rest', 'hover', 'pressed'] as const).map((state) => { + return `color.background.${role}.subtle.${state}` as const; + }); + for (const state of ['rest', 'hover'] as const) { for (const background of [...basePaths, ...subtleBackgrounds]) { check(`color.foreground.${role}.${state}`, background, TEXT_RATIO, true); } } - for (const state of ['rest', 'hover', 'pressed']) { + for (const state of ['rest', 'hover', 'pressed'] as const) { check( `color.foreground.${role}.onSolid`, `color.background.${role}.solid.${state}`, diff --git a/packages/@luke-ui/react/src/theme/control-border.ts b/packages/@luke-ui/react/src/theme/control-border.ts index 2abb95cc..d9d4198f 100644 --- a/packages/@luke-ui/react/src/theme/control-border.ts +++ b/packages/@luke-ui/react/src/theme/control-border.ts @@ -6,8 +6,9 @@ */ import type { Oklch } from './color.js'; -import { clampUnit, contrastRatio, gamutMapOklch } from './color.js'; -import { CONTRAST_SEARCH_STEP, RATIO_HEADROOM, UI_RATIO } from './contrast-policy.js'; +import { contrastRatio, gamutMapOklch } from './color.js'; +import { RATIO_HEADROOM, UI_RATIO } from './contrast-policy.js'; +import { lightnessCandidates } from './lightness-candidates.js'; import type { ScaleFamily } from './scale.js'; type ColorMode = 'light' | 'dark'; @@ -37,21 +38,27 @@ interface SolveControlBorderRequest { export function solveControlBorder(params: SolveControlBorderRequest): Oklch { const { neutral, canvas, recessed, mode } = params; const seed = neutral[7]; - const direction = mode === 'light' ? -1 : 1; const target = UI_RATIO + RATIO_HEADROOM; const worstRatio = (candidate: Oklch) => { return Math.min(contrastRatio(candidate, canvas), contrastRatio(candidate, recessed)); }; - let lightness = seed.l; - for (;;) { - const clamped = clampUnit(lightness); + let resolved: Oklch | undefined; + for (const lightness of lightnessCandidates(seed.l, mode === 'light' ? 0 : 1)) { const candidate = gamutMapOklch({ - l: clamped, + l: lightness, c: seed.c, h: seed.h, }); - if (worstRatio(candidate) >= target || clamped === 0 || clamped === 1) return candidate; - lightness = clamped + direction * CONTRAST_SEARCH_STEP; + resolved = candidate; + if (worstRatio(candidate) >= target) return candidate; } + return ( + resolved ?? + gamutMapOklch({ + l: seed.l, + c: seed.c, + h: seed.h, + }) + ); } diff --git a/packages/@luke-ui/react/src/theme/define-theme.test.ts b/packages/@luke-ui/react/src/theme/define-theme.test.ts index 78d98e7b..919a6f47 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vite-plus/test'; import { gamutMapOklch, parseColor } from './color.js'; import { flattenThemeContract } from './contract.js'; -import { defaultDepth, defineTheme, normalizeTheme } from './define-theme.js'; +import { defaultDepth, defaultScrim, defineTheme, normalizeTheme } from './define-theme.js'; +import { defaultSourceColors } from './foundation.js'; import { paperTheme } from './foundations/paper.js'; import { tactileTheme } from './foundations/tactile.js'; import { generateFamilyWithDiagnostics } from './scale.js'; @@ -115,9 +116,9 @@ describe('defineTheme accent pre-conditioning shares the generator gate', () => const resolved = accents.flatMap((accent) => { return (['light', 'dark'] as const).map((mode) => { const foundation = normalizeTheme({ color: { accent }, name: 'accent-gate' }); - const source = parseColor(foundation[mode].color.accent); + const source = foundation[mode].color.accent; const { diagnostics } = generateFamilyWithDiagnostics({ - background: parseColor(foundation[mode].color.background), + background: foundation[mode].color.background, mode, role: 'accent', source, @@ -241,8 +242,12 @@ describe('normalizeTheme resolves the source-tier `background` split from `neutr }, name: 'background-explicit', }); - expect(foundation.light.color.background).toBe('oklch(0.99 0.002 210)'); - expect(foundation.dark.color.background).toBe('oklch(0.18 0.01 210)'); + expect(foundation.light.color.background).toEqual( + gamutMapOklch(parseColor('oklch(0.99 0.002 210)')), + ); + expect(foundation.dark.color.background).toEqual( + gamutMapOklch(parseColor('oklch(0.18 0.01 210)')), + ); // Different from the neutral canvas anchor: the split actually took effect. expect(foundation.light.color.background).not.toBe(foundation.light.color.neutral); expect(foundation.dark.color.background).not.toBe(foundation.dark.color.neutral); @@ -258,10 +263,12 @@ describe('normalizeTheme resolves the source-tier `background` split from `neutr name: 'background-single-mode', }); // Light keeps the authored value verbatim. - expect(foundation.light.color.background).toBe('oklch(0.4 0.05 30)'); + expect(foundation.light.color.background).toEqual( + gamutMapOklch(parseColor('oklch(0.4 0.05 30)')), + ); // Dark is adapted from light: same hue and chroma, but the dark canvas lightness (~0.22), not - // the light source's lightness (0.4) and not a raw copy of the light string. - const adaptedDark = parseColor(foundation.dark.color.background); + // the light source's lightness (0.4) and not a raw copy of the light colour. + const adaptedDark = foundation.dark.color.background; expect(adaptedDark.h).toBeCloseTo(30, 0); expect(adaptedDark.c).toBeCloseTo(0.05, 2); expect(adaptedDark.l).toBeCloseTo(0.22, 2); @@ -275,8 +282,8 @@ describe('normalizeTheme resolves the source-tier `background` split from `neutr color: { accent: '#3b82f6', background: 'oklch(0.5 0.03 140)' }, name: 'background-single-value', }); - const light = parseColor(foundation.light.color.background); - const dark = parseColor(foundation.dark.color.background); + const light = foundation.light.color.background; + const dark = foundation.dark.color.background; expect(light.h).toBeCloseTo(140, 0); expect(dark.h).toBeCloseTo(140, 0); expect(light.l).toBeCloseTo(0.985, 2); @@ -284,6 +291,33 @@ describe('normalizeTheme resolves the source-tier `background` split from `neutr }); }); +describe('normalizeTheme resolves source colours once onto the foundation', () => { + it('carries generator colours as Oklch and scrim as CSS text, with defaults applied', () => { + const foundation = normalizeTheme({ + color: { accent: '#3b82f6' }, + name: 'resolved-once', + }); + const light = foundation.light.color; + expect(Number.isFinite(light.accent.l)).toBe(true); + expect(Number.isFinite(light.accent.c)).toBe(true); + expect(Number.isFinite(light.accent.h)).toBe(true); + expect(light.info).toEqual(gamutMapOklch(parseColor(defaultSourceColors.light.info))); + expect(light.success).toEqual(gamutMapOklch(parseColor(defaultSourceColors.light.success))); + expect(light.warning).toEqual(gamutMapOklch(parseColor(defaultSourceColors.light.warning))); + expect(light.danger).toEqual(gamutMapOklch(parseColor(defaultSourceColors.light.danger))); + expect(light.focus).toEqual(gamutMapOklch(parseColor(defaultSourceColors.light.focus))); + expect(light.scrim).toBe(defaultScrim.light); + expect(typeof light.scrim).toBe('string'); + }); + + it('keeps the adapted accent hue without a format-parse round trip', () => { + const source = gamutMapOklch(parseColor('#3b82f6')); + const foundation = normalizeTheme({ color: { accent: '#3b82f6' }, name: 'precision' }); + expect(foundation.light.color.accent.h).toBe(source.h); + expect(foundation.dark.color.accent.h).toBe(source.h); + }); +}); + /** Extracts the set of unique `--luke-*` variable names declared in a stylesheet. */ function emittedVarNames(css: string): Set { return new Set([...css.matchAll(/(--luke-[a-z0-9-]+):/g)].map((match) => match[1] ?? '')); diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index 2a07972a..33bf738c 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -2,16 +2,18 @@ * The `defineTheme` authoring util: the sole public theme-authoring surface. It normalises a small, * curated-default {@link ThemeInput} into the internal per-mode {@link ThemeFoundation} and hands it * to the internal {@link buildTheme} value pipeline. It owns the single-value accent/neutral - * adaptation and the resolution of curated defaults (materials, radius, scrim). + * adaptation and the one resolution of curated defaults (source colours, materials, radius, scrim) + * into {@link Oklch} values the foundation carries. */ import { buildTheme, ThemeContrastError } from './build-theme.js'; import type { Oklch } from './color.js'; -import { clampUnit, formatOklch, gamutMapOklch, parseColor } from './color.js'; -import { CONTRAST_SEARCH_STEP, TEXT_RATIO } from './contrast-policy.js'; +import { gamutMapOklch, parseColor } from './color.js'; +import { TEXT_RATIO } from './contrast-policy.js'; import { resolveThemeInput } from './extend-theme.js'; import type { ThemeFoundation, ThemeModeFoundation, ThemeSourceColors } from './foundation.js'; import { defaultSourceColors } from './foundation.js'; +import { lightnessCandidates } from './lightness-candidates.js'; import { passesOnSolidGate } from './scale.js'; /** @@ -224,11 +226,11 @@ const RADIUS_STEPS = { control: 2, detail: 1, overlay: 4, surface: 3 } as const; /** * Compiles a curated {@link ThemeInput} into a complete static stylesheet. Resolves any `extends` * chain into one merged input first, then normalises it into the per-mode {@link ThemeFoundation} - * shape — adapting single-value accents and neutrals per mode, generating the radius scale, and - * merging materials over curated defaults — then delegates to {@link buildTheme}, whose build-time - * contrast validation stays authoritative. Throws when a single-value accent has no accessible - * lightness in a mode, and (via `buildTheme`) throws {@link ThemeContrastError} when any resolved - * pair misses WCAG 2.2 AA. + * shape — adapting single-value accents and neutrals per mode, resolving source colours to + * {@link Oklch} once, generating the radius scale, and merging materials over curated defaults — + * then delegates to {@link buildTheme}, whose build-time contrast validation stays authoritative. + * Throws when a single-value accent has no accessible lightness in a mode, and (via `buildTheme`) + * throws {@link ThemeContrastError} when any resolved pair misses WCAG 2.2 AA. */ export function defineTheme(input: ThemeInput | ExtendingThemeInput): string { const resolved = resolveThemeInput(input); @@ -243,13 +245,12 @@ export function defineTheme(input: ThemeInput | ExtendingThemeInput): string { } /** - * Resolves a {@link ThemeInput} into the internal per-mode {@link ThemeFoundation} `buildTheme` - * consumes, resolving any `extends` chain into one merged input first. Exported for internal callers - * and tests only; it is not part of the public package entry, where `defineTheme` is the sole - * authoring surface. + * Resolves a merged {@link ThemeInput} into the internal per-mode {@link ThemeFoundation} + * `buildTheme` consumes. The `extends` chain must already be folded; call {@link resolveThemeInput} + * once and pass `resolved.input`. Exported for internal callers and tests only; it is not part of + * the public package entry, where `defineTheme` is the sole authoring surface. */ -export function normalizeTheme(themeInput: ThemeInput | ExtendingThemeInput): ThemeFoundation { - const input = resolveThemeInput(themeInput).input; +export function normalizeTheme(input: ThemeInput): ThemeFoundation { const foundation: ThemeFoundation = { dark: buildModeFoundation(input, 'dark'), light: buildModeFoundation(input, 'light'), @@ -284,7 +285,7 @@ function omitUndefined>(record: T): Partial ) as Partial; } -/** Resolves every source-colour role for one mode into the strings `buildTheme` accepts. */ +/** Resolves every source-colour role for one mode into the {@link Oklch} values `buildTheme` accepts. */ function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { const { color } = input; const defaults = defaultSourceColors[mode]; @@ -301,17 +302,12 @@ function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { // Emitted verbatim; a single string applies to both modes, an omitted side falls back to the // curated mode-aware default. scrim: resolveVerbatimRole(color.scrim, mode, defaultScrim[mode]), + danger: resolveSourceRole(color.danger, mode, defaults.danger), + focus: resolveSourceRole(color.focus, mode, defaults.focus), + info: resolveSourceRole(color.info, mode, defaults.info), + success: resolveSourceRole(color.success, mode, defaults.success), + warning: resolveSourceRole(color.warning, mode, defaults.warning), }; - const feedback = { - danger: color.danger, - focus: color.focus, - info: color.info, - success: color.success, - warning: color.warning, - } as const; - for (const role of ['info', 'success', 'warning', 'danger', 'focus'] as const) { - colors[role] = resolveVerbatimRole(feedback[role], mode, defaults[role]); - } return colors; } @@ -323,75 +319,84 @@ function resolveAdaptedRole( input: ColorInput, mode: ColorMode, adapt: (source: Oklch, mode: ColorMode, raw: string) => Oklch, -): string { - if (typeof input === 'string') - return formatOklch(adapt(gamutMapOklch(parseColor(input)), mode, input)); +): Oklch { + if (typeof input === 'string') return adapt(gamutMapOklch(parseColor(input)), mode, input); const side = sideOf(input, mode); - if (side !== undefined) return side; + if (side !== undefined) return gamutMapOklch(parseColor(side)); // The other side is present (a partial `{ light }` or `{ dark }`); generate this side from it. const other = sideOf(input, mode === 'light' ? 'dark' : 'light'); if (other === undefined) { throw new Error(`Theme "accent": provide a colour for at least one of light or dark.`); } - return formatOklch(adapt(gamutMapOklch(parseColor(other)), mode, other)); + return adapt(gamutMapOklch(parseColor(other)), mode, other); } /** Resolves the neutral role, honouring `neutral` then `neutralStyle` (default `'neutral'`). */ -function resolveNeutral(color: ThemeInput['color'], mode: ColorMode): string { +function resolveNeutral(color: ThemeInput['color'], mode: ColorMode): Oklch { if (color.neutral !== undefined) { const side = sideOf(color.neutral, mode); - if (side !== undefined) return side; + if (side !== undefined) return gamutMapOklch(parseColor(side)); if (typeof color.neutral === 'string') { - return adaptNeutralString(gamutMapOklch(parseColor(color.neutral)), mode); + return adaptNeutral(gamutMapOklch(parseColor(color.neutral)), mode); } const other = sideOf(color.neutral, mode === 'light' ? 'dark' : 'light'); - if (other !== undefined) return adaptNeutralString(gamutMapOklch(parseColor(other)), mode); + if (other !== undefined) return adaptNeutral(gamutMapOklch(parseColor(other)), mode); } const style = NEUTRAL_STYLE[color.neutralStyle ?? 'neutral']; - return formatOklch( - gamutMapOklch({ - l: NEUTRAL_LIGHTNESS[mode], - c: style.chroma, - h: style.hue, - }), - ); + return gamutMapOklch({ + l: NEUTRAL_LIGHTNESS[mode], + c: style.chroma, + h: style.hue, + }); } /** Adapts a single neutral source to the mode canvas lightness, preserving hue and chroma. */ -function adaptNeutralString(source: Oklch, mode: ColorMode): string { - return formatOklch( - gamutMapOklch({ - l: NEUTRAL_LIGHTNESS[mode], - c: source.c, - h: source.h, - }), - ); +function adaptNeutral(source: Oklch, mode: ColorMode): Oklch { + return gamutMapOklch({ + l: NEUTRAL_LIGHTNESS[mode], + c: source.c, + h: source.h, + }); } /** * Resolves an optional canvas-anchor role (currently `background`) for one mode: an explicit side * wins, a single string or the opposite side is adapted to the mode canvas lightness (mirroring - * `adaptNeutralString`), and an entirely omitted input falls back to `fallback` verbatim — the + * `adaptNeutral`), and an entirely omitted input falls back to `fallback` verbatim — the * resolved neutral canvas anchor, not a second independent adaptation. */ function resolveOptionalModeColour( input: ColorInput | undefined, mode: ColorMode, - fallback: string, -): string { + fallback: Oklch, +): Oklch { if (input === undefined) return fallback; const side = sideOf(input, mode); - if (side !== undefined) return side; + if (side !== undefined) return gamutMapOklch(parseColor(side)); if (typeof input === 'string') { - return adaptNeutralString(gamutMapOklch(parseColor(input)), mode); + return adaptNeutral(gamutMapOklch(parseColor(input)), mode); } const other = sideOf(input, mode === 'light' ? 'dark' : 'light'); - if (other !== undefined) return adaptNeutralString(gamutMapOklch(parseColor(other)), mode); + if (other !== undefined) return adaptNeutral(gamutMapOklch(parseColor(other)), mode); return fallback; } /** - * Resolves a feedback/focus role used verbatim. A single string is used for both modes; an explicit + * Resolves a feedback/focus role used as a generator colour. A single string is used for both modes; + * an explicit side is used directly; an omitted side or role falls back to the curated mode default. + */ +function resolveSourceRole( + input: ColorInput | undefined, + mode: ColorMode, + fallback: string, +): Oklch { + if (input === undefined) return gamutMapOklch(parseColor(fallback)); + if (typeof input === 'string') return gamutMapOklch(parseColor(input)); + return gamutMapOklch(parseColor(sideOf(input, mode) ?? fallback)); +} + +/** + * Resolves a role used verbatim as CSS text. A single string is used for both modes; an explicit * side is used directly; an omitted side or role falls back to the curated mode default. */ function resolveVerbatimRole( @@ -436,8 +441,7 @@ function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { let best: number | null = null; let bestDistance = Number.POSITIVE_INFINITY; - for (let l = low; l <= high + 1e-9; l += CONTRAST_SEARCH_STEP) { - const candidate = clampUnit(l); + for (const candidate of lightnessCandidates(low, high)) { if (!passes(candidate)) continue; const distance = Math.abs(candidate - target); if (distance < bestDistance) { diff --git a/packages/@luke-ui/react/src/theme/extend-theme.test.ts b/packages/@luke-ui/react/src/theme/extend-theme.test.ts index 8ea0629a..aaed0ae7 100644 --- a/packages/@luke-ui/react/src/theme/extend-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/extend-theme.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from 'vite-plus/test'; import { splitBlocks } from './__fixtures__/theme-css.js'; import { ThemeContrastError } from './build-theme.js'; -import { parseColor } from './color.js'; +import { gamutMapOklch, parseColor } from './color.js'; import type { ExtendingThemeInput, ThemeInput } from './define-theme.js'; import { defaultDepth, defineTheme, normalizeTheme } from './define-theme.js'; +import { resolveThemeInput } from './extend-theme.js'; import { tactileTheme } from './foundations/tactile.js'; +function foundationOf(input: ThemeInput | ExtendingThemeInput) { + return normalizeTheme(resolveThemeInput(input).input); +} + /** Every `--luke-*` declaration in a stylesheet, keyed by rule block and variable name. */ function declarations(css: string): Array<[string, string]> { return Object.entries(splitBlocks(css)).flatMap(([blockName, block]) => { @@ -69,17 +74,21 @@ describe('theme inheritance', () => { color: { accent: { dark: 'oklch(0.75 0.1 200)', light: 'oklch(0.52 0.11 200)' } }, name: 'pair-accent', }; - const foundation = normalizeTheme({ + const foundation = foundationOf({ color: { accent: 'oklch(0.6 0.15 30)' }, extends: base, name: 'string-accent', }); - expect(foundation.light.color.accent).not.toBe('oklch(0.52 0.11 200)'); - expect(foundation.dark.color.accent).not.toBe('oklch(0.75 0.1 200)'); + expect(foundation.light.color.accent).not.toEqual( + gamutMapOklch(parseColor('oklch(0.52 0.11 200)')), + ); + expect(foundation.dark.color.accent).not.toEqual( + gamutMapOklch(parseColor('oklch(0.75 0.1 200)')), + ); - const light = parseColor(foundation.light.color.accent); - const dark = parseColor(foundation.dark.color.accent); + const light = foundation.light.color.accent; + const dark = foundation.dark.color.accent; expect(light.h).toBeCloseTo(30, 0); expect(dark.h).toBeCloseTo(30, 0); expect(light.l).toBeCloseTo(0.5, 1); @@ -94,8 +103,8 @@ describe('theme inheritance', () => { }, name: 'pair-neutral', }; - const baseFoundation = normalizeTheme(base); - const foundation = normalizeTheme({ + const baseFoundation = foundationOf(base); + const foundation = foundationOf({ color: { neutralStyle: 'warm' }, extends: base, name: 'warm-neutral', @@ -103,8 +112,8 @@ describe('theme inheritance', () => { // The extending theme's `neutralStyle` decides the canvas, so the inherited raw `neutral` went // with it rather than shadowing the style. - expect(foundation.light.color.neutral).not.toBe(baseFoundation.light.color.neutral); - expect(parseColor(foundation.light.color.neutral).h).toBeCloseTo(70, 0); + expect(foundation.light.color.neutral).not.toEqual(baseFoundation.light.color.neutral); + expect(foundation.light.color.neutral.h).toBeCloseTo(70, 0); }); it('inherits materials per rung and radius per step', () => { @@ -117,7 +126,7 @@ describe('theme inheritance', () => { name: 'material-base', radius: { base: 4, control: 10 }, }; - const foundation = normalizeTheme({ + const foundation = foundationOf({ depth: { light: { overlay: 'own-light-overlay', resting: undefined } }, extends: base, name: 'material-child', @@ -142,7 +151,7 @@ describe('theme inheritance', () => { name: 'type-base', typography: { fontFamily: 'dm-sans', fontWeight: { body: 300, heading: 800 } }, }; - const foundation = normalizeTheme({ + const foundation = foundationOf({ extends: base, name: 'type-child', typography: { fontFamily: 'apple-system', fontWeight: { body: 400 } }, @@ -167,11 +176,13 @@ describe('theme inheritance', () => { extends: root, name: 'middle', }; - const foundation = normalizeTheme({ extends: middle, name: 'leaf' }); + const foundation = foundationOf({ extends: middle, name: 'leaf' }); // A role only the innermost base sets reaches the outermost theme through the middle theme. - expect(foundation.light.color.success).toBe('oklch(0.45 0.12 150)'); - expect(foundation.dark.color.success).toBe('oklch(0.8 0.12 150)'); + expect(foundation.light.color.success).toEqual( + gamutMapOklch(parseColor('oklch(0.45 0.12 150)')), + ); + expect(foundation.dark.color.success).toEqual(gamutMapOklch(parseColor('oklch(0.8 0.12 150)'))); const first: ThemeInput = { color: { accent: '#3b82f6' }, name: 'first' }; const second: ExtendingThemeInput = { extends: first, name: 'second' }; diff --git a/packages/@luke-ui/react/src/theme/foundation.ts b/packages/@luke-ui/react/src/theme/foundation.ts index 70da79df..8d9284b9 100644 --- a/packages/@luke-ui/react/src/theme/foundation.ts +++ b/packages/@luke-ui/react/src/theme/foundation.ts @@ -1,8 +1,11 @@ /** * The typed theme-foundation contract accepted by `buildTheme`, plus the curated defaults Luke UI - * applies when optional foundation fields are omitted. + * applies when optional foundation fields are omitted. Source colours that participate in generation + * cross this boundary as {@link Oklch}; CSS-text values such as scrim stay strings. */ +import type { Oklch } from './color.js'; + /** * The complete input for one theme. A foundation is the minimal authored surface: Luke UI * generates the full semantic token contract from it. @@ -98,12 +101,14 @@ interface ActionControlFinishFoundation { } /** - * 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. + * Source colours for one mode, already resolved into {@link Oklch} for every role that participates + * in generation. `defineTheme` applies curated defaults and parses authoring strings once; + * `buildTheme` consumes these values as colours, not CSS text. `scrim` is the exception: it is + * emitted verbatim and may carry an alpha channel. */ export interface ThemeSourceColors { /** Required. The brand or interaction accent colour. */ - accent: string; + accent: Oklch; /** * Required. The canvas anchor, resolved per mode from an explicit `background`, an adapted * opposite-mode `background`, or (when `background` is entirely omitted) a copy of the resolved @@ -111,38 +116,34 @@ export interface ThemeSourceColors { * family's ramp and the elevation surfaces (including `color.surface.canvas`) are generated * against `background`, not `neutral`. */ - background: string; - /** Source colour for the `danger` role. 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; - /** Source colour for the `info` role. Defaults to an accessible Luke UI blue for the mode. */ - info?: string; + background: Oklch; + /** Source colour for the `danger` role. */ + danger: Oklch; + /** Keyboard-focus ring colour, used verbatim after gamut mapping. */ + focus: Oklch; + /** Source colour for the `info` role. */ + info: Oklch; /** * Required. Anchors the surface, text, and border ramps — the family's hue/chroma character. * `background` is the actual canvas colour; the two coincide unless `background` is authored * separately. */ - neutral: string; + neutral: Oklch; /** * Modal-backdrop dimming colour, emitted verbatim (may carry an alpha channel). Required * internally: `defineTheme` always resolves it, from the author's value or a mode-aware default. */ scrim: string; - /** Source colour for the `success` role. Defaults to an accessible Luke UI green for the mode. */ - success?: string; - /** Source colour for the `warning` role. Defaults to an accessible Luke UI amber for the mode. */ - warning?: string; + /** Source colour for the `success` role. */ + success: Oklch; + /** Source colour for the `warning` role. */ + warning: Oklch; } /** - * The per-mode source colour fields the compiler parses and resolves into OKLCH. `background` is the - * resolved canvas anchor and `focus` is the authored keyboard-focus ring, both colours the - * foundation must carry. `scrim` is deliberately absent, because it is emitted verbatim rather than - * parsed. + * The per-mode source colour fields that participate in generation as {@link Oklch}. `background` is + * the resolved canvas anchor and `focus` is the authored keyboard-focus ring. `scrim` is + * deliberately absent, because it is emitted as CSS text rather than parsed. */ export const SOURCE_COLOR_FIELDS = [ 'neutral', @@ -221,7 +222,7 @@ export function deriveNestedRadius> + Record<'info' | 'success' | 'warning' | 'danger' | 'focus', string> > = { dark: { danger: 'oklch(0.72 0.16 25)', diff --git a/packages/@luke-ui/react/src/theme/lightness-candidates.test.ts b/packages/@luke-ui/react/src/theme/lightness-candidates.test.ts new file mode 100644 index 00000000..6a30a8ea --- /dev/null +++ b/packages/@luke-ui/react/src/theme/lightness-candidates.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { CONTRAST_SEARCH_STEP } from './contrast-policy.js'; +import { lightnessCandidates } from './lightness-candidates.js'; + +/** First grid value that satisfies `predicate`, or `undefined` when none does. */ +function firstMatch( + from: number, + to: number, + predicate: (lightness: number) => boolean, +): number | undefined { + for (const lightness of lightnessCandidates(from, to)) { + if (predicate(lightness)) return lightness; + } + return undefined; +} + +describe('lightnessCandidates', () => { + it('yields a single clamped value when from equals to', () => { + expect([...lightnessCandidates(0.5, 0.5)]).toEqual([0.5]); + expect([...lightnessCandidates(1.2, 1.2)]).toEqual([1]); + }); + + it('yields nothing for an empty non-finite band', () => { + expect([...lightnessCandidates(Number.NaN, 0.5)]).toEqual([]); + expect([...lightnessCandidates(0.5, Number.NaN)]).toEqual([]); + expect(firstMatch(Number.NaN, 0.5, () => true)).toBeUndefined(); + }); + + it('walks the fixed step from from toward to without appending an off-grid endpoint', () => { + const band = [...lightnessCandidates(0.4, 0.62)]; + expect(band[0]).toBe(0.4); + expect(band.at(-1)).toBeCloseTo(0.62, 12); + expect(band).toHaveLength(Math.round((0.62 - 0.4) / CONTRAST_SEARCH_STEP) + 1); + + const offGridEnd = [...lightnessCandidates(0.4, 0.621)]; + expect(offGridEnd.at(-1)).toBeCloseTo(0.62, 12); + expect(offGridEnd).not.toContain(0.621); + }); + + it('walks a band that starts at 0 instead of stopping on the first clamp', () => { + const values = [...lightnessCandidates(0, 0.01)]; + expect(values[0]).toBe(0); + expect(values.length).toBeGreaterThan(1); + expect(values.at(-1)).toBeCloseTo(0.01, 12); + }); + + it('walks downward and stops when clamping saturates', () => { + const values = [...lightnessCandidates(0.005, 0)]; + expect(values[0]).toBe(0.005); + expect(values.at(-1)).toBe(0); + expect(values.every((lightness) => lightness >= 0 && lightness <= 1)).toBe(true); + }); + + it('does not append 0 when the destination is off-grid just above 0', () => { + const values = [...lightnessCandidates(0.005, 0.001)]; + expect(values[0]).toBe(0.005); + expect(values.at(-1)).toBeCloseTo(0.0025, 12); + expect(values).not.toContain(0); + expect(values).not.toContain(0.001); + }); + + it('does not append 1 when the destination is off-grid just below 1', () => { + const values = [...lightnessCandidates(0.995, 0.999)]; + expect(values[0]).toBe(0.995); + expect(values.at(-1)).toBeCloseTo(0.9975, 12); + expect(values).not.toContain(1); + expect(values).not.toContain(0.999); + }); + + it('yields 0 when the destination is exactly 0', () => { + expect([...lightnessCandidates(0.005, 0)].at(-1)).toBe(0); + expect([...lightnessCandidates(0.006, 0)].at(-1)).toBe(0); + }); + + it('yields 1 when the destination is exactly 1', () => { + expect([...lightnessCandidates(0.995, 1)].at(-1)).toBe(1); + expect([...lightnessCandidates(0.996, 1)].at(-1)).toBe(1); + }); + + it('walks downward toward a lower destination rather than treating it as empty', () => { + const values = [...lightnessCandidates(0.5, 0.49)]; + expect(values[0]).toBe(0.5); + expect(values.at(-1)).toBeCloseTo(0.49, 12); + expect(values.length).toBeGreaterThan(1); + }); + + it('lets a caller select with a synthetic predicate', () => { + expect(firstMatch(0.4, 0.62, (lightness) => lightness >= 0.5)).toBeCloseTo(0.5, 12); + }); + + it('returns no match when the predicate nothing satisfies', () => { + expect(firstMatch(0.4, 0.62, () => false)).toBeUndefined(); + expect(firstMatch(0.5, 0.5, () => false)).toBeUndefined(); + }); +}); diff --git a/packages/@luke-ui/react/src/theme/lightness-candidates.ts b/packages/@luke-ui/react/src/theme/lightness-candidates.ts new file mode 100644 index 00000000..fdcf7525 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/lightness-candidates.ts @@ -0,0 +1,46 @@ +/** + * The shared OKLCH lightness candidate grid every contrast search walks. It owns stepping, direction, + * clamping, and stopping, and yields lightness numbers. Callers keep their own selection rule, + * contrast predicate, colour construction, and diagnostics. + */ + +import { clampUnit } from './color.js'; +import { CONTRAST_SEARCH_STEP } from './contrast-policy.js'; + +/** Inclusive comparison slack so a grid point that lands on `to` is not dropped to float error. */ +const ENDPOINT_EPSILON = 1e-9; + +/** + * Yields the fixed lightness grid from `from` toward `to`. Each value is clamped to `[0, 1]`. The + * start is always included when the range is finite. The destination is included only when it lands + * on the grid; an off-grid `to` is not appended. When `from === to`, yields that single clamped + * value. Stops when the next step would pass `to`, or when clamping saturates at 0 or 1. A saturated + * 0 or 1 is yielded only when that boundary is the destination. + */ +export function* lightnessCandidates(from: number, to: number): Generator { + if (!Number.isFinite(from) || !Number.isFinite(to)) return; + if (from === to) { + yield clampUnit(from); + return; + } + const direction = to > from ? 1 : -1; + const step = direction * CONTRAST_SEARCH_STEP; + let index = 0; + for (;;) { + const raw = from + index * step; + const clamped = clampUnit(raw); + yield clamped; + const nextRaw = raw + step; + const nextClamped = clampUnit(nextRaw); + if (nextClamped === clamped) return; + if (hasPassedEnd(nextRaw, to, direction)) { + if (nextClamped === to && (to === 0 || to === 1)) yield nextClamped; + return; + } + index += 1; + } +} + +function hasPassedEnd(raw: number, to: number, direction: number): boolean { + return direction > 0 ? raw > to + ENDPOINT_EPSILON : raw < to - ENDPOINT_EPSILON; +} diff --git a/packages/@luke-ui/react/src/theme/path-record.ts b/packages/@luke-ui/react/src/theme/path-record.ts new file mode 100644 index 00000000..452670ec --- /dev/null +++ b/packages/@luke-ui/react/src/theme/path-record.ts @@ -0,0 +1,17 @@ +/** + * Typed `[path, value]` records for contract value producers. `Object.fromEntries` widens keys to + * `string`; this module is the one place that restores `{ [P in K]: string }` from the pair keys. + * Callers annotate the destination so a missing contract path is a type error, not a runtime gap. + */ + +/** A `[path, value]` pair that keeps the path's literal type. */ +export function pathEntry(path: K, value: string): readonly [K, string] { + return [path, value]; +} + +/** Builds a complete path-keyed record from {@link pathEntry} pairs. */ +export function pathRecord( + entries: Iterable, +): { [P in K]: string } { + return Object.fromEntries(entries) as { [P in K]: string }; +} diff --git a/packages/@luke-ui/react/src/theme/scale.ts b/packages/@luke-ui/react/src/theme/scale.ts index f8f2cb04..c837573f 100644 --- a/packages/@luke-ui/react/src/theme/scale.ts +++ b/packages/@luke-ui/react/src/theme/scale.ts @@ -13,8 +13,9 @@ import type { Oklch } from './color.js'; import { clampUnit, contrastRatio, gamutMapOklch } from './color.js'; import type { SEMANTIC_ROLES } from './contrast-policy.js'; -import { CONTRAST_SEARCH_STEP, RATIO_HEADROOM, TEXT_RATIO } from './contrast-policy.js'; +import { RATIO_HEADROOM, TEXT_RATIO } from './contrast-policy.js'; import type { FamilyDiagnostics, GamutReduction, SolidAnchorDiagnostics } from './diagnostics.js'; +import { lightnessCandidates } from './lightness-candidates.js'; /** A scale family's semantic role. Derived from the canonical role list, never restated. */ export type FamilyRole = (typeof SEMANTIC_ROLES)[number]; @@ -401,9 +402,7 @@ function resolveSolidAnchor(request: GenerateFamilyRequest): ResolvedAnchor { let bestDistance = Number.POSITIVE_INFINITY; let bestAttemptLightness = preferred; let bestAttemptRatio = gateRatio(preferred); - const stepCount = Math.round((high - low) / CONTRAST_SEARCH_STEP); - for (let index = 0; index <= stepCount; index++) { - const lightness = low + index * CONTRAST_SEARCH_STEP; + for (const lightness of lightnessCandidates(low, high)) { const ratio = gateRatio(lightness); if (ratio > bestAttemptRatio) { bestAttemptRatio = ratio; diff --git a/packages/@luke-ui/react/src/theme/semantic-map.test.ts b/packages/@luke-ui/react/src/theme/semantic-map.test.ts index dd35c7b2..0c9ffcb1 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.test.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.test.ts @@ -26,6 +26,8 @@ const CONTROL_BORDER: Record = { light: parseColor('oklch(0.38 0.006 250)'), }; +const FOCUS = parseColor('oklch(0.6 0.2 260)'); + // A representative source per role and mode. `info`/`success`/`warning`/`danger` reuse Luke UI's // curated defaults, which are chosen to clear the on-solid gate on near-white/near-black canvases; // `accent` reuses the vibrant blue scale.test.ts exercises without adaptation in either mode; @@ -71,13 +73,12 @@ describe('mapSemanticColors', () => { const families = buildFamilies(mode, background); const surfaces = generateSurfaces({ background, mode }); const scrim = 'oklch(0 0 0 / 0.45)'; - const focus = parseColor('oklch(0.6 0.2 260)'); const controlBorder = CONTROL_BORDER[mode]; const result = mapSemanticColors({ controlBorder, families, - focus, + focus: FOCUS, scrim, surfaces, }); @@ -97,7 +98,7 @@ describe('mapSemanticColors', () => { expect(result['color.text.disabled']).toBe(formatOklch(families.neutral[8])); expect(result['color.border.decorative']).toBe(formatOklch(families.neutral[6])); expect(result['color.border.control']).toBe(formatOklch(controlBorder)); - expect(result['color.border.focus']).toBe(formatOklch(focus)); + expect(result['color.border.focus']).toBe(formatOklch(FOCUS)); // The shared contract: identical steps for all six roles, keyed to the role's own family. for (const role of SEMANTIC_ROLES) { @@ -115,21 +116,6 @@ describe('mapSemanticColors', () => { expect(result[`color.border.${role}`]).toBe(formatOklch(family[7])); } }); - - it(`defaults border.focus to the accent family's step 8 when focus is omitted (${mode})`, () => { - const background = BACKGROUND[mode]; - const families = buildFamilies(mode, background); - const surfaces = generateSurfaces({ background, mode }); - - const result = mapSemanticColors({ - controlBorder: CONTROL_BORDER[mode], - families, - scrim: 'oklch(0 0 0 / 0.45)', - surfaces, - }); - - expect(result['color.border.focus']).toBe(formatOklch(families.accent[8])); - }); } }); @@ -148,6 +134,7 @@ describe('mapSemanticColors', () => { const result = mapSemanticColors({ controlBorder: CONTROL_BORDER[mode], families, + focus: FOCUS, scrim: 'oklch(0 0 0 / 0.45)', surfaces, }); @@ -170,6 +157,7 @@ describe('mapSemanticColors', () => { const result = mapSemanticColors({ controlBorder: CONTROL_BORDER.light, families, + focus: FOCUS, scrim, surfaces, }); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts index 7a4498be..e5dcebcd 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.ts @@ -12,13 +12,27 @@ import type { Oklch } from './color.js'; import { formatOklch } from './color.js'; +import type { ModePath } from './contract.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; import type { GeneratedSurfaces } from './elevation.js'; +import { pathEntry, pathRecord } from './path-record.js'; import type { FamilyRole, ScaleFamily } from './scale.js'; -/** Every generated colour contract leaf's CSS value, keyed by its dotted path (for example - * `'color.text.primary'`), plus the passed-through `'color.scrim'`. */ -export type SemanticColorValues = Record; +/** Every generated colour contract leaf's CSS value, keyed by its dotted path. */ +export type SemanticColorValues = { + [Path in Extract]: string; +}; + +type RoleColorPath = Extract< + keyof SemanticColorValues, + | `color.background.${FamilyRole}.${string}` + | `color.foreground.${FamilyRole}.${string}` + | `color.border.${FamilyRole}` +>; + +type FunctionalColorValues = { + [Path in Exclude]: string; +}; /** The inputs to {@link mapSemanticColors}. */ interface MapSemanticColorsRequest { @@ -30,8 +44,8 @@ interface MapSemanticColorsRequest { controlBorder: Oklch; /** The generated scale family for each role, already mode-resolved. */ families: Record; - /** The authored keyboard-focus source colour. Defaults to the accent family's step 8. */ - focus?: Oklch; + /** The resolved keyboard-focus source colour. */ + focus: Oklch; /** The authored scrim value, passed through verbatim (it may carry an alpha channel). */ scrim: string; /** The generated elevation surface set, already mode-resolved. */ @@ -41,47 +55,54 @@ interface MapSemanticColorsRequest { /** * Resolves every colour contract leaf onto the private families and surfaces, per the locked * semantic mapping table. `families` and `surfaces` are already mode-resolved. `scrim` passes through - * verbatim. `focus` defaults to the accent family's step 8 when the theme author omits it. + * verbatim. `focus` is the resolved keyboard-focus source colour. */ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticColorValues { + return { + ...mapFunctionalColors(request), + ...mapRoleColorValues(request.families), + }; +} + +function mapFunctionalColors(request: MapSemanticColorsRequest): FunctionalColorValues { const { families, surfaces, scrim, focus, controlBorder } = request; const neutral = families.neutral; - const values: Record = {}; - - // Surfaces: canvas IS the background, so it is aliased here rather than recomputed. - values['color.surface.canvas'] = formatOklch(surfaces.canvas); - values['color.surface.recessed'] = formatOklch(surfaces.recessed); - values['color.surface.floating'] = formatOklch(surfaces.floating); - values['color.surface.overlay'] = formatOklch(surfaces.overlay); - values['color.scrim'] = scrim; - values['color.loadingSkeleton'] = formatOklch(neutral[8]); - - // Functional text and borders: neutral only, and distinct from the six shared roles that share the - // `border` branch with them. - values['color.text.primary'] = formatOklch(neutral[12]); - values['color.text.secondary'] = formatOklch(neutral[11]); - values['color.text.disabled'] = formatOklch(neutral[8]); - values['color.border.decorative'] = formatOklch(neutral[6]); - values['color.border.control'] = formatOklch(controlBorder); - values['color.border.focus'] = formatOklch(focus ?? families.accent[8]); - - // The shared semantic contract: every role maps onto its own family through the same steps, so a - // role's meaning never decides which visual slots it can fill. - for (const role of SEMANTIC_ROLES) { - const family = families[role]; - values[`color.background.${role}.subtle.rest`] = formatOklch(family[3]); - values[`color.background.${role}.subtle.hover`] = formatOklch(family[4]); - values[`color.background.${role}.subtle.pressed`] = formatOklch(family[5]); - values[`color.background.${role}.solid.rest`] = formatOklch(family[9]); - values[`color.background.${role}.solid.hover`] = formatOklch(family[10]); - // Deliberate dup: the pressed solid is carried by depth.recessed / actionControlFinish.recessed / - // transform, not a third solid colour. - values[`color.background.${role}.solid.pressed`] = formatOklch(family[10]); - values[`color.foreground.${role}.rest`] = formatOklch(family[11]); - values[`color.foreground.${role}.hover`] = formatOklch(family[12]); - values[`color.foreground.${role}.onSolid`] = formatOklch(family.contrast); - values[`color.border.${role}`] = formatOklch(family[7]); - } + return { + 'color.surface.canvas': formatOklch(surfaces.canvas), + 'color.surface.recessed': formatOklch(surfaces.recessed), + 'color.surface.floating': formatOklch(surfaces.floating), + 'color.surface.overlay': formatOklch(surfaces.overlay), + 'color.scrim': scrim, + 'color.loadingSkeleton': formatOklch(neutral[8]), + 'color.text.primary': formatOklch(neutral[12]), + 'color.text.secondary': formatOklch(neutral[11]), + 'color.text.disabled': formatOklch(neutral[8]), + 'color.border.decorative': formatOklch(neutral[6]), + 'color.border.control': formatOklch(controlBorder), + 'color.border.focus': formatOklch(focus), + }; +} - return values; +function mapRoleColorValues(families: Record): { + [Path in RoleColorPath]: string; +} { + return pathRecord( + SEMANTIC_ROLES.flatMap((role) => { + const family = families[role]; + return [ + pathEntry(`color.background.${role}.subtle.rest`, formatOklch(family[3])), + pathEntry(`color.background.${role}.subtle.hover`, formatOklch(family[4])), + pathEntry(`color.background.${role}.subtle.pressed`, formatOklch(family[5])), + pathEntry(`color.background.${role}.solid.rest`, formatOklch(family[9])), + pathEntry(`color.background.${role}.solid.hover`, formatOklch(family[10])), + // Deliberate dup: the pressed solid is carried by depth.recessed / + // actionControlFinish.recessed / transform, not a third solid colour. + pathEntry(`color.background.${role}.solid.pressed`, formatOklch(family[10])), + pathEntry(`color.foreground.${role}.rest`, formatOklch(family[11])), + pathEntry(`color.foreground.${role}.hover`, formatOklch(family[12])), + pathEntry(`color.foreground.${role}.onSolid`, formatOklch(family.contrast)), + pathEntry(`color.border.${role}`, formatOklch(family[7])), + ]; + }), + ); } diff --git a/packages/@luke-ui/react/src/theme/stylesheet.test.ts b/packages/@luke-ui/react/src/theme/stylesheet.test.ts index ed83be6b..cb99cb12 100644 --- a/packages/@luke-ui/react/src/theme/stylesheet.test.ts +++ b/packages/@luke-ui/react/src/theme/stylesheet.test.ts @@ -8,20 +8,19 @@ import { tactileFoundation, } from './__fixtures__/theme-css.js'; import { buildTheme } from './build-theme.js'; -import { flattenThemeContract, spaceScale, typeStyles } from './contract.js'; +import { + flattenThemeContract, + partitionContractPairs, + spaceScale, + typeStyles, +} from './contract.js'; import type { ThemeFoundation } from './foundation.js'; -import { defaultFontWeights, defaultRadius, defaultSourceColors } from './foundation.js'; +import { defaultFontWeights, defaultRadius } from './foundation.js'; const pairs = flattenThemeContract(); -const isModePath = (path: string) => { - return ( - path.startsWith('actionControlFinish.') || - path.startsWith('color.') || - path.startsWith('depth.') - ); -}; -const modeVarNames = pairs.filter(([path]) => isModePath(path)).map(([, varName]) => varName); -const identityVarNames = pairs.filter(([path]) => !isModePath(path)).map(([, varName]) => varName); +const { identityPairs, modePairs } = partitionContractPairs(pairs); +const modeVarNames = modePairs.map(([, varName]) => varName); +const identityVarNames = identityPairs.map(([, varName]) => varName); function countOccurrences(text: string, needle: string): number { return text.split(needle).length - 1; @@ -198,16 +197,8 @@ describe('buildTheme defaults', () => { it('fills omitted optional fields with the documented defaults', () => { const explicitFoundation: ThemeFoundation = { - dark: { - actionControlFinish: minimalFoundation.dark.actionControlFinish, - color: { ...minimalFoundation.dark.color, ...defaultSourceColors.dark }, - depth: minimalFoundation.dark.depth, - }, - light: { - actionControlFinish: minimalFoundation.light.actionControlFinish, - color: { ...minimalFoundation.light.color, ...defaultSourceColors.light }, - depth: minimalFoundation.light.depth, - }, + dark: minimalFoundation.dark, + light: minimalFoundation.light, name: 'minimal-check', radius: { ...defaultRadius }, typography: { fontFamily: 'inter', fontWeight: { ...defaultFontWeights } }, diff --git a/packages/@luke-ui/react/src/theme/stylesheet.ts b/packages/@luke-ui/react/src/theme/stylesheet.ts index bc6ff5b0..db18cf7a 100644 --- a/packages/@luke-ui/react/src/theme/stylesheet.ts +++ b/packages/@luke-ui/react/src/theme/stylesheet.ts @@ -5,7 +5,14 @@ */ import { precomputeValues } from '@capsizecss/vanilla-extract'; -import { flattenThemeContract, spaceScale, typeStyles, typeStyleWeightRole } from './contract.js'; +import { + flattenThemeContract, + partitionContractPairs, + spaceScale, + typeStyles, + typeStyleWeightRole, +} from './contract.js'; +import type { IdentityPath, ModePath, SpaceStep, TypeStyle } from './contract.js'; import type { ThemeFoundation } from './foundation.js'; import { codeFontFamilyStack, @@ -14,6 +21,7 @@ import { defaultRadius, themeFontFamilyStacks, } from './foundation.js'; +import { pathEntry, pathRecord } from './path-record.js'; import { getThemeClassName } from './theme-class-name.js'; import { CONTROL_SIZE_VALUES, @@ -34,20 +42,11 @@ type ColorMode = 'light' | 'dark'; */ export function assembleStylesheet( foundation: ThemeFoundation, - lightValues: Record, - darkValues: Record, + lightValues: Record, + darkValues: Record, ): string { const selector = `.${getThemeClassName(foundation.name)}`; - const pairs = flattenThemeContract(); - const isModePath = (path: string) => { - return ( - path.startsWith('actionControlFinish.') || - path.startsWith('color.') || - path.startsWith('depth.') - ); - }; - const identityPairs = pairs.filter(([path]) => !isModePath(path)); - const modePairs = pairs.filter(([path]) => isModePath(path)); + const { identityPairs, modePairs } = partitionContractPairs(flattenThemeContract()); const identityDeclarations = declarations(identityPairs, buildIdentityValues(foundation)); const lightDeclarations = ['color-scheme: light;', ...declarations(modePairs, lightValues)]; @@ -97,7 +96,7 @@ export function assembleStylesheet( ].join('\n'); } -function buildIdentityValues(foundation: ThemeFoundation): Record { +function buildIdentityValues(foundation: ThemeFoundation): { [Path in IdentityPath]: string } { const fontFamily = foundation.typography?.fontFamily ?? defaultFontFamily; const fontWeight = foundation.typography?.fontWeight; const radius = foundation.radius; @@ -108,11 +107,13 @@ function buildIdentityValues(foundation: ThemeFoundation): Record = { + return { ...CONTROL_SIZE_VALUES, ...INTERACTION_VALUES, ...FONT_VALUES, ...buildCapsizeValues(fontFamily), + ...typeStyleValues('fontFamily', () => bodyFontFamily), + ...typeStyleValues('fontWeight', (style) => resolvedWeights[typeStyleWeightRole[style]]), 'font.family.body': bodyFontFamily, 'font.family.code': codeFontFamilyStack, 'font.weight.body': resolvedWeights.body, @@ -124,38 +125,54 @@ function buildIdentityValues(foundation: ThemeFoundation): Record { - const values: Record = {}; - for (const style of typeStyles) { - const fontSize = Number.parseFloat(FONT_VALUES[`font.${style}.fontSize`]); - const leading = Number.parseFloat(FONT_VALUES[`font.${style}.lineHeight`]); - const { baselineTrim, capHeightTrim } = precomputeValues({ - fontMetrics: FONT_METRICS[fontFamily], - fontSize, - leading, - }); - values[`font.${style}.baselineTrim`] = baselineTrim; - values[`font.${style}.capHeightTrim`] = capHeightTrim; - } - return values; +function typeStyleValues( + field: Field, + valueFor: (style: TypeStyle) => string, +): { [P in `font.${TypeStyle}.${Field}`]: string } { + return pathRecord( + typeStyles.map((style) => pathEntry(`font.${style}.${field}`, valueFor(style))), + ); +} + +function spaceValues(): { [Step in SpaceStep as `space.${Step}`]: string } { + return pathRecord(spaceScale.map(([step, value]) => pathEntry(`space.${step}`, value))); +} + +function buildCapsizeValues(fontFamily: keyof typeof FONT_METRICS): { + [Style in TypeStyle as `font.${Style}.baselineTrim`]: string; +} & { + [Style in TypeStyle as `font.${Style}.capHeightTrim`]: string; +} { + return pathRecord( + typeStyles.flatMap((style) => { + const { baselineTrim, capHeightTrim } = capsizeTrims(fontFamily, style); + return [ + pathEntry(`font.${style}.baselineTrim`, baselineTrim), + pathEntry(`font.${style}.capHeightTrim`, capHeightTrim), + ]; + }), + ); +} + +function capsizeTrims(fontFamily: keyof typeof FONT_METRICS, style: TypeStyle) { + const fontSize = Number.parseFloat(FONT_VALUES[`font.${style}.fontSize`]); + const leading = Number.parseFloat(FONT_VALUES[`font.${style}.lineHeight`]); + return precomputeValues({ + fontMetrics: FONT_METRICS[fontFamily], + fontSize, + leading, + }); } -function declarations( - pairs: Array<[path: string, varName: string]>, - values: Record, +function declarations( + pairs: Array<[Path, string]>, + values: Record, ): Array { return pairs.map(([path, varName]) => { const value = values[path]; diff --git a/packages/@luke-ui/react/src/theme/token-values.ts b/packages/@luke-ui/react/src/theme/token-values.ts index cf365191..3c6a50dc 100644 --- a/packages/@luke-ui/react/src/theme/token-values.ts +++ b/packages/@luke-ui/react/src/theme/token-values.ts @@ -10,20 +10,24 @@ import appleSystemMetrics from '@capsizecss/metrics/appleSystem'; import dMSansMetrics from '@capsizecss/metrics/dMSans'; import interMetrics from '@capsizecss/metrics/inter'; -import { typeStyles, typeStyleMetricStep } from './contract.js'; +import { typeStyleMetricStep, typeStyles } from './contract.js'; +import type { IdentityPath, TypeStyle } from './contract.js'; import { FONT_METRIC_SCALE } from './font-metric-scale.js'; import { MOTION_DURATION_SCALE } from './motion.js'; +import { pathEntry, pathRecord } from './path-record.js'; /** * Structural block sizes for the small and medium controls, the minimum tap target, and * Combobox's square actions. */ -export const CONTROL_SIZE_VALUES = { +export const CONTROL_SIZE_VALUES: { + [Path in Extract]: string; +} = { 'controlSize.comboboxAction': '28px', 'controlSize.medium': '40px', 'controlSize.minTarget': '24px', 'controlSize.small': '32px', -} as const; +}; /** * Shape of a Capsize font-metrics object, named locally so `.d.ts` output never needs Capsize's @@ -46,42 +50,52 @@ export const FONT_METRICS: Record<'apple-system' | 'dm-sans' | 'inter', CapsizeF }; type FontValueLeaf = 'fontSize' | 'letterSpacing' | 'lineHeight'; -type FontValueKey = `font.${(typeof typeStyles)[number]}.${FontValueLeaf}`; +type FontValueKey = `font.${TypeStyle}.${FontValueLeaf}`; + +function styleMetrics(style: TypeStyle) { + return FONT_METRIC_SCALE[typeStyleMetricStep[style]]; +} /** * Fixed metrics for each public type style: font size, line height, and letter spacing, resolved * from the private metric scale. Family, weight, and Capsize trims are resolved in the stylesheet * from the active theme. */ -export const FONT_VALUES = Object.fromEntries( +export const FONT_VALUES: { readonly [Key in FontValueKey]: string } = pathRecord( typeStyles.flatMap((style) => { - const metrics = FONT_METRIC_SCALE[typeStyleMetricStep[style]]; + const metrics = styleMetrics(style); return [ - [`font.${style}.fontSize`, metrics.fontSize], - [`font.${style}.letterSpacing`, metrics.letterSpacing], - [`font.${style}.lineHeight`, metrics.lineHeight], - ] as const; + pathEntry(`font.${style}.fontSize`, metrics.fontSize), + pathEntry(`font.${style}.letterSpacing`, metrics.letterSpacing), + pathEntry(`font.${style}.lineHeight`, metrics.lineHeight), + ]; }), -) as { readonly [Key in FontValueKey]: string }; +); /** Inline and block sizes for the four public icon sizes. */ -export const ICON_SIZE_VALUES = { +export const ICON_SIZE_VALUES: { + [Path in Extract]: string; +} = { 'iconSize.large': '32px', 'iconSize.medium': '24px', 'iconSize.small': '20px', 'iconSize.xsmall': '16px', -} as const; +}; /** The fade every control recipe applies to a disabled or pending control. */ -export const INTERACTION_VALUES = { +export const INTERACTION_VALUES: { + [Path in Extract]: string; +} = { 'interaction.disabledOpacity': '0.55', -} as const; +}; /** Durations and easing curves for interaction motion, named for the role each plays. */ -export const MOTION_VALUES = { +export const MOTION_VALUES: { + [Path in Extract]: string; +} = { 'motion.duration.enter': MOTION_DURATION_SCALE[500], 'motion.duration.exit': MOTION_DURATION_SCALE[300], 'motion.duration.feedback': MOTION_DURATION_SCALE[200], 'motion.easing.exit': 'cubic-bezier(0.3, 0, 1, 1)', 'motion.easing.standard': 'cubic-bezier(0, 0, 0.4, 1)', -} as const; +}; diff --git a/packages/@luke-ui/react/src/theme/validate-foundation.test.ts b/packages/@luke-ui/react/src/theme/validate-foundation.test.ts index d23c8ac0..72b478ce 100644 --- a/packages/@luke-ui/react/src/theme/validate-foundation.test.ts +++ b/packages/@luke-ui/react/src/theme/validate-foundation.test.ts @@ -56,6 +56,24 @@ describe('buildTheme foundation validation', () => { ); }); + it('rejects a source colour that is not a well-formed OKLCH value', () => { + const invalidAccent: ThemeFoundation = { + ...tactileFoundation, + light: { + ...tactileFoundation.light, + color: { + ...tactileFoundation.light.color, + accent: { l: 1.5, c: 0.1, h: 200 }, + }, + }, + name: 'invalid-accent', + }; + + expect(() => buildTheme(invalidAccent)).toThrow( + 'light.color.accent: must be an OKLCH colour with lightness 0-1', + ); + }); + it("produces the validator's message rather than a TypeError for a shadow rung set to undefined", () => { // `defineTheme` now filters `undefined` rungs before merging (define-theme.test.ts covers that // fallback), but `buildTheme` is also called directly with a raw foundation (tests, tooling, diff --git a/packages/@luke-ui/react/src/theme/validate-foundation.ts b/packages/@luke-ui/react/src/theme/validate-foundation.ts index 9cebf4ce..3ea9925c 100644 --- a/packages/@luke-ui/react/src/theme/validate-foundation.ts +++ b/packages/@luke-ui/react/src/theme/validate-foundation.ts @@ -1,11 +1,10 @@ /** * Checks a `ThemeFoundation` before it enters the colour and stylesheet pipeline: kebab-case naming, - * parseable source colours, safe-to-emit authored CSS strings, curated font-family choices, and + * well-formed source colours, safe-to-emit authored CSS strings, curated font-family choices, and * in-range weight and radius numbers. It collects every issue before throwing, so an author fixes * the whole foundation at once instead of one error per build. */ -import { parseColor } from './color.js'; import type { ThemeFoundation } from './foundation.js'; import { SOURCE_COLOR_FIELDS, themeFontFamilyStacks } from './foundation.js'; import { getThemeClassName } from './theme-class-name.js'; @@ -23,6 +22,23 @@ function isUnsafeCssValue(value: unknown): boolean { return typeof value !== 'string' || value.trim() === '' || /[;{}]/.test(value); } +/** Whether a value is not an OKLCH colour with finite channels and lightness in 0-1. */ +function isInvalidOklch(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return true; + const { l, c, h } = value as { c?: unknown; h?: unknown; l?: unknown }; + return ( + typeof l !== 'number' || + typeof c !== 'number' || + typeof h !== 'number' || + !Number.isFinite(l) || + !Number.isFinite(c) || + !Number.isFinite(h) || + l < 0 || + l > 1 || + c < 0 + ); +} + /** * Throws one `Error` listing every issue found, one per line. Returns nothing when the foundation * is well-formed. @@ -38,11 +54,8 @@ export function validateFoundation(foundation: ThemeFoundation): void { 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)}`); + if (isInvalidOklch(value)) { + issues.push(`${mode}.color.${field}: must be an OKLCH colour with lightness 0-1`); } } if (isUnsafeCssValue(modeFoundation.color.scrim)) { -- 2.51.2