[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add defineTheme authoring util (additive) (#229)

Introduce defineTheme alongside the existing buildTheme/ThemeFoundation:
it normalises a curated ThemeInput into the per-mode foundation, with
single-value accent adaptation via an on-solid lightness search that
mirrors build-theme's validation, neutralStyle-driven neutrals,
generative radius, and optional materials merged over curated
extremely-subtle defaults.

The emitted contract is unchanged (still 152 leaves); the resolved scrim
is computed but not emitted yet (lands in #228). Slice 1 of #226.

authored by

Luke Bennett and committed by
GitHub
(Jul 24, 2026, 9:50 AM +1000) d3c73099 862d6677

+618
+207
packages/@luke-ui/react/src/theme/define-theme.test.ts
··· 1 + import { describe, expect, it } from 'vite-plus/test'; 2 + import { buildTheme } from './build-theme.js'; 3 + import { contrastRatio, gamutMapOklch, parseColor } from './color.js'; 4 + import { defaultDepth, defineTheme } from './define-theme.js'; 5 + import type { ThemeInput } from './define-theme.js'; 6 + import { defaultSourceColors } from './foundation.js'; 7 + import { paperFoundation, tactileFoundation } from './foundations.js'; 8 + 9 + /** 10 + * Splits a generated stylesheet into its five rule blocks: identity, base light, media-query dark, 11 + * explicit light, and explicit dark. 12 + */ 13 + function splitBlocks(css: string) { 14 + const blocks = css.split('\n\n').filter((block) => block.trim() !== ''); 15 + if (blocks.length !== 5) throw new Error(`expected 5 rule blocks, found ${blocks.length}`); 16 + const [identity, baseLight, mediaDark, explicitLight, explicitDark] = blocks; 17 + if ( 18 + identity === undefined || 19 + baseLight === undefined || 20 + mediaDark === undefined || 21 + explicitLight === undefined || 22 + explicitDark === undefined 23 + ) { 24 + throw new Error('expected every generated theme rule block to be defined'); 25 + } 26 + return { baseLight, explicitDark, explicitLight, identity, mediaDark }; 27 + } 28 + 29 + function extractValue(block: string, varName: string): string { 30 + const match = new RegExp(`${varName}: ([^;]+);`).exec(block); 31 + if (match === null || match[1] === undefined) throw new Error(`missing ${varName} in block`); 32 + return match[1]; 33 + } 34 + 35 + const ACCENT_SOLID = '--luke-color-intent-accent-surface-solid'; 36 + const SURFACE_VAR_NAMES = [ 37 + '--luke-color-surface-canvas', 38 + '--luke-color-surface-resting', 39 + '--luke-color-surface-recessed', 40 + '--luke-color-surface-floating', 41 + '--luke-color-surface-overlay', 42 + ]; 43 + 44 + describe('defineTheme colour-only authoring', () => { 45 + it('builds a theme from just an accent and a neutral character, accessible in both modes', () => { 46 + const css = defineTheme({ 47 + color: { accent: '#3b82f6', neutralStyle: 'cool' }, 48 + name: 'colour-only', 49 + }); 50 + const blocks = splitBlocks(css); 51 + for (const block of [blocks.baseLight, blocks.mediaDark]) { 52 + const textPrimary = parseColor(extractValue(block, '--luke-color-text-primary')); 53 + const borderControl = parseColor(extractValue(block, '--luke-color-border-control')); 54 + const canvas = parseColor(extractValue(block, '--luke-color-surface-canvas')); 55 + for (const varName of SURFACE_VAR_NAMES) { 56 + const surface = parseColor(extractValue(block, varName)); 57 + expect(contrastRatio(textPrimary, surface)).toBeGreaterThanOrEqual(4.5); 58 + } 59 + expect(contrastRatio(borderControl, canvas)).toBeGreaterThanOrEqual(3); 60 + } 61 + }); 62 + }); 63 + 64 + describe('defineTheme single-value accent adaptation', () => { 65 + const accents = [ 66 + '#3b82f6', 67 + '#ef4444', 68 + '#22c55e', 69 + '#eab308', 70 + '#f97316', 71 + 'oklch(0.7 0.15 320)', 72 + 'oklch(0.6 0.12 160)', 73 + 'oklch(0.5 0.2 270)', 74 + ]; 75 + 76 + for (const accent of accents) { 77 + it(`adapts ${accent} to an accessible light and dark accent via a per-mode search`, () => { 78 + // buildTheme throws ThemeContrastError on any breach, so reaching the assertions proves the 79 + // adapted accent is accessible in both modes. 80 + const blocks = splitBlocks(defineTheme({ color: { accent }, name: 'accent-adapt' })); 81 + const lightSolid = parseColor(extractValue(blocks.baseLight, ACCENT_SOLID)); 82 + const darkSolid = parseColor(extractValue(blocks.mediaDark, ACCENT_SOLID)); 83 + const source = gamutMapOklch(parseColor(accent)); 84 + 85 + // The source hue is preserved; only the lightness (and gamut-clamped chroma) is adapted. 86 + expect(lightSolid.h).toBeCloseTo(source.h, 0); 87 + expect(darkSolid.h).toBeCloseTo(source.h, 0); 88 + 89 + // Each mode lands in its own vibrant band near the mode target (~0.5 light, ~0.72 dark). 90 + expect(lightSolid.l).toBeCloseTo(0.5, 1); 91 + expect(darkSolid.l).toBeCloseTo(0.72, 1); 92 + 93 + // A naive passthrough would emit the source lightness verbatim for both modes; a per-mode 94 + // search instead moves the lightness independently, so the modes differ and at least one 95 + // mode is moved off the source lightness. 96 + expect(lightSolid.l).not.toBeCloseTo(darkSolid.l, 2); 97 + const movedFromSource = 98 + Math.abs(lightSolid.l - source.l) > 0.01 || Math.abs(darkSolid.l - source.l) > 0.01; 99 + expect(movedFromSource).toBe(true); 100 + }); 101 + } 102 + }); 103 + 104 + describe('defineTheme partial per-mode merges', () => { 105 + it('merges a partial depth ladder per mode without cross-mode bleed', () => { 106 + const overlay = 'X'; 107 + const blocks = splitBlocks( 108 + defineTheme({ 109 + color: { accent: '#3b82f6' }, 110 + depth: { light: { overlay } }, 111 + name: 'partial-depth', 112 + }), 113 + ); 114 + // The authored light rung wins; the other light rungs keep the light default. 115 + expect(extractValue(blocks.baseLight, '--luke-depth-overlay')).toBe(overlay); 116 + expect(extractValue(blocks.baseLight, '--luke-depth-resting')).toBe(defaultDepth.light.resting); 117 + // Dark is untouched: every dark rung, including overlay, keeps the dark default. 118 + expect(extractValue(blocks.mediaDark, '--luke-depth-overlay')).toBe(defaultDepth.dark.overlay); 119 + expect(extractValue(blocks.mediaDark, '--luke-depth-resting')).toBe(defaultDepth.dark.resting); 120 + }); 121 + 122 + it('defaults the omitted dark side of a partial colour without bleeding the light override', () => { 123 + const infoVarNames = [ 124 + '--luke-color-intent-info-text', 125 + '--luke-color-intent-info-border', 126 + '--luke-color-intent-info-surface-subtle', 127 + ]; 128 + const overridden = splitBlocks( 129 + defineTheme({ color: { accent: '#fff', info: { light: '#1d39c4' } }, name: 'partial-color' }), 130 + ); 131 + const allDefault = splitBlocks( 132 + defineTheme({ color: { accent: '#fff' }, name: 'partial-color-default' }), 133 + ); 134 + // The omitted dark info side falls back to the curated default: identical to the all-default build. 135 + for (const varName of infoVarNames) { 136 + expect(extractValue(overridden.mediaDark, varName)).toBe( 137 + extractValue(allDefault.mediaDark, varName), 138 + ); 139 + } 140 + // The explicit light info override changed the light info kit and did not bleed into dark. 141 + const overriddenLight = infoVarNames.map((varName) => 142 + extractValue(overridden.baseLight, varName), 143 + ); 144 + const defaultLight = infoVarNames.map((varName) => extractValue(allDefault.baseLight, varName)); 145 + expect(overriddenLight).not.toEqual(defaultLight); 146 + }); 147 + }); 148 + 149 + describe('defineTheme parity with the bundled foundations', () => { 150 + const tactileInput: ThemeInput = { 151 + actionControlFinish: { 152 + dark: tactileFoundation.dark.actionControlFinish, 153 + light: tactileFoundation.light.actionControlFinish, 154 + }, 155 + color: { 156 + accent: { 157 + dark: tactileFoundation.dark.color.accent, 158 + light: tactileFoundation.light.color.accent, 159 + }, 160 + neutral: { 161 + dark: tactileFoundation.dark.color.neutral, 162 + light: tactileFoundation.light.color.neutral, 163 + }, 164 + }, 165 + depth: { dark: tactileFoundation.dark.depth, light: tactileFoundation.light.depth }, 166 + name: 'tactile', 167 + }; 168 + 169 + const paperInput: ThemeInput = { 170 + actionControlFinish: { 171 + dark: paperFoundation.dark.actionControlFinish, 172 + light: paperFoundation.light.actionControlFinish, 173 + }, 174 + color: { 175 + accent: { 176 + dark: paperFoundation.dark.color.accent, 177 + light: paperFoundation.light.color.accent, 178 + }, 179 + // Paper authors feedback colours in light only; the omitted dark sides default. 180 + danger: { light: paperFoundation.light.color.danger }, 181 + info: { light: paperFoundation.light.color.info }, 182 + neutral: { 183 + dark: paperFoundation.dark.color.neutral, 184 + light: paperFoundation.light.color.neutral, 185 + }, 186 + success: { light: paperFoundation.light.color.success }, 187 + warning: { light: paperFoundation.light.color.warning }, 188 + }, 189 + depth: { dark: paperFoundation.dark.depth, light: paperFoundation.light.depth }, 190 + name: 'paper', 191 + radius: { control: 4 }, 192 + }; 193 + 194 + it('reproduces Tactile byte-for-byte', () => { 195 + expect(defineTheme(tactileInput)).toBe(buildTheme(tactileFoundation)); 196 + }); 197 + 198 + it('reproduces Paper byte-for-byte', () => { 199 + expect(defineTheme(paperInput)).toBe(buildTheme(paperFoundation)); 200 + }); 201 + 202 + it('resolves the omitted dark feedback sides to the curated defaults', () => { 203 + // Sanity-check the parity claim: Paper's dark feedback intents come from defaultSourceColors. 204 + expect(paperFoundation.dark.color.info).toBeUndefined(); 205 + expect(defaultSourceColors.dark.info).toMatch(/^oklch\(/); 206 + }); 207 + });
+395
packages/@luke-ui/react/src/theme/define-theme.ts
··· 1 + /** 2 + * The `defineTheme` authoring util: a small, curated-default authoring surface that normalises a 3 + * {@link ThemeInput} into the existing per-mode {@link ThemeFoundation} and hands it to 4 + * {@link buildTheme}. It is additive — `buildTheme` and its raw `ThemeFoundation` stay public — and 5 + * owns the single-value accent/neutral adaptation the raw foundation never had. 6 + */ 7 + 8 + import { buildTheme } from './build-theme.js'; 9 + import type { Oklch } from './color.js'; 10 + import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js'; 11 + import type { ThemeFoundation, ThemeModeFoundation, ThemeSourceColors } from './foundation.js'; 12 + import { defaultSourceColors } from './foundation.js'; 13 + 14 + /** 15 + * A colour value: one string (adapted independently for each mode) OR a per-mode object where 16 + * EITHER side may be omitted to fall back to that role's curated default / generation. Strings 17 + * accept `#rgb`, `#rrggbb`, or `oklch(<l> <c> <h>)` (lightness 0-1 or %, no alpha), except `scrim`, 18 + * which is used verbatim and may carry an alpha channel. 19 + */ 20 + export type ColorInput = string | { light?: string; dark?: string }; 21 + 22 + /** A composite `box-shadow` ladder for one colour mode, rung by rung. */ 23 + export interface DepthLadder { 24 + /** Inset treatment for a pressed control or sunken surface. */ 25 + recessed: string; 26 + /** Resting treatment for an interactive control or surface. */ 27 + resting: string; 28 + /** Treatment for a hovered control or elevated surface. */ 29 + raised: string; 30 + /** Treatment for a floating surface such as a menu. */ 31 + floating: string; 32 + /** Treatment for a high-elevation surface such as a dialog. */ 33 + overlay: string; 34 + } 35 + 36 + /** A Button/IconButton `background-image` face-finish ladder for one colour mode. */ 37 + export interface ControlFinish { 38 + /** Face lighting for a pressed control. */ 39 + recessed: string; 40 + /** Face lighting for a resting control. */ 41 + resting: string; 42 + /** Face lighting for a hovered control. */ 43 + raised: string; 44 + } 45 + 46 + /** 47 + * The curated theme-authoring input. A basic theme authors an accent and a neutral character and 48 + * lets everything else default; materials are optional and deep-partial; light and dark stay 49 + * independently authorable. 50 + */ 51 + export interface ThemeInput { 52 + /** 53 + * Kebab-case theme identity, for example `'tactile'`. The theme's identity class is 54 + * `luke-ui-theme-${name}`. 55 + */ 56 + name: string; 57 + /** Source colours. Each is one value (adapted per mode) or an explicit `{ light, dark }` pair. */ 58 + color: { 59 + /** Required — the brand or interaction accent. */ 60 + accent: ColorInput; 61 + /** Neutral canvas anchor. Give a raw colour, or set `neutralStyle` for a curated neutral. */ 62 + neutral?: ColorInput; 63 + /** 64 + * Curated neutral character when `neutral` is omitted; sets the neutral hue and tint while the 65 + * mode sets its lightness. 66 + * @default 'neutral' 67 + */ 68 + neutralStyle?: 'cool' | 'neutral' | 'warm'; 69 + /** Informational intent colour. Defaults to an accessible Luke UI blue for the mode. */ 70 + info?: ColorInput; 71 + /** Success intent colour. Defaults to an accessible Luke UI green for the mode. */ 72 + success?: ColorInput; 73 + /** Warning intent colour. Defaults to an accessible Luke UI amber for the mode. */ 74 + warning?: ColorInput; 75 + /** Danger intent colour. Defaults to an accessible Luke UI red for the mode. */ 76 + danger?: ColorInput; 77 + /** Keyboard-focus ring colour, used verbatim after gamut mapping. Defaults per mode. */ 78 + focus?: ColorInput; 79 + /** Modal-backdrop dimming colour, used verbatim; defaults to black at a mode-aware alpha. */ 80 + scrim?: ColorInput; 81 + }; 82 + /** Typography — family and weights only. The type scale is source-owned (not authored here). */ 83 + typography?: { 84 + /** 85 + * Curated Capsize-compatible font-family choice. 86 + * @default 'inter' 87 + */ 88 + fontFamily?: 'inter' | 'apple-system' | 'dm-sans'; 89 + /** Font weights for the four theme-controlled weight roles. */ 90 + fontWeight?: { body?: number; label?: number; heading?: number; emphasis?: number }; 91 + }; 92 + /** Corner radii. A generative base + multiplier scale, with explicit per-step overrides. */ 93 + radius?: { 94 + /** 95 + * Base radius in pixels. Generates `detail = base`, `control = base*2`, `surface = base*3`, 96 + * `overlay = base*4`, each scaled by `multiplier`. 97 + * @default 4 98 + */ 99 + base?: number; 100 + /** 101 + * Scales the whole generated set. 102 + * @default 1 103 + */ 104 + multiplier?: number; 105 + /** Explicit override for the detail radius (checkboxes, tags, badges). */ 106 + detail?: number; 107 + /** Explicit override for the control radius (buttons, fields, selects). */ 108 + control?: number; 109 + /** Explicit override for the surface radius (cards, popovers, menus). */ 110 + surface?: number; 111 + /** Explicit override for the overlay radius (dialogs, sheets). */ 112 + overlay?: number; 113 + // radius.full is fixed at 9999px and is not authored. 114 + }; 115 + /** 116 + * Composite `box-shadow` depth ladder, per mode. Optional and deep-partial: an omitted rung 117 + * falls back to the curated extremely-subtle default for that mode. 118 + */ 119 + depth?: { light?: Partial<DepthLadder>; dark?: Partial<DepthLadder> }; 120 + /** 121 + * Button/IconButton face finish, per mode. Optional and deep-partial: an omitted rung falls back 122 + * to `'none'` (a flat control). 123 + */ 124 + actionControlFinish?: { light?: Partial<ControlFinish>; dark?: Partial<ControlFinish> }; 125 + } 126 + 127 + type ColorMode = 'light' | 'dark'; 128 + 129 + // The WCAG 2.2 AA text ratio the on-solid gate must clear; matches build-theme's TEXT_RATIO so the 130 + // adaptation search and the build-time validation agree. 131 + const TEXT_RATIO = 4.5; 132 + 133 + /** 134 + * `neutralStyle` → the source neutral's hue and small chroma; the mode supplies the lightness. 135 + * Light `'cool'` ≈ `oklch(0.985 0.01 250)`; dark `'cool'` ≈ `oklch(0.22 0.01 250)`. 136 + */ 137 + const NEUTRAL_STYLE = { 138 + cool: { chroma: 0.01, hue: 250 }, 139 + neutral: { chroma: 0, hue: 0 }, 140 + warm: { chroma: 0.01, hue: 70 }, 141 + } as const satisfies Record<string, { chroma: number; hue: number }>; 142 + 143 + // Canvas lightness a single-value or styled neutral targets per mode: near-white light, near-dark 144 + // dark. The neutral solid's on-solid gate depends on its (tiny) chroma and a fixed solid lightness, 145 + // not on this anchor, so the neutral never has the accent's mid-lightness dead zone. 146 + const NEUTRAL_LIGHTNESS = { dark: 0.22, light: 0.985 } as const satisfies Record<ColorMode, number>; 147 + 148 + // The vibrant band a single-value accent is adapted into, and the lightness the search starts from. 149 + // Contrast for the on-solid text lives at the band edges (dark solids take near-white text, light 150 + // solids take near-black); the middle is a dead zone, so the search targets a vibrant lightness and 151 + // walks outward to the nearest lightness whose whole solid trio clears the gate. 152 + const ACCENT_TARGET = { dark: 0.72, light: 0.5 } as const satisfies Record<ColorMode, number>; 153 + const ACCENT_BAND = { 154 + dark: [0.6, 0.82], 155 + light: [0.4, 0.62], 156 + } as const satisfies Record<ColorMode, [number, number]>; 157 + const ACCENT_SEARCH_STEP = 0.0025; 158 + 159 + /** Curated extremely-subtle, hue-neutral shadow ladder applied when a `depth` rung is omitted. */ 160 + export const defaultDepth: Record<ColorMode, DepthLadder> = { 161 + dark: { 162 + floating: '0 4px 12px oklch(0 0 0 / 0.45), 0 2px 4px oklch(0 0 0 / 0.3)', 163 + overlay: '0 12px 32px oklch(0 0 0 / 0.55), 0 4px 12px oklch(0 0 0 / 0.35)', 164 + raised: '0 2px 4px oklch(0 0 0 / 0.35), 0 1px 2px oklch(0 0 0 / 0.25)', 165 + recessed: 'inset 0 1px 2px oklch(0 0 0 / 0.3)', 166 + resting: '0 1px 2px oklch(0 0 0 / 0.3)', 167 + }, 168 + light: { 169 + floating: '0 4px 12px oklch(0 0 0 / 0.08), 0 2px 4px oklch(0 0 0 / 0.05)', 170 + overlay: '0 12px 32px oklch(0 0 0 / 0.12), 0 4px 12px oklch(0 0 0 / 0.07)', 171 + raised: '0 2px 4px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04)', 172 + recessed: 'inset 0 1px 2px oklch(0 0 0 / 0.06)', 173 + resting: '0 1px 2px oklch(0 0 0 / 0.05)', 174 + }, 175 + }; 176 + 177 + /** Curated flat control finish applied when an `actionControlFinish` rung is omitted. */ 178 + export const defaultControlFinish: ControlFinish = { 179 + raised: 'none', 180 + recessed: 'none', 181 + resting: 'none', 182 + }; 183 + 184 + /** Curated modal-backdrop scrim applied when `scrim` is omitted, black at a mode-aware alpha. */ 185 + export const defaultScrim: Record<ColorMode, string> = { 186 + dark: 'oklch(0 0 0 / 0.4)', 187 + light: 'oklch(0 0 0 / 0.2)', 188 + }; 189 + 190 + const DEFAULT_RADIUS_BASE = 4; 191 + const DEFAULT_RADIUS_MULTIPLIER = 1; 192 + const RADIUS_STEPS = { control: 2, detail: 1, overlay: 4, surface: 3 } as const; 193 + 194 + /** 195 + * Compiles a curated {@link ThemeInput} into a complete static stylesheet. Normalises the input 196 + * into the per-mode {@link ThemeFoundation} shape — adapting single-value accents and neutrals per 197 + * mode, generating the radius scale, and merging materials over curated defaults — then delegates 198 + * to {@link buildTheme}, whose build-time contrast validation stays authoritative. Throws when a 199 + * single-value accent has no accessible lightness in a mode, and (via `buildTheme`) throws 200 + * {@link ThemeContrastError} when any resolved pair misses WCAG 2.2 AA. 201 + */ 202 + export function defineTheme(input: ThemeInput): string { 203 + const { foundation } = normalizeTheme(input); 204 + // The resolved `scrim` is computed by `normalizeTheme` but the emitted contract has no scrim 205 + // leaf yet, so there is nowhere to emit it in this additive slice. Emission lands in #228; until 206 + // then the resolved value is intentionally not consumed here. 207 + return buildTheme(foundation); 208 + } 209 + 210 + /** The fully resolved theme: the foundation `buildTheme` consumes plus the not-yet-emitted scrim. */ 211 + interface NormalizedTheme { 212 + foundation: ThemeFoundation; 213 + /** Resolved per-mode scrim. Computed but not emitted until the contract gains a scrim leaf (#228). */ 214 + scrim: Record<ColorMode, string>; 215 + } 216 + 217 + /** Resolves a {@link ThemeInput} into the per-mode foundation and the resolved scrim. */ 218 + function normalizeTheme(input: ThemeInput): NormalizedTheme { 219 + const foundation: ThemeFoundation = { 220 + dark: buildModeFoundation(input, 'dark'), 221 + light: buildModeFoundation(input, 'light'), 222 + name: input.name, 223 + radius: resolveRadius(input), 224 + }; 225 + if (input.typography !== undefined) foundation.typography = input.typography; 226 + return { 227 + foundation, 228 + scrim: { 229 + dark: resolveVerbatimRole(input.color.scrim, 'dark', defaultScrim.dark), 230 + light: resolveVerbatimRole(input.color.scrim, 'light', defaultScrim.light), 231 + }, 232 + }; 233 + } 234 + 235 + /** Resolves one mode's source colours and materials. */ 236 + function buildModeFoundation(input: ThemeInput, mode: ColorMode): ThemeModeFoundation { 237 + return { 238 + actionControlFinish: { ...defaultControlFinish, ...(input.actionControlFinish?.[mode] ?? {}) }, 239 + color: resolveColors(input, mode), 240 + depth: { ...defaultDepth[mode], ...(input.depth?.[mode] ?? {}) }, 241 + }; 242 + } 243 + 244 + /** Resolves every source-colour role for one mode into the strings `buildTheme` accepts. */ 245 + function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { 246 + const { color } = input; 247 + const defaults = defaultSourceColors[mode]; 248 + const colors: ThemeSourceColors = { 249 + accent: resolveAdaptedRole(color.accent, mode, adaptAccent), 250 + neutral: resolveNeutral(color, mode), 251 + }; 252 + const feedback = { 253 + danger: color.danger, 254 + focus: color.focus, 255 + info: color.info, 256 + success: color.success, 257 + warning: color.warning, 258 + } as const; 259 + for (const role of ['info', 'success', 'warning', 'danger', 'focus'] as const) { 260 + colors[role] = resolveVerbatimRole(feedback[role], mode, defaults[role]); 261 + } 262 + return colors; 263 + } 264 + 265 + /** 266 + * Resolves an accent-style role that adapts a single value per mode. An explicit side is used 267 + * verbatim; a single string or an omitted side is adapted through `adapt` for the mode. 268 + */ 269 + function resolveAdaptedRole( 270 + input: ColorInput, 271 + mode: ColorMode, 272 + adapt: (source: Oklch, mode: ColorMode, raw: string) => Oklch, 273 + ): string { 274 + if (typeof input === 'string') 275 + return formatOklch(adapt(gamutMapOklch(parseColor(input)), mode, input)); 276 + const side = sideOf(input, mode); 277 + if (side !== undefined) return side; 278 + // The other side is present (a partial `{ light }` or `{ dark }`); generate this side from it. 279 + const other = sideOf(input, mode === 'light' ? 'dark' : 'light'); 280 + if (other === undefined) { 281 + throw new Error(`Theme "accent": provide a colour for at least one of light or dark.`); 282 + } 283 + return formatOklch(adapt(gamutMapOklch(parseColor(other)), mode, other)); 284 + } 285 + 286 + /** Resolves the neutral role, honouring `neutral` then `neutralStyle` (default `'neutral'`). */ 287 + function resolveNeutral(color: ThemeInput['color'], mode: ColorMode): string { 288 + if (color.neutral !== undefined) { 289 + const side = sideOf(color.neutral, mode); 290 + if (side !== undefined) return side; 291 + if (typeof color.neutral === 'string') { 292 + return adaptNeutralString(gamutMapOklch(parseColor(color.neutral)), mode); 293 + } 294 + const other = sideOf(color.neutral, mode === 'light' ? 'dark' : 'light'); 295 + if (other !== undefined) return adaptNeutralString(gamutMapOklch(parseColor(other)), mode); 296 + } 297 + const style = NEUTRAL_STYLE[color.neutralStyle ?? 'neutral']; 298 + return formatOklch(gamutMapOklch({ c: style.chroma, h: style.hue, l: NEUTRAL_LIGHTNESS[mode] })); 299 + } 300 + 301 + /** Adapts a single neutral source to the mode canvas lightness, preserving hue and chroma. */ 302 + function adaptNeutralString(source: Oklch, mode: ColorMode): string { 303 + return formatOklch(gamutMapOklch({ c: source.c, h: source.h, l: NEUTRAL_LIGHTNESS[mode] })); 304 + } 305 + 306 + /** 307 + * Resolves a feedback/focus role used verbatim. A single string is used for both modes; an explicit 308 + * side is used directly; an omitted side or role falls back to the curated mode default. 309 + */ 310 + function resolveVerbatimRole( 311 + input: ColorInput | undefined, 312 + mode: ColorMode, 313 + fallback: string, 314 + ): string { 315 + if (input === undefined) return fallback; 316 + if (typeof input === 'string') return input; 317 + return sideOf(input, mode) ?? fallback; 318 + } 319 + 320 + /** Returns the explicit side of a `ColorInput`, or `undefined` when it is a string or omitted. */ 321 + function sideOf(input: ColorInput, mode: ColorMode): string | undefined { 322 + return typeof input === 'string' ? undefined : input[mode]; 323 + } 324 + 325 + /** 326 + * Adapts a single-value accent for one mode: preserve the source hue and chroma, then search the 327 + * vibrant band for a lightness whose whole solid trio (`solid`/`solidHover`/`solidPressed`) clears 328 + * the on-solid contrast gate. Returns the lightness nearest the mode target, and throws when no 329 + * lightness in the band is accessible. 330 + */ 331 + function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { 332 + const target = ACCENT_TARGET[mode]; 333 + const [low, high] = ACCENT_BAND[mode]; 334 + const makeSolid = (l: number) => gamutMapOklch({ c: source.c, h: source.h, l }); 335 + const passes = (l: number) => onSolidGatePasses(mode, makeSolid(l)); 336 + 337 + if (passes(target)) return makeSolid(target); 338 + 339 + let best: number | null = null; 340 + let bestDistance = Number.POSITIVE_INFINITY; 341 + for (let l = low; l <= high + 1e-9; l += ACCENT_SEARCH_STEP) { 342 + const candidate = clampUnit(l); 343 + if (!passes(candidate)) continue; 344 + const distance = Math.abs(candidate - target); 345 + if (distance < bestDistance) { 346 + bestDistance = distance; 347 + best = candidate; 348 + } 349 + } 350 + if (best === null) { 351 + throw new Error( 352 + `Theme accent "${raw}" has no accessible ${mode} lightness: no vibrant lightness lets ` + 353 + 'near-white or near-black on-solid text clear 4.5:1 across the solid, hover, and pressed ' + 354 + 'states. Author an explicit { light, dark } accent instead.', 355 + ); 356 + } 357 + return makeSolid(best); 358 + } 359 + 360 + /** 361 + * Whether the on-solid gate passes for an accent solid: the near-white or near-black on-solid text 362 + * `buildTheme` would choose clears 4.5:1 against the solid, hover, and pressed states. Mirrors 363 + * `buildIntentKit` + `chooseOnSolid` in build-theme so the search agrees with build-time validation. 364 + */ 365 + function onSolidGatePasses(mode: ColorMode, solid: Oklch): boolean { 366 + const hoverDirection = mode === 'light' ? -1 : 1; 367 + const solids = [ 368 + solid, 369 + gamutMapOklch({ ...solid, l: clampUnit(solid.l + 0.05 * hoverDirection) }), 370 + gamutMapOklch({ ...solid, l: clampUnit(solid.l + 0.09 * hoverDirection) }), 371 + ]; 372 + const nearWhite = gamutMapOklch({ c: 0, h: solid.h, l: 0.985 }); 373 + const nearBlack = gamutMapOklch({ c: 0.01, h: solid.h, l: 0.18 }); 374 + const whiteMinimum = Math.min(...solids.map((state) => contrastRatio(nearWhite, state))); 375 + const blackMinimum = Math.min(...solids.map((state) => contrastRatio(nearBlack, state))); 376 + return Math.max(whiteMinimum, blackMinimum) >= TEXT_RATIO; 377 + } 378 + 379 + /** Generates the radius scale from `base`/`multiplier`, with explicit per-step overrides winning. */ 380 + function resolveRadius(input: ThemeInput): NonNullable<ThemeFoundation['radius']> { 381 + const radius = input.radius; 382 + const base = radius?.base ?? DEFAULT_RADIUS_BASE; 383 + const multiplier = radius?.multiplier ?? DEFAULT_RADIUS_MULTIPLIER; 384 + const generated = (step: number) => Math.round(base * step * multiplier); 385 + return { 386 + control: radius?.control ?? generated(RADIUS_STEPS.control), 387 + detail: radius?.detail ?? generated(RADIUS_STEPS.detail), 388 + overlay: radius?.overlay ?? generated(RADIUS_STEPS.overlay), 389 + surface: radius?.surface ?? generated(RADIUS_STEPS.surface), 390 + }; 391 + } 392 + 393 + function clampUnit(value: number): number { 394 + return Math.min(1, Math.max(0, value)); 395 + }
+16
packages/@luke-ui/react/src/theme/index.tsx
··· 24 24 /** One WCAG contrast failure recorded on a {@link ThemeContrastError}. */ 25 25 export type { ThemeContrastFailure } from './build-theme.js'; 26 26 27 + /** 28 + * `defineTheme(input)` is the curated authoring entry point: it normalises a small {@link ThemeInput} 29 + * (accent + neutral character, with everything else defaulting) into the per-mode foundation and 30 + * compiles it through `buildTheme`. It adapts single-value accents and neutrals per mode, generates 31 + * the radius scale, and merges optional materials over curated defaults. It throws when a 32 + * single-value accent has no accessible lightness, and otherwise throws the same 33 + * {@link ThemeContrastError} as `buildTheme`. 34 + */ 35 + export { defineTheme } from './define-theme.js'; 36 + 37 + /** The curated `defineTheme` authoring input plus its colour and material building blocks. */ 38 + export type { ColorInput, ControlFinish, DepthLadder, ThemeInput } from './define-theme.js'; 39 + 40 + /** Curated defaults `defineTheme` applies for omitted materials and scrim. */ 41 + export { defaultControlFinish, defaultDepth, defaultScrim } from './define-theme.js'; 42 + 27 43 /** The typed theme-foundation contract accepted by `buildTheme`. */ 28 44 export type { 29 45 ActionControlFinishFoundation,