diff --git a/apps/docs/content/docs/docs/authoring-a-theme.mdx b/apps/docs/content/docs/docs/authoring-a-theme.mdx index d035a0fa..1253481e 100644 --- a/apps/docs/content/docs/docs/authoring-a-theme.mdx +++ b/apps/docs/content/docs/docs/authoring-a-theme.mdx @@ -19,15 +19,20 @@ A basic theme authors an accent colour and a neutral character. Everything else - optional `color.background` to separate the page canvas from the neutral family's hue and chroma character. It defaults to the resolved neutral canvas anchor. - optional `color.info`, `color.success`, `color.warning`, `color.danger`, `color.focus`, and - `color.scrim` + `color.backdrop` - optional `typography`, `radius`, `depth`, and `actionControlFinish` overrides Each colour accepts one string, adapted independently for light and dark, or an explicit `{ light, dark }` pair. Omit either side to fall back to that role's default. Luke UI supplies -accessible mode-specific defaults for every optional colour. `radius` is a generative `base` and -`multiplier` scale with explicit per-step overrides. `depth` and `actionControlFinish` are optional -and deep-partial per mode over curated, extremely-subtle defaults. The typography styles and motion -values are source-owned and are not authored. +accessible mode-specific defaults for every optional colour. + +`color.backdrop` is the one colour that may include alpha. Luke UI emits it as +`color.overlay.backdrop`. Hover and pressed colours are generated from the resting semantic colours. +Do not author them. + +`radius` is a generative `base` and `multiplier` scale with explicit per-step overrides. `depth` and +`actionControlFinish` are optional and deep-partial per mode over curated, extremely-subtle +defaults. The typography styles and motion values are source-owned and are not authored. @@ -97,12 +102,12 @@ some only, and does not check one category. Every text/surface pair the compiler emits is a hard gate: -- `color.text.primary` and `color.text.secondary` against all four elevation surfaces (`canvas`, +- `color.text.primary` and `color.text.secondary` against the elevation surfaces (`canvas`, `recessed`, `floating`, `overlay`). - For each of the six semantic roles (`neutral`, `accent`, `info`, `success`, `warning`, `danger`), - its foreground `rest` and `hover` against `canvas`, `recessed`, and that role's own `subtle` rest, - hover, and pressed backgrounds. -- Each role's `onSolid` foreground against its `solid` rest, hover, and pressed backgrounds. + its rest, hover, and pressed foreground against `canvas`, `recessed`, and that role's own subtle + rest, hover, and pressed backgrounds. +- Each role's `onSolid` foreground against its solid rest, hover, and pressed backgrounds. ### Guaranteed at 3:1 @@ -120,7 +125,7 @@ combination. `color.border.neutral` and `color.border.accent`. This applies to all six roles, but only as an advisory diagnostic. `defineTheme` records and reports a miss, but never throws it. -These borders use the same subtle scale step as ordinary separators. An author can then choose a +These borders use the same subtle family rung as ordinary separators. An author can then choose a neutral character whose role borders read as gentle separators rather than assertive boundaries. Diagnostics record whether each check is a hard gate or advisory-only (`ContrastCheck.hard`). Tooling can then tell the two apart without a guess from the token path. diff --git a/apps/docs/content/docs/docs/color.mdx b/apps/docs/content/docs/docs/color.mdx index 4178bc55..e7693986 100644 --- a/apps/docs/content/docs/docs/color.mdx +++ b/apps/docs/content/docs/docs/color.mdx @@ -11,13 +11,14 @@ mode. A component then stays consistent, because it does not need to know the un Surface roles describe where an element sits in the interface: -- `canvas`: the page. -- `recessed`: an inset area. -- `floating`: elevated UI, such as menus and cards. -- `overlay`: dialogs and other high-elevation surfaces. +- `color.surface.canvas`: the page. +- `color.surface.recessed`: an inset area. +- `color.surface.floating`: elevated UI, such as menus and cards. +- `color.surface.overlay`: dialogs and other high-elevation surfaces. This is an opaque colour. -`scrim` dims the page behind an open overlay. Text uses `primary` and `secondary` roles. Borders -distinguish decorative, control, and focus uses. +`color.overlay.backdrop` dims content behind a modal. It is not a surface colour. + +Text uses `primary` and `secondary` roles. Borders distinguish decorative, control, and focus uses. ```tsx import { vars } from '@luke-ui/react/theme'; @@ -54,33 +55,35 @@ stays constant, so only the role and mode change its colours. ### Background and foreground -Each role has a `subtle` and a `solid` background ramp, and each ramp has `rest`, `hover`, and -`pressed` states: +Each role has a `subtle` and a `solid` background ramp. Each ramp has `rest`, `hover`, and `pressed` +states: ```tsx import { vars } from '@luke-ui/react/theme'; vars.color.background.warning.subtle.rest; +vars.color.background.warning.subtle.hover; +vars.color.background.warning.subtle.pressed; +vars.color.background.warning.solid.rest; vars.color.background.warning.solid.hover; +vars.color.background.warning.solid.pressed; ``` -`rest` is an explicit state rather than an implied default. A token path cannot be both a string -leaf and the parent of `hover` and `pressed`, so resting values need their own name. `solid.pressed` -deliberately reuses the `solid.hover` colour, because there is no third, deeper solid rung. A -component carries a pressed look through depth, finish, or a transform. It does not invent another -solid colour. - -Foreground gives each role a resting and a stronger interactive colour, plus a colour guaranteed to -read against that role's solid backgrounds: +Foreground gives each role rest, hover, and pressed colours, plus a colour guaranteed to read +against that role's solid backgrounds: ```tsx vars.color.foreground.warning.rest; vars.color.foreground.warning.hover; +vars.color.foreground.warning.pressed; vars.color.foreground.warning.onSolid; ``` -There is no foreground `pressed`. The background ramp and other cues carry a pressed look instead, -so text and icons reuse `hover` for a pressed control. +`onSolid` is a single token, not a state ramp. It must read against `solid.rest`, `solid.hover`, and +`solid.pressed`. + +Built-in controls select these tokens for hover and pressed. Custom interactive elements should do +the same. ### Borders @@ -126,8 +129,8 @@ CSS variable names followed the same shape, so `--luke-color-intent-danger-surfa became `--luke-color-background-danger-solid-hover`. The current contract also carries capabilities `color.intent` never had. It adds a foreground and -border for `neutral`. It also adds interactive backgrounds, hover foregrounds, and on-solid -foregrounds for `info`, `success`, and `warning`. +border for `neutral`. It also adds interactive backgrounds, hover and pressed foregrounds, and +on-solid foregrounds for `info`, `success`, and `warning`. ## Continue learning diff --git a/apps/docs/src/lib/token-purpose-groups.test.ts b/apps/docs/src/lib/token-purpose-groups.test.ts index eeeb5884..a7c272d1 100644 --- a/apps/docs/src/lib/token-purpose-groups.test.ts +++ b/apps/docs/src/lib/token-purpose-groups.test.ts @@ -44,7 +44,8 @@ test('splits the colour family across the purposes it serves', () => { ); expect(purposeOf.get('color.surface.canvas')).toBe('surfaces'); - expect(purposeOf.get('color.scrim')).toBe('surfaces'); + expect(purposeOf.get('color.surface.overlay')).toBe('surfaces'); + expect(purposeOf.get('color.overlay.backdrop')).toBe('surfaces'); expect(purposeOf.get('color.text.secondary')).toBe('content'); expect(purposeOf.get('color.loadingSkeleton')).toBe('content'); expect(purposeOf.get('color.border.focus')).toBe('borders'); diff --git a/apps/docs/src/lib/token-purpose-groups.ts b/apps/docs/src/lib/token-purpose-groups.ts index 207cdfe2..302fad45 100644 --- a/apps/docs/src/lib/token-purpose-groups.ts +++ b/apps/docs/src/lib/token-purpose-groups.ts @@ -22,7 +22,8 @@ export interface TokenPurposeGroup { */ const PURPOSE_DEFINITIONS = [ { - description: 'Background layers, from the page canvas to the dimming layer behind an overlay.', + description: + 'Background layers, from the page canvas to the translucent backdrop behind a dialog.', id: 'surfaces', related: { label: 'Colour', splat: 'color' }, showSamples: true, @@ -126,7 +127,7 @@ const COLOR_SECTION_PURPOSES: Record = { background: 'roles', foreground: 'roles', loadingSkeleton: 'content', - scrim: 'surfaces', + overlay: 'surfaces', surface: 'surfaces', text: 'content', }; @@ -136,8 +137,10 @@ const STRUCTURAL_BORDERS = new Set(['control', 'decorative', 'focus']); function resolveColorPurpose(path: string): TokenPurposeId | undefined { const [, section, leaf] = path.split('.'); if (section === undefined) return undefined; - if (section !== 'border') return COLOR_SECTION_PURPOSES[section]; - return leaf !== undefined && STRUCTURAL_BORDERS.has(leaf) ? 'borders' : 'roles'; + if (section === 'border') { + return leaf !== undefined && STRUCTURAL_BORDERS.has(leaf) ? 'borders' : 'roles'; + } + return COLOR_SECTION_PURPOSES[section]; } function resolvePurpose(token: ThemeToken): TokenPurposeId | undefined { diff --git a/apps/docs/src/styles/app.css b/apps/docs/src/styles/app.css index 40c5f517..b477897a 100644 --- a/apps/docs/src/styles/app.css +++ b/apps/docs/src/styles/app.css @@ -22,17 +22,17 @@ code > * { --color-fd-muted-foreground: var(--luke-color-text-secondary); --color-fd-popover: var(--luke-color-surface-floating); --color-fd-popover-foreground: var(--luke-color-text-primary); - --color-fd-card: var(--luke-color-surface-resting); + --color-fd-card: var(--luke-color-surface-canvas); --color-fd-card-foreground: var(--luke-color-text-primary); --color-fd-border: var(--luke-color-border-decorative); --color-fd-primary: var(--luke-color-background-accent-solid-rest); --color-fd-primary-foreground: var(--luke-color-foreground-accent-on-solid); - --color-fd-secondary: var(--luke-color-surface-raised); + --color-fd-secondary: var(--luke-color-surface-recessed); --color-fd-secondary-foreground: var(--luke-color-text-primary); --color-fd-accent: var(--luke-color-background-neutral-subtle-hover); --color-fd-accent-foreground: var(--luke-color-text-primary); --color-fd-ring: var(--luke-color-border-focus); - --color-fd-overlay: var(--luke-color-surface-overlay); + --color-fd-overlay: var(--luke-color-overlay-backdrop); } /* Keep Fumadocs' 28px DocsTitle with Luke UI's matching 36px line height */ diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 3d529782..cbb35cc6 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -50,13 +50,10 @@ Keep the detail that changes the reader's code: - `Button` sizes a nested `Icon`, so an icon needs no `size` prop. - A field component takes no plain `ref`, so `inputRef` is the only way to reach the control. -- `solid.pressed` reuses the `solid.hover` colour, so a custom pressed state needs depth, finish, or - a transform. Cut the detail that only explains the mechanism: - `Icon` renders an `` that references a symbol in the generated spritesheet. -- A token path cannot be both a string leaf and the parent of `hover` and `pressed`. - Which internal modules a component imports. ### Other technologies diff --git a/docs/STYLING.md b/docs/STYLING.md index 23cbb1b9..2dcca158 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -45,8 +45,9 @@ with no class and no JS required. Neither step injects styles at runtime. `Icon` owns its box, and `IconSizeProvider` (`FIELD_CONTROL_ICON_SIZE`) owns its per-size step — and gives the `suffix` slot the same `order: 1` for the same Spectrum ordering. Combobox's control is not a plain `Group` with that state to hand, so it stays CSS-driven. -- `overlays/mobile-overlay.css.ts`: the scrim, tray, and dialog styles `MobileOverlay` renders for - the mobile combobox tray, based on Apache-2.0 React Spectrum's `Tray.tsx` and `tray/index.css`. +- `overlays/mobile-overlay.css.ts`: the backdrop, tray, and dialog styles `MobileOverlay` renders + for the mobile combobox tray, based on Apache-2.0 React Spectrum's `Tray.tsx` and + `tray/index.css`. - `overlays/`: the private mobile tray plumbing. `mobile-overlay.tsx` wraps React Aria's `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. @@ -58,10 +59,10 @@ with no class and no JS required. Neither step injects styles at runtime. - `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 one resolution of curated defaults (source colours, materials, radius, scrim) into the + and the one resolution of curated defaults (source colours, materials, radius, backdrop) 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 + with generator source colours as OKLCH and CSS-text values such as backdrop 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 @@ -69,8 +70,9 @@ with no class and no JS required. Neither step injects styles at runtime. - `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. + constrained step-9 solid-anchor search and `passesOnSolidGate`. Semantic consumers read named + rungs via `FAMILY_RUNG`. See [THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for + interaction-state generation. - `theme/motion.ts`: the private ordinal duration scale (`MOTION_DURATION_SCALE`) behind the public `motion.duration` roles in `token-values.ts`. It is resolved in TypeScript and never emitted, so no `--luke-motion-duration-*` custom property exists. @@ -81,7 +83,7 @@ with no class and no JS required. Neither step injects styles at runtime. - `theme/elevation.ts`: the mode-aware elevation surface generator (`generateSurfaces`), where `surfaces.canvas` is always exactly the resolved `background`. - `theme/semantic-map.ts`: the one default mapping (`mapSemanticColors`) from generated families and - surfaces onto the colour contract's leaves. + surfaces onto the colour contract's leaves, including generated hover and pressed states. - `theme/diagnostics.ts`: the `compileTheme` diagnostics data model (family, surface, solid-anchor, and contrast-check detail) consumed by the "Theme/Diagnostics" Storybook story. - `theme/token-board.tsx`: the contract-driven "Theme/Token board" Storybook story, which renders diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index bf7d648a..a34ea14c 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -11,18 +11,20 @@ Per colour mode, `compileTheme` (in `build-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 - slots, and every role's solid clears 4.5:1 against on-solid text (see `SEMANTIC_ROLES` in - `contrast-policy.ts`). +2. Computes `text.primary` from the resolved neutral source (`highContrastText`, the family's + step-12 rung) before any solid-anchor search, then generates the six private 12-step OKLCH + families (`neutral`, `accent`, `info`, `success`, `warning`, `danger`). Every family is + accessibility-gated against that same `text.primary`. 3. Derives the mode-aware elevation surfaces (`canvas`/`recessed`/`floating`/`overlay`) with `elevation.ts`'s `generateSurfaces`. `surfaces.canvas` is always exactly the resolved `background` — canvas IS the background, not a derived value. 4. Applies the one default semantic mapping (`semantic-map.ts`'s `mapSemanticColors`) that aliases - every colour contract leaf onto a family step or a generated surface. + every colour contract leaf onto a family step, a generated surface, or a generated interaction + state, and passes the authored backdrop through. Neutral step 12 becomes `text.primary`. 5. Runs the full WCAG 2.2 validation matrix (`validateContrast`), which stays authoritative and - throws `ThemeContrastError` on a hard-gate miss. + throws `ThemeContrastError` on a hard-gate miss. Validation measures semantic token + relationships, including each role's rest, hover, and pressed foregrounds against its subtle + ramp, and `onSolid` against solid rest, hover, and pressed. `compileTheme` returns `{ css, diagnostics }`; `ThemeDiagnostics` records everything the pipeline resolved (both modes' families, surfaces, solid-anchor search, and contrast checks) for tooling. The @@ -92,8 +94,8 @@ indicator. Advisory borders are not guaranteed to be visible enough on their own Accent adaptation is forgiving but never sacrifices the AA on-solid guarantee: - A single-value accent (`accent: '#...'`) is pre-adapted by `defineTheme`'s `adaptAccent` into an - accessible vibrant band before the scale generator sees it, so the common case never throws at - build time. + accessible vibrant band before the scale generator sees it. The pre-conditioner calls the same + `passesOnSolidGate` function the generator uses, with the same `text.primary`. - An explicit per-mode accent (`accent: { light, dark }`) is used verbatim because the author has chosen its lightness. If its whole tone band has no lightness where near-white or near-black on-solid text clears AA, `compileTheme` throws `ThemeGenerationError` naming the failing role and @@ -103,35 +105,41 @@ Accent adaptation is forgiving but never sacrifices the AA on-solid guarantee: `scale.ts`'s `passesOnSolidGate` is the only function that decides whether a solid can carry readable text. It asks whether the near-white or near-black on-solid colour the generator would -choose clears the AA text ratio plus the search headroom across both solid states the engine emits: -step 9 and its step-10 hover. There is no third, deeper pressed state to test. -`background..solid.pressed` reuses step 10 and carries the press through depth, finish, and -transform instead. +choose clears the AA text ratio plus the search headroom across the public solid rest, hover, and +pressed colours. `defineTheme`'s `adaptAccent` pre-conditioner calls that same function rather than keeping its own -copy. That gives two guarantees: - -- Any lightness the pre-conditioner accepts also passes the generator's solid-anchor search. -- The generator emits the accent the pre-conditioner picks. The search honours it verbatim instead - of quietly resolving a different lightness. +copy. Any lightness it accepts also passes the generator's solid-anchor search, and the generator +emits the accent it picks. 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. Both searches walk the same lightness candidate grid, -so a lightness the pre-conditioner accepts is one the solid-anchor search will visit too. +generator alone would report as unsatisfiable. Both searches walk the same lightness candidate grid. `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. `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. +semantic map, and the validation matrix. ## `loadingSkeleton` `color.loadingSkeleton` maps to the neutral family's step 8, for better perceptibility of the loading state against typical surfaces. +## Interaction states + +Hover and pressed colours are public semantic tokens, generated at theme compile time. Hover mixes +the resting colour 5% toward `text.primary` in OKLab. Pressed mixes it 10% toward the same target. +Those strengths are fixed. If a solid cannot carry readable on-solid text across the three states, +the solid-anchor search adapts the resting colour rather than changing the mix strengths. + +Recipes select those tokens. They do not emit `color-mix()` or call a runtime helper. Theme authors +do not author hover or pressed colours. There is no public overlay hover, pressed, or tint API. +`color.overlay.backdrop` is the authored modal dimming layer; `color.surface.overlay` is the opaque +high-elevation surface. + +`validateContrast` measures the emitted token pairs. It does not catalogue first-party components. + ## Alpha is deferred The private scale intentionally has no alpha (transparent) track yet. Adding one later is diff --git a/packages/@luke-ui/react/src/link/recipe.css.ts b/packages/@luke-ui/react/src/link/recipe.css.ts index 9e53005a..b30e0ae1 100644 --- a/packages/@luke-ui/react/src/link/recipe.css.ts +++ b/packages/@luke-ui/react/src/link/recipe.css.ts @@ -68,21 +68,19 @@ export const linkRecipe = recipe({ '&[data-hovered="true"]:not([data-disabled="true"])': { color: vars.color.foreground.accent.hover, }, - // Press reuses the hover foreground: the shared contract carries no separate pressed - // content colour, so the stronger hover value covers both interactive states. '&[data-pressed="true"]:not([data-disabled="true"])': { - color: vars.color.foreground.accent.hover, + color: vars.color.foreground.accent.pressed, }, }, }, neutral: { - color: vars.color.text.secondary, + color: vars.color.foreground.neutral.rest, selectors: { '&[data-hovered="true"]:not([data-disabled="true"])': { - color: vars.color.text.primary, + color: vars.color.foreground.neutral.hover, }, '&[data-pressed="true"]:not([data-disabled="true"])': { - color: vars.color.text.primary, + color: vars.color.foreground.neutral.pressed, }, }, }, diff --git a/packages/@luke-ui/react/src/overlays/mobile-overlay.css.ts b/packages/@luke-ui/react/src/overlays/mobile-overlay.css.ts index e211882f..fed41a23 100644 --- a/packages/@luke-ui/react/src/overlays/mobile-overlay.css.ts +++ b/packages/@luke-ui/react/src/overlays/mobile-overlay.css.ts @@ -34,7 +34,7 @@ const trayExitTransition = overlayExitTransition(['opacity', 'translate']); /** Based on Apache-2.0 React Spectrum `Tray.tsx` and `tray/index.css`. */ export const mobileOverlay = styleInLayer('recipes', { - backgroundColor: vars.color.scrim, + backgroundColor: vars.color.overlay.backdrop, blockSize: '100dvh', insetInline: 0, position: 'absolute', diff --git a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts index 0970f22a..a29dd7b0 100644 --- a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts +++ b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts @@ -370,12 +370,15 @@ const comboboxConfig = { cursor: 'not-allowed', opacity: vars.interaction.disabledOpacity, }, - '&[data-focused="true"]:not([data-disabled="true"])': { + '&[data-focused="true"]:not([data-disabled="true"]):not([data-selected="true"])': { backgroundColor: vars.color.background.neutral.subtle.rest, }, - '&[data-hovered="true"]:not([data-disabled="true"])': { + '&[data-hovered="true"]:not([data-disabled="true"]):not([data-selected="true"])': { backgroundColor: vars.color.background.neutral.subtle.hover, }, + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-selected="true"])': { + backgroundColor: vars.color.background.neutral.subtle.pressed, + }, '&[data-focus-visible="true"]:not([data-disabled="true"])': { backgroundColor: vars.color.background.accent.subtle.hover, }, @@ -383,6 +386,12 @@ const comboboxConfig = { backgroundColor: vars.color.background.accent.subtle.rest, fontWeight: vars.font.weight.label, }, + '&[data-hovered="true"][data-selected="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.background.accent.subtle.hover, + }, + '&[data-pressed="true"][data-selected="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.background.accent.subtle.pressed, + }, '&[data-selected="true"][data-focus-visible="true"]:not([data-disabled="true"])': { backgroundColor: vars.color.background.accent.subtle.pressed, }, diff --git a/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts b/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts index 5a4533c9..986ec2f9 100644 --- a/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts +++ b/packages/@luke-ui/react/src/theme/__fixtures__/radix-scales.ts @@ -337,19 +337,20 @@ export const HUE_STRESS_CORPUS: ReadonlyArray = [ ]; /** - * Dead-zone sources: mid-lightness colours where neither near-white nor near-black on-solid text - * clears AA across the solid and its hover, and whose authored tone the generator preserves. A - * A source-toned role given one of these is genuinely unsatisfiable and throws. + * Dead-zone source: a mid-lightness colour whose whole tone-faithful window fails the on-solid gate + * for a source-toned role. */ -export const UNSATISFIABLE_ON_SOLID: Record<'light' | 'dark', CorpusEntry> = { - dark: { - name: 'dead-zone-blue-dark', - note: 'mid lightness; whole tone window is a dead zone', - source: 'oklch(0.55 0.2 258)', - }, - light: { - name: 'dead-zone-red-light', - note: 'mid lightness; whole tone window is a dead zone', - source: 'oklch(0.62 0.19 27)', - }, +export const UNSATISFIABLE_ON_SOLID: CorpusEntry = { + name: 'dead-zone-red-dark', + note: 'mid lightness and high chroma; whole tone window is a dead zone', + source: 'oklch(0.58 0.28 0)', +}; + +/** + * Light-mode mid-tone the solid-anchor search adapts into its tone-faithful window. + */ +export const ADAPTABLE_MID_TONE: CorpusEntry = { + name: 'mid-tone-red-light', + note: 'light-mode mid lightness the solid-anchor search adapts', + source: 'oklch(0.62 0.19 27)', }; 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 2377a3b4..fe442b4c 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; +import { UNSATISFIABLE_ON_SOLID } from './__fixtures__/radix-scales.js'; import { extractValue, paperFoundation, @@ -7,9 +8,17 @@ import { tactileFoundation, } from './__fixtures__/theme-css.js'; import { buildTheme, compileTheme, ThemeGenerationError } from './build-theme.js'; -import { contrastRatio, parseColor } from './color.js'; +import { contrastRatio, formatOklch, parseColor } from './color.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; +import { normalizeTheme } from './define-theme.js'; import type { ThemeFoundation } from './foundation.js'; +import { + FAMILY_RUNG, + highContrastText, + INTERACTION_HOVER_STRENGTH, + INTERACTION_PRESSED_STRENGTH, + mixInteractionState, +} from './scale.js'; describe('buildTheme independent modes', () => { it('derives each mode from its own sources rather than inverting light', () => { @@ -58,18 +67,21 @@ describe('buildTheme generation failures', () => { it('throws ThemeGenerationError naming the role, mode, and achieved ratio for a dead-zone warning', () => { const error = buildGenerationError({ ...tactileFoundation, - light: { - ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, warning: resolvedColor('oklch(0.62 0.19 27)') }, + dark: { + ...tactileFoundation.dark, + color: { + ...tactileFoundation.dark.color, + warning: resolvedColor(UNSATISFIABLE_ON_SOLID.source), + }, }, name: 'bad-warning', }); expect(error.role).toBe('warning'); - expect(error.mode).toBe('light'); + expect(error.mode).toBe('dark'); expect(error.bestAttempt.step).toBe(9); expect(error.bestAttempt.onSolidRatio).toBeLessThan(4.5); - expect(error.message).toContain('Cannot generate the light "warning" family'); + expect(error.message).toContain('Cannot generate the dark "warning" family'); expect(error.message).toContain(`${error.bestAttempt.onSolidRatio.toFixed(2)}:1`); // Warning is generated fifth, so the four roles before it are reported and the last one is not. expect(Object.keys(error.diagnostics.completedFamilies)).toEqual([ @@ -83,15 +95,13 @@ describe('buildTheme generation failures', () => { it('throws ThemeGenerationError for an accent no on-solid text can sit on', () => { const caught = (() => { try { - // A mid-lightness tone whose whole solid window is an on-solid dead zone: neither near-white - // nor near-black on-solid text clears AA anywhere the search can reach. buildTheme({ ...tactileFoundation, - light: { - ...tactileFoundation.light, + dark: { + ...tactileFoundation.dark, color: { - ...tactileFoundation.light.color, - accent: resolvedColor('oklch(0.62 0.19 27)'), + ...tactileFoundation.dark.color, + accent: resolvedColor(UNSATISFIABLE_ON_SOLID.source), }, }, name: 'bad-accent', @@ -104,12 +114,11 @@ describe('buildTheme generation failures', () => { expect(caught).toBeInstanceOf(ThemeGenerationError); const error = caught as ThemeGenerationError; expect(error.role).toBe('accent'); - expect(error.mode).toBe('light'); + expect(error.mode).toBe('dark'); expect(error.bestAttempt.step).toBe(9); expect(error.bestAttempt.onSolidRatio).toBeLessThan(4.5); - // The partial diagnostics carry the failing role/mode and the families resolved before it. expect(error.diagnostics.role).toBe('accent'); - expect(error.diagnostics.mode).toBe('light'); + expect(error.diagnostics.mode).toBe('dark'); expect(error.diagnostics.completedFamilies.neutral).toBeDefined(); expect(error.diagnostics.completedFamilies.accent).toBeUndefined(); }); @@ -123,9 +132,7 @@ describe('compileTheme diagnostics', () => { const modeDiagnostics = diagnostics[mode]; expect(modeDiagnostics.mode).toBe(mode); // A family diagnostic per role, and every scale role generated. - expect(Object.keys(modeDiagnostics.families).sort()).toEqual( - ['accent', 'danger', 'info', 'neutral', 'success', 'warning'].sort(), - ); + expect(Object.keys(modeDiagnostics.families).sort()).toEqual([...SEMANTIC_ROLES].sort()); expect(modeDiagnostics.families.accent.solidAnchor.satisfied).toBe(true); // The canvas surface equals the resolved background anchor. expect(modeDiagnostics.surfaces.canvas).toBeDefined(); @@ -141,6 +148,43 @@ describe('compileTheme diagnostics', () => { }); }); +describe('compileTheme interaction source', () => { + const chromaticNeutralFoundation = normalizeTheme({ + color: { + accent: '#3b82f6', + neutral: { dark: 'oklch(0.22 0.03 70)', light: 'oklch(0.985 0.03 70)' }, + }, + name: 'chromatic-neutral', + }); + + it('gates every family against the emitted neutral text.primary, including the neutral family', () => { + const { css, diagnostics } = compileTheme(chromaticNeutralFoundation); + const blocks = splitBlocks(css); + + for (const mode of ['light', 'dark'] as const) { + const block = mode === 'light' ? blocks.baseLight : blocks.mediaDark; + const textPrimary = diagnostics[mode].families.neutral.family[FAMILY_RUNG.textPrimary]; + expect(textPrimary).toEqual( + highContrastText(chromaticNeutralFoundation[mode].color.neutral, mode), + ); + expect(extractValue(block, '--luke-color-text-primary')).toBe(formatOklch(textPrimary)); + + for (const role of SEMANTIC_ROLES) { + const familyDiagnostics = diagnostics[mode].families[role]; + const solid = familyDiagnostics.family[FAMILY_RUNG.solid]; + const hover = mixInteractionState(solid, textPrimary, INTERACTION_HOVER_STRENGTH); + const pressed = mixInteractionState(solid, textPrimary, INTERACTION_PRESSED_STRENGTH); + expect(extractValue(block, `--luke-color-background-${role}-solid-hover`)).toBe( + formatOklch(hover), + ); + expect(extractValue(block, `--luke-color-background-${role}-solid-pressed`)).toBe( + formatOklch(pressed), + ); + } + } + }); +}); + describe('bundled themes meet WCAG 2.2 AA', () => { for (const foundation of [tactileFoundation, paperFoundation]) { // `validateContrast` in contrast-validation.ts already hard-gates text-vs-surface contrast @@ -175,14 +219,8 @@ describe('bundled themes meet WCAG 2.2 AA', () => { }); it(`${foundation.name} keeps dark accent subtle-hover legible for primary text`, () => { - // The subtle component surfaces (scale steps 3-5) ramp from the canvas independently of the - // elevation surfaces and aren't pinned apart from `floating`; what matters is that primary - // text stays legible on the hovered subtle surface. The neutral subtle hover is - // excluded here because that exact colour pair is already hard-gated under different names: - // `color.text.primary` and `color.foreground.neutral.hover` both alias neutral step 12, and - // `validateContrast` gates the latter against all three neutral subtle states at >=4.5:1. - // No hard-gated pair covers primary text on the *accent* subtle ramp, so that is the pair - // worth recomputing. + // Subtle rest ramps from the canvas independently of the elevation surfaces. Primary text + // on generated accent subtle-hover is not a hard-gated pair, so recompute it here. const { mediaDark } = splitBlocks(buildTheme(foundation)); const textPrimary = parseColor(extractValue(mediaDark, '--luke-color-text-primary')); const subtleHover = parseColor( diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 31a55ef2..287ac7f4 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -14,7 +14,7 @@ import { generateSurfaces } from './elevation.js'; import type { ThemeInheritance } from './extend-theme.js'; import type { ThemeFoundation, ThemeModeFoundation } from './foundation.js'; import type { FamilyRole, ScaleFamily } from './scale.js'; -import { generateFamilyWithDiagnostics, ScaleGenerationError } from './scale.js'; +import { generateFamilyWithDiagnostics, highContrastText, ScaleGenerationError } from './scale.js'; import type { SemanticColorValues } from './semantic-map.js'; import { mapSemanticColors } from './semantic-map.js'; import { assembleStylesheet } from './stylesheet.js'; @@ -187,12 +187,14 @@ function buildModeColors(mode: ColorMode, modeFoundation: ThemeModeFoundation): const families = {} as Record; const familyDiagnostics = {} as Record; + const textPrimary = highContrastText(source.neutral, mode); // Generated in canonical role order, so a build that fails part-way reports the families it had - // already resolved. Every role now guarantees on-solid, so any of the six can be the one that throws. + // already resolved. Every role publishes a solid, so any of them can be the one that throws. for (const role of SEMANTIC_ROLES) { try { const generated = generateFamilyWithDiagnostics({ background: canvasAnchor, + interactionSource: textPrimary, mode, role, source: source[role], @@ -222,7 +224,7 @@ function buildModeColors(mode: ColorMode, modeFoundation: ThemeModeFoundation): controlBorder, families, focus: source.focus, - scrim: modeFoundation.color.scrim, + backdrop: modeFoundation.color.backdrop, surfaces, }); return { colorValues, familyDiagnostics, surfaces }; diff --git a/packages/@luke-ui/react/src/theme/color.test.ts b/packages/@luke-ui/react/src/theme/color.test.ts index 1fbf1ae2..45d4917c 100644 --- a/packages/@luke-ui/react/src/theme/color.test.ts +++ b/packages/@luke-ui/react/src/theme/color.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js'; +import { contrastRatio, formatOklch, gamutMapOklch, mixOklab, parseColor } from './color.js'; describe('parseColor', () => { it('round-trips a hex colour through OKLCH formatting', () => { @@ -24,6 +24,25 @@ describe('parseColor', () => { }); }); +describe('mixOklab', () => { + it('mixes equal parts of two colours toward the midpoint lightness', () => { + const white = parseColor('#ffffff'); + const black = parseColor('#000000'); + const result = mixOklab(white, black, 0.5); + expect(result.l).toBeCloseTo((white.l + black.l) / 2, 5); + expect(result.c).toBeCloseTo(0, 5); + }); + + it('returns the second colour at amount 1', () => { + const from = parseColor('#ffffff'); + const to = parseColor('#0160ae'); + const result = mixOklab(from, to, 1); + expect(result.l).toBeCloseTo(to.l, 5); + expect(result.c).toBeCloseTo(to.c, 5); + expect(result.h).toBeCloseTo(to.h, 1); + }); +}); + describe('contrastRatio', () => { it('measures white on black as 21:1', () => { expect(contrastRatio(parseColor('#ffffff'), parseColor('#000000'))).toBeCloseTo(21, 5); diff --git a/packages/@luke-ui/react/src/theme/color.ts b/packages/@luke-ui/react/src/theme/color.ts index 4d27bba4..2819eeb1 100644 --- a/packages/@luke-ui/react/src/theme/color.ts +++ b/packages/@luke-ui/react/src/theme/color.ts @@ -39,6 +39,21 @@ export function parseColor(input: string): Oklch { throw new Error(`cannot parse colour "${input}"; expected #rgb, #rrggbb, or oklch( )`); } +/** + * Mixes opaque `a` and `b` in OKLab, matching + * `color-mix(in oklab, a <100-N>%, b %)` where `amountOfB` is `N / 100`. + */ +export function mixOklab(a: Oklch, b: Oklch, amountOfB: number): Oklch { + const t = clampUnit(amountOfB); + const from = oklchToOklab(a); + const to = oklchToOklab(b); + return oklabToOklch({ + l: from.l * (1 - t) + to.l * t, + a: from.a * (1 - t) + to.a * t, + b: from.b * (1 - t) + to.b * t, + }); +} + /** * WCAG 2.2 contrast ratio between two colours, computed on their sRGB-gamut-mapped equivalents. */ @@ -115,6 +130,12 @@ function relativeLuminance(color: Oklch): number { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } +interface Oklab { + l: number; + a: number; + b: number; +} + type SrgbTriple = [number, number, number]; const HEX_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; @@ -156,13 +177,30 @@ function isInSrgbGamut(color: Oklch): boolean { ); } -function oklchToLinearSrgb(color: Oklch): SrgbTriple { +function oklchToOklab(color: Oklch): Oklab { const hueRadians = (normalizeHue(color.h) * Math.PI) / 180; - const labA = color.c * Math.cos(hueRadians); - const labB = color.c * Math.sin(hueRadians); - const lCubeRoot = color.l + 0.3963377774 * labA + 0.2158037573 * labB; - const mCubeRoot = color.l - 0.1055613458 * labA - 0.0638541728 * labB; - const sCubeRoot = color.l - 0.0894841775 * labA - 1.291485548 * labB; + return { + l: color.l, + a: color.c * Math.cos(hueRadians), + b: color.c * Math.sin(hueRadians), + }; +} + +function oklabToOklch(color: Oklab): Oklch { + const c = Math.hypot(color.a, color.b); + const h = c < 0.000001 ? 0 : normalizeHue((Math.atan2(color.b, color.a) * 180) / Math.PI); + return { + l: color.l, + c, + h, + }; +} + +function oklchToLinearSrgb(color: Oklch): SrgbTriple { + const { l, a: labA, b: labB } = oklchToOklab(color); + const lCubeRoot = l + 0.3963377774 * labA + 0.2158037573 * labB; + const mCubeRoot = l - 0.1055613458 * labA - 0.0638541728 * labB; + const sCubeRoot = l - 0.0894841775 * labA - 1.291485548 * labB; const lCone = lCubeRoot ** 3; const mCone = mCubeRoot ** 3; const sCone = sCubeRoot ** 3; @@ -181,14 +219,9 @@ function linearSrgbToOklch(rgb: SrgbTriple): Oklch { const lCubeRoot = Math.cbrt(lCone); const mCubeRoot = Math.cbrt(mCone); const sCubeRoot = Math.cbrt(sCone); - const l = 0.2104542553 * lCubeRoot + 0.793617785 * mCubeRoot - 0.0040720468 * sCubeRoot; - const labA = 1.9779984951 * lCubeRoot - 2.428592205 * mCubeRoot + 0.4505937099 * sCubeRoot; - const labB = 0.0259040371 * lCubeRoot + 0.7827717662 * mCubeRoot - 0.808675766 * sCubeRoot; - const c = Math.hypot(labA, labB); - const h = c < 0.000001 ? 0 : normalizeHue((Math.atan2(labB, labA) * 180) / Math.PI); - return { - l, - c, - h, - }; + return oklabToOklch({ + l: 0.2104542553 * lCubeRoot + 0.793617785 * mCubeRoot - 0.0040720468 * sCubeRoot, + a: 1.9779984951 * lCubeRoot - 2.428592205 * mCubeRoot + 0.4505937099 * sCubeRoot, + b: 0.0259040371 * lCubeRoot + 0.7827717662 * mCubeRoot - 0.808675766 * sCubeRoot, + }); } diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts index 17c57217..1b34428d 100644 --- a/packages/@luke-ui/react/src/theme/contract.test.ts +++ b/packages/@luke-ui/react/src/theme/contract.test.ts @@ -46,11 +46,7 @@ describe('theme contract', () => { expect(countLeaves(vars)).toBe(flattenThemeContract().length); }); - it('gives all six semantic roles the same 60 leaves under the documented variable names', () => { - // The migration table in the specification is a promise about these exact names, so they are - // 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. + it('gives every semantic role the same background, foreground, and border leaves', () => { const leaf = (path: ModePath, varName: string): [ModePath, string] => [path, varName]; const expected = SEMANTIC_ROLES.flatMap((role) => [ leaf(`color.border.${role}`, `--luke-color-border-${role}`), @@ -64,6 +60,7 @@ describe('theme contract', () => { }), leaf(`color.foreground.${role}.rest`, `--luke-color-foreground-${role}-rest`), leaf(`color.foreground.${role}.hover`, `--luke-color-foreground-${role}-hover`), + leaf(`color.foreground.${role}.pressed`, `--luke-color-foreground-${role}-pressed`), leaf(`color.foreground.${role}.onSolid`, `--luke-color-foreground-${role}-on-solid`), ]); const rolePaths = new Set(expected.map(([path]) => path)); @@ -75,7 +72,6 @@ describe('theme contract', () => { ); }); - expect(expected).toHaveLength(60); const byPath = (a: ReadonlyArray, b: ReadonlyArray) => { return (a[0] ?? '').localeCompare(b[0] ?? ''); }; @@ -114,6 +110,15 @@ describe('theme contract', () => { } }); + it('exposes overlay backdrop as the only overlay leaf, and no longer has scrim', () => { + const byPath = new Map(flattenThemeContract()); + expect(vars.color.overlay).toEqual({ + backdrop: 'var(--luke-color-overlay-backdrop)', + }); + expect(byPath.get('color.overlay.backdrop')).toBe('--luke-color-overlay-backdrop'); + expect(Object.hasOwn(vars.color, 'scrim')).toBe(false); + }); + it('defines the selected spacing steps from the 4px scale', () => { expect(spaceScale).toEqual([ ['100', '4px'], diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index 54b8a96f..94238af2 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -13,8 +13,8 @@ const typeStyle = { /** * The background capabilities every semantic role gets, spread once per role so the six roles cannot - * drift apart. `rest` is an explicit leaf because a nested tree path cannot be both a string leaf and - * the parent of `hover` and `pressed`. + * drift apart. `rest` is an explicit leaf because a nested tree path cannot be both a string leaf + * and the parent of `hover` and `pressed`. */ const roleBackground = { subtle: { @@ -30,12 +30,13 @@ const roleBackground = { }; /** - * The content capabilities every semantic role gets. There is no `pressed` foreground: press is - * carried by the background ramp and non-colour cues, so text and icons reuse `hover`. + * The content capabilities every semantic role gets. `onSolid` is a single contrast-solved colour + * for that role's solid fill, not a state ramp. */ const roleForeground = { rest: null, hover: null, + pressed: null, onSolid: null, }; @@ -138,10 +139,10 @@ export const themeContractTree = { * (`neutral`, `accent`, `info`, `success`, `warning`, `danger`). * * Organised by the property a token styles, not by the component that happens to use it: the - * functional leaves (`surface`, `scrim`, `loadingSkeleton`, `text`, and the first three `border` + * functional leaves (`surface`, `overlay`, `loadingSkeleton`, `text`, and the first three `border` * leaves) come first, then `background` / `foreground` / the role leaves under `border` give all * six roles the same capabilities. A role's meaning never decides which visual slots it can fill, - * so no role is a special case here. + * so no role is a special case here. Hover and pressed leaves are generated, not authored. */ color: { surface: { @@ -150,8 +151,10 @@ export const themeContractTree = { floating: null, overlay: null, }, - /** Modal-backdrop dimming layer behind an overlay surface. */ - scrim: null, + /** Translucent layer painted over other interface content, such as a modal backdrop. */ + overlay: { + backdrop: null, + }, loadingSkeleton: null, text: { primary: null, @@ -159,7 +162,7 @@ export const themeContractTree = { /** Dedicated muted text (form fields), not opacity. Emits `--luke-color-text-disabled`. */ disabled: null, }, - /** Subtle and solid background ramps, each with the shared rest / hover / pressed states. */ + /** Subtle and solid background ramps, each with generated rest / hover / pressed states. */ background: { neutral: { ...roleBackground }, accent: { ...roleBackground }, @@ -168,7 +171,7 @@ export const themeContractTree = { warning: { ...roleBackground }, danger: { ...roleBackground }, }, - /** Resting and stronger interactive content colours, plus the guaranteed on-solid pairing. */ + /** Resting, hover, and pressed content colours, plus the guaranteed on-solid pairing. */ foreground: { neutral: { ...roleForeground }, accent: { ...roleForeground }, diff --git a/packages/@luke-ui/react/src/theme/contrast-policy.ts b/packages/@luke-ui/react/src/theme/contrast-policy.ts index 486edd22..b719a033 100644 --- a/packages/@luke-ui/react/src/theme/contrast-policy.ts +++ b/packages/@luke-ui/react/src/theme/contrast-policy.ts @@ -43,7 +43,7 @@ export const CONTRAST_SEARCH_STEP = 0.0025; * restating a subset anywhere would reintroduce the asymmetry this module exists to prevent. * `FamilyRole` in `scale.ts` is derived from this, so the type cannot drift from the list either. * - * Every role's solid (step 9) and its hover (step 10) must clear 4.5:1 against on-solid text. The + * Every role's solid rest, hover, and pressed colours must clear 4.5:1 against on-solid text. The * scale generator always searches for that contrast. `contrast-validation.ts` enforces it for all * six roles at compile time. */ 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 122fa53f..7e3f7ec1 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vite-plus/test'; -import { paperFoundation, resolvedColor, tactileFoundation } from './__fixtures__/theme-css.js'; +import { resolvedColor, tactileFoundation } from './__fixtures__/theme-css.js'; import { buildTheme, compileTheme, ThemeContrastError } from './build-theme.js'; -import { SEMANTIC_ROLES } from './contrast-policy.js'; +import { SEMANTIC_ROLES, TEXT_RATIO, UI_RATIO } from './contrast-policy.js'; import type { ThemeFoundation } from './foundation.js'; describe('buildTheme contrast failures', () => { @@ -42,10 +42,7 @@ describe('buildTheme contrast failures', () => { ); }); - it('rejects a pathological dark-mode canvas the fixed text anchors cannot clear', () => { - // v2 pins text lightness (neutral steps 11/12) per mode, so an unworkable neutral character no - // longer produces low-contrast text; the honest failure mode is instead a canvas whose lightness - // leaves the fixed text anchors below AA. A near-white dark canvas does exactly that. + it('rejects a dark-mode canvas the fixed text anchors cannot clear', () => { const error = buildFailures({ ...tactileFoundation, dark: { @@ -90,105 +87,88 @@ describe('buildTheme contrast failures', () => { }); describe('contrast validation matrix', () => { - // The matrix is role-uniform by construction: each role contributes the same per-role hard - // and advisory counts below, plus a handful of hard checks that aren't per-role at all (see - // `validateContrast`). Deriving the totals from those pieces means adding a role, or changing - // a per-role count, updates the expectation automatically instead of needing a hand-edited - // number. - const PER_ROLE_HARD_HOVER = 5; - const PER_ROLE_HARD_ON_SOLID = 3; - const PER_ROLE_HARD_REST = 5; - const PER_ROLE_ADVISORY_BORDER = 2; + const INTERACTION_STATES = ['rest', 'hover', 'pressed'] as const; + const BASE_SURFACES = ['color.surface.canvas', 'color.surface.recessed'] as const; + const ELEVATION_SURFACES = [ + ...BASE_SURFACES, + 'color.surface.floating', + 'color.surface.overlay', + ] as const; - // Hard checks `validateContrast` runs once, not per role: functional primary/secondary text - // against the 4 elevation surfaces (8), the focus ring and `border.control` boundaries - // against the 2 base surfaces (4), and `danger.solid.rest` against the 2 base surfaces (2). - const NON_PER_ROLE_HARD_CHECKS = 8 + 4 + 2; - - const expectedHard = - SEMANTIC_ROLES.length * (PER_ROLE_HARD_HOVER + PER_ROLE_HARD_ON_SOLID + PER_ROLE_HARD_REST) + - NON_PER_ROLE_HARD_CHECKS; - const expectedAdvisory = SEMANTIC_ROLES.length * PER_ROLE_ADVISORY_BORDER; - - for (const foundation of [tactileFoundation, paperFoundation]) { - it(`measures ${expectedHard} hard and ${expectedAdvisory} advisory checks per mode for ${foundation.name}, the same for every role`, () => { - // `compileTheme` returns only once every hard gate passed, so reaching these assertions is - // itself the proof that all hard checks pass for the bundled theme. - const { diagnostics } = compileTheme(foundation); - const summary = (['light', 'dark'] as const).map((mode) => { - const checks = diagnostics[mode].contrastChecks; - const countFor = (foreground: string, hard: boolean) => { - return checks.filter((check) => check.hard === hard && check.foreground === foreground) - .length; - }; - return { - advisory: checks.filter((check) => !check.hard).length, - hard: checks.filter((check) => check.hard).length, - mode, - perRole: SEMANTIC_ROLES.map((role) => ({ - advisoryBorder: countFor(`color.border.${role}`, false), - hardHover: countFor(`color.foreground.${role}.hover`, true), - hardOnSolid: countFor(`color.foreground.${role}.onSolid`, true), - hardRest: countFor(`color.foreground.${role}.rest`, true), - role, - })), - }; + function expectedChecks() { + const hard: Array<{ background: string; foreground: string; required: number }> = []; + const advisory: Array<{ background: string; foreground: string; required: number }> = []; + for (const text of ['color.text.primary', 'color.text.secondary'] as const) { + for (const surface of ELEVATION_SURFACES) { + hard.push({ background: surface, foreground: text, required: TEXT_RATIO }); + } + } + for (const role of SEMANTIC_ROLES) { + const subtleBackgrounds = INTERACTION_STATES.map((state) => { + return `color.background.${role}.subtle.${state}`; }); - expect(summary).toEqual( - (['light', 'dark'] as const).map((mode) => ({ - advisory: expectedAdvisory, - hard: expectedHard, - mode, - perRole: SEMANTIC_ROLES.map((role) => ({ - advisoryBorder: PER_ROLE_ADVISORY_BORDER, - hardHover: PER_ROLE_HARD_HOVER, - hardOnSolid: PER_ROLE_HARD_ON_SOLID, - hardRest: PER_ROLE_HARD_REST, - role, - })), - })), - ); - }); + for (const state of INTERACTION_STATES) { + for (const background of [...BASE_SURFACES, ...subtleBackgrounds]) { + hard.push({ + background, + foreground: `color.foreground.${role}.${state}`, + required: TEXT_RATIO, + }); + } + } + for (const state of INTERACTION_STATES) { + hard.push({ + background: `color.background.${role}.solid.${state}`, + foreground: `color.foreground.${role}.onSolid`, + required: TEXT_RATIO, + }); + } + for (const background of BASE_SURFACES) { + advisory.push({ background, foreground: `color.border.${role}`, required: UI_RATIO }); + } + } + for (const background of BASE_SURFACES) { + hard.push({ background, foreground: 'color.border.focus', required: UI_RATIO }); + hard.push({ background, foreground: 'color.border.control', required: UI_RATIO }); + hard.push({ + background, + foreground: 'color.background.danger.solid.rest', + required: UI_RATIO, + }); + } + return { advisory, hard }; } - it('records on each check whether missing its ratio fails the build', () => { - // Every text pair is a hard gate, and so are the two solved boundaries `border.focus` and - // `border.control`, plus `danger.solid.rest` vs the base surfaces (the only role fill gated — - // see `validateContrast` for why the other five roles are not). The six semantic borders are the - // only advisory checks. `color.border.decorative` is not measured. The "Theme/Diagnostics" - // inspector uses this flag instead of matching token paths. + it('records the semantic hard and advisory pairs', () => { const { diagnostics } = compileTheme(tactileFoundation); - const advisoryBorders = SEMANTIC_ROLES.map((role) => `color.border.${role}`); - const summary = (['light', 'dark'] as const).map((mode) => { + const expected = expectedChecks(); + const pairKey = (check: { background: string; foreground: string; required: number }) => { + return `${check.foreground} ${check.background} ${check.required}`; + }; + for (const mode of ['light', 'dark'] as const) { const checks = diagnostics[mode].contrastChecks; - const advisory = checks.filter((check) => !check.hard); - const hard = checks.filter((check) => check.hard); - return { - advisoryForegrounds: [...new Set(advisory.map((check) => check.foreground))].sort(), - // A hard gate that missed its ratio would throw before `compileTheme` returns. A recorded - // hard check that does not pass means the flag disagrees with the compiler. - everyHardGatePasses: hard.every((check) => check.passes), - hardBoundaryForegrounds: [ - ...new Set(hard.filter((check) => check.required === 3).map((check) => check.foreground)), - ].sort(), - hardRatios: [...new Set(hard.map((check) => check.required))].sort((a, b) => a - b), - mode, - partitionsEveryCheck: hard.length + advisory.length === checks.length, - }; - }); - expect(summary).toEqual( - (['light', 'dark'] as const).map((mode) => ({ - advisoryForegrounds: [...advisoryBorders].sort(), - everyHardGatePasses: true, - hardBoundaryForegrounds: [ - 'color.background.danger.solid.rest', - 'color.border.control', - 'color.border.focus', - ].sort(), - hardRatios: [3, 4.5], - mode, - partitionsEveryCheck: true, - })), - ); + const actualHard = checks + .filter((check) => check.hard) + .map((check) => { + return { + background: check.background, + foreground: check.foreground, + required: check.required, + }; + }); + const actualAdvisory = checks + .filter((check) => !check.hard) + .map((check) => { + return { + background: check.background, + foreground: check.foreground, + required: check.required, + }; + }); + expect([...actualHard].map(pairKey).sort()).toEqual([...expected.hard].map(pairKey).sort()); + expect([...actualAdvisory].map(pairKey).sort()).toEqual( + [...expected.advisory].map(pairKey).sort(), + ); + } }); }); diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.ts b/packages/@luke-ui/react/src/theme/contrast-validation.ts index e2f43062..b4f6d71f 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.ts @@ -34,19 +34,18 @@ interface ValidationResult { 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 - * ones populate `failures` (which `compileTheme` raises as a - * {@link import('./build-theme.js').ThemeContrastError}). + * Runs the full semantic validation matrix over the emitted (rounded) colour values. Every pair is + * recorded as a {@link ContrastCheck}, and the hard ones populate `failures` (which `compileTheme` + * raises as a {@link import('./build-theme.js').ThemeContrastError}). * * Hard at the AA text ratio: functional primary and secondary text against all four elevation - * surfaces; every role's resting and hover foreground against the base surfaces and that role's own - * subtle ramp; and every role's on-solid foreground against its solid ramp. Hard at the non-text - * ratio: the authored focus ring and `border.control`, which is `control-border.ts`'s solved - * boundary rather than a scale-step alias; and `danger.solid.rest` against the base surfaces, - * because it is the only role fill that carries a required state's boundary (the invalid field - * boundary). This last gate is deliberately not extended to the other five roles: a role's solid - * anchor is solved for 4.5:1 on-solid text, not for 3:1 against the surface behind it, and for + * surfaces; every role's rest, hover, and pressed foreground against the base surfaces and that + * role's own subtle ramp; and every role's on-solid foreground against its solid ramp. Hard at the + * non-text ratio: the authored focus ring and `border.control`, which is `control-border.ts`'s + * solved boundary rather than a scale-step alias; and `danger.solid.rest` against the base + * surfaces, because it is the only role fill that carries a required state's boundary (the invalid + * field boundary). This last gate is deliberately not extended to the other five roles: a role's + * solid anchor is solved for 4.5:1 on-solid text, not for 3:1 against the surface behind it, and for * `warning` that lands at only 2.43:1 against canvas in light mode. * * The six semantic borders alias step 7 of the 12-step scale, a subtle separator that deliberately @@ -87,18 +86,17 @@ export function validateContrast( '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'] 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. + // Per role: rest, hover, and pressed foregrounds vs the base surfaces and that role's own + // subtle ramp, and the on-solid foreground vs its solid ramp. 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'] as const).map((state) => { return `color.background.${role}.subtle.${state}` as const; }); - for (const state of ['rest', 'hover'] as const) { + for (const state of ['rest', 'hover', 'pressed'] as const) { for (const background of [...basePaths, ...subtleBackgrounds]) { check(`color.foreground.${role}.${state}`, background, TEXT_RATIO, true); } @@ -113,18 +111,18 @@ export function validateContrast( } } // The keyboard-focus ring is authored and focus-visibility critical, so it stays a hard 3:1 gate, - // and `border.control` is a solved boundary held to the same ratio: 4 checks. + // and `border.control` is a solved boundary held to the same ratio. for (const background of basePaths) check('color.border.focus', background, UI_RATIO, true); for (const background of basePaths) check('color.border.control', background, UI_RATIO, true); - // `danger.solid.rest` vs the base surfaces: 2 checks. It is the only role fill that carries a - // required state's boundary (the invalid field boundary), so it is held to the same hard - // non-text ratio as the focus ring and `border.control`. This is deliberately NOT a per-role - // loop: a role's solid anchor is solved for 4.5:1 on-solid text, not for 3:1 against the surface - // behind it, and for `warning` that lands at only 2.43:1 against canvas in light mode. Extending - // this gate to the other five roles throws `ThemeContrastError` on the bundled themes. + // `danger.solid.rest` is the only role fill that carries a required state's boundary (the invalid + // field boundary), so it is held to the same hard non-text ratio as the focus ring and + // `border.control`. This is deliberately not a per-role loop: a role's solid anchor is solved for + // 4.5:1 on-solid text, not for 3:1 against the surface behind it, and for `warning` that lands at + // only 2.43:1 against canvas in light mode. Extending this gate to the other five roles throws + // `ThemeContrastError` on the bundled themes. for (const background of basePaths) check('color.background.danger.solid.rest', background, UI_RATIO, true); - // The six semantic borders, measured and reported but not gated: 12 advisory checks. + // Semantic role borders are measured and reported but not gated. for (const role of SEMANTIC_ROLES) { for (const background of basePaths) { check(`color.border.${role}`, background, UI_RATIO, false); diff --git a/packages/@luke-ui/react/src/theme/control-border.ts b/packages/@luke-ui/react/src/theme/control-border.ts index d9d4198f..ba08eeb9 100644 --- a/packages/@luke-ui/react/src/theme/control-border.ts +++ b/packages/@luke-ui/react/src/theme/control-border.ts @@ -10,6 +10,7 @@ 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'; +import { FAMILY_RUNG } from './scale.js'; type ColorMode = 'light' | 'dark'; @@ -19,7 +20,7 @@ interface SolveControlBorderRequest { canvas: Oklch; /** The colour mode being solved for. */ mode: ColorMode; - /** The generated neutral family for this mode, whose step 7 seeds the search. */ + /** The generated neutral family for this mode, whose semantic border rung seeds the search. */ neutral: ScaleFamily; /** The recessed surface the boundary is gated against. */ recessed: Oklch; @@ -27,17 +28,18 @@ interface SolveControlBorderRequest { /** * Solves `color.border.control` as a dedicated contrast boundary, rather than a subtle step-7 - * alias: neutral steps 7-8 land at roughly 1.6-2.7:1 against the base surfaces, well - * short of the 3:1 non-text gate. Starting from step 7's own lightness (its hue and a low, neutral - * chroma), the search steps in the higher-contrast direction, darker in light mode and lighter in - * dark mode, until the candidate clears 3:1 (plus headroom) against both `canvas` and `recessed`, - * gated on whichever of the two currently has the lower contrast. It stops at the first clearing - * lightness, so the result deviates from the step-7 aesthetic by the minimum needed to reach the - * boundary. Lightness is clamped to [0, 1]; a neutral hue always reaches the target within range. + * alias: the semantic border and muted rungs land at roughly 1.6-2.7:1 against the base surfaces, + * well short of the 3:1 non-text gate. Starting from {@link FAMILY_RUNG.border}'s own lightness + * (its hue and a low, neutral chroma), the search steps in the higher-contrast direction, darker in + * light mode and lighter in dark mode, until the candidate clears 3:1 (plus headroom) against both + * `canvas` and `recessed`, gated on whichever of the two currently has the lower contrast. It stops + * at the first clearing lightness, so the result deviates from the border-rung aesthetic by the + * minimum needed to reach the boundary. Lightness is clamped to [0, 1]; a neutral hue always + * reaches the target within range. */ export function solveControlBorder(params: SolveControlBorderRequest): Oklch { const { neutral, canvas, recessed, mode } = params; - const seed = neutral[7]; + const seed = neutral[FAMILY_RUNG.border]; const target = UI_RATIO + RATIO_HEADROOM; const worstRatio = (candidate: Oklch) => { return Math.min(contrastRatio(candidate, canvas), contrastRatio(candidate, recessed)); 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 919a6f47..c67c2647 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vite-plus/test'; +import { compileTheme } from './build-theme.js'; import { gamutMapOklch, parseColor } from './color.js'; import { flattenThemeContract } from './contract.js'; -import { defaultDepth, defaultScrim, defineTheme, normalizeTheme } from './define-theme.js'; +import { defaultBackdrop, defaultDepth, 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'; +import { FAMILY_RUNG } from './scale.js'; /** * Splits a generated stylesheet into its five rule blocks: identity, base light, media-query dark, @@ -110,26 +111,19 @@ describe('defineTheme accent pre-conditioning shares the generator gate', () => ]; it('hands the generator an accent the solid-anchor search honours verbatim in both modes', () => { - // The pre-conditioner gates on `passesOnSolidGate`, the same predicate the solid-anchor search - // decides on. It cannot be stricter than the solver, and the solver does not re-search the chosen - // tone: the emitted solid is the pre-conditioned accent. const resolved = accents.flatMap((accent) => { + const foundation = normalizeTheme({ color: { accent }, name: 'accent-gate' }); + const { diagnostics } = compileTheme(foundation); return (['light', 'dark'] as const).map((mode) => { - const foundation = normalizeTheme({ color: { accent }, name: 'accent-gate' }); const source = foundation[mode].color.accent; - const { diagnostics } = generateFamilyWithDiagnostics({ - background: foundation[mode].color.background, - mode, - role: 'accent', - source, - }); + const familyDiagnostics = diagnostics[mode].families.accent; return { accent, mode, - reSearched: diagnostics.solidAnchor.adaptedForOnSolid, + reSearched: familyDiagnostics.solidAnchor.adaptedForOnSolid, solidMovedOffPreconditionedTone: - Math.abs(diagnostics.family[9].l - source.l) > 1e-9 || - Math.abs(diagnostics.solidAnchor.resolvedLightness - source.l) > 1e-9, + Math.abs(familyDiagnostics.family[FAMILY_RUNG.solid].l - source.l) > 1e-9 || + Math.abs(familyDiagnostics.solidAnchor.resolvedLightness - source.l) > 1e-9, }; }); }); @@ -198,16 +192,16 @@ describe('defineTheme partial per-mode merges', () => { }); }); -describe('defineTheme scrim validation', () => { - it('rejects an unsafe authored scrim value with a message naming the field', () => { - // The scrim is deliberately excluded from OKLCH colour parsing (its alpha channel does not fit +describe('defineTheme backdrop validation', () => { + it('rejects an unsafe authored backdrop value with a message naming the field', () => { + // Backdrop is deliberately excluded from OKLCH colour parsing (its alpha channel does not fit // that pattern) and emitted verbatim, so it needs its own shape check rather than none at all. expect(() => { return defineTheme({ - color: { accent: '#3b82f6', scrim: 'oklch(0 0 0 / 0.2); } .evil {' }, - name: 'unsafe-scrim', + color: { accent: '#3b82f6', backdrop: 'oklch(0 0 0 / 0.2); } .evil {' }, + name: 'unsafe-backdrop', }); - }).toThrow('color.scrim: must be a non-empty CSS colour value'); + }).toThrow('light.color.backdrop: must be a non-empty CSS colour value'); }); }); @@ -292,7 +286,7 @@ 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', () => { + it('carries generator colours as Oklch and backdrop as CSS text, with defaults applied', () => { const foundation = normalizeTheme({ color: { accent: '#3b82f6' }, name: 'resolved-once', @@ -306,8 +300,8 @@ describe('normalizeTheme resolves source colours once onto the foundation', () = 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'); + expect(light.backdrop).toBe(defaultBackdrop.light); + expect(typeof light.backdrop).toBe('string'); }); it('keeps the adapted accent hue without a format-parse round trip', () => { @@ -333,20 +327,21 @@ describe('defineTheme emits the full contract for the bundled themes', () => { const css = defineTheme(input); const emitted = emittedVarNames(css); - it(`${name} emits exactly the contract variables, including scrim and disabled text`, () => { + it(`${name} emits exactly the contract variables, including overlay backdrop and disabled text`, () => { // Derived from the contract, not hardcoded: `contract.test.ts` already asserts the typed // `vars` tree has exactly as many leaves as `flattenThemeContract()`, so this only needs to // check that a bundled theme's emitted CSS matches that same list, not restate its length. expect(emitted.size).toBe(contractNames.length); expect([...emitted].sort()).toEqual([...contractNames].sort()); - expect(emitted.has('--luke-color-scrim')).toBe(true); + expect(emitted.has('--luke-color-overlay-backdrop')).toBe(true); + expect(emitted.has('--luke-color-overlay-hover')).toBe(false); + expect(emitted.has('--luke-color-overlay-pressed')).toBe(false); + expect(emitted.has('--luke-color-overlay-tint')).toBe(false); + expect(emitted.has('--luke-color-scrim')).toBe(false); expect(emitted.has('--luke-color-text-disabled')).toBe(true); }); it(`${name} paints info, success, and warning with a real interactive ramp`, () => { - // The three feedback roles carry the same capabilities as every other role. The contract - // inventory cannot prove that each ramp is interactive because a flat colour still fills every - // leaf. These emitted values prove that each state stays distinct. const blocks = splitBlocks(css); for (const block of [blocks.baseLight, blocks.mediaDark]) { for (const role of ['info', 'success', 'warning']) { @@ -356,15 +351,15 @@ describe('defineTheme emits the full contract for the bundled themes', () => { `--luke-color-background-${role}-subtle-pressed`, `--luke-color-background-${role}-solid-rest`, `--luke-color-background-${role}-solid-hover`, + `--luke-color-background-${role}-solid-pressed`, ].map((varName) => extractValue(block, varName)); expect(new Set(ramp).size).toBe(ramp.length); - // Solid pressed deliberately reuses solid hover: depth and finish carry the press. - expect(extractValue(block, `--luke-color-background-${role}-solid-pressed`)).toBe( - extractValue(block, `--luke-color-background-${role}-solid-hover`), - ); expect(extractValue(block, `--luke-color-foreground-${role}-rest`)).not.toBe( extractValue(block, `--luke-color-foreground-${role}-hover`), ); + expect(extractValue(block, `--luke-color-foreground-${role}-hover`)).not.toBe( + extractValue(block, `--luke-color-foreground-${role}-pressed`), + ); } } }); diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index 33bf738c..fe6bb2a6 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -2,8 +2,8 @@ * 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 one resolution of curated defaults (source colours, materials, radius, scrim) - * into {@link Oklch} values the foundation carries. + * adaptation and the one resolution of curated defaults (source colours, materials, radius, + * backdrop) into {@link Oklch} values the foundation carries. */ import { buildTheme, ThemeContrastError } from './build-theme.js'; @@ -14,13 +14,13 @@ 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'; +import { highContrastText, passesOnSolidGate } from './scale.js'; /** * A colour value: one string (adapted independently for each mode) OR a per-mode object where * EITHER side may be omitted to fall back to that role's curated default / generation. Strings - * accept `#rgb`, `#rrggbb`, or `oklch( )` (lightness 0-1 or %, no alpha), except `scrim`, - * which is used verbatim and may carry an alpha channel. + * accept `#rgb`, `#rrggbb`, or `oklch( )` (lightness 0-1 or %, no alpha), except + * `backdrop`, which is used verbatim and may carry an alpha channel. */ export type ColorInput = string | { light?: string; dark?: string }; @@ -139,7 +139,7 @@ export interface ThemeInput extends ThemeInputCommon { /** Keyboard-focus ring colour, used verbatim after gamut mapping. Defaults per mode. */ focus?: ColorInput; /** Modal-backdrop dimming colour, used verbatim; defaults to black at a mode-aware alpha. */ - scrim?: ColorInput; + backdrop?: ColorInput; }; /** * A theme to start from. Every value this theme leaves out comes from the base. `name` never @@ -179,9 +179,9 @@ const NEUTRAL_LIGHTNESS = { dark: 0.22, light: 0.985 } as const satisfies Record // The vibrant band a single-value accent is adapted into, and the lightness the search starts from. // Contrast for the on-solid text lives at the band edges (dark solids take near-white text, light // solids take near-black); the middle is a dead zone, so the search targets a vibrant lightness and -// walks outward to the nearest lightness whose solid and hover both clear the on-solid gate. The band -// is deliberately wider than the generator's own tone-faithful window, which is what lets it rescue -// accents the generator alone could not reach. +// walks outward to the nearest lightness whose solid rest, hover, and pressed clear the on-solid +// gate. The band is deliberately wider than the generator's own tone-faithful window, which is what +// lets it rescue accents the generator alone could not reach. const ACCENT_TARGET = { dark: 0.72, light: 0.5 } as const satisfies Record; const ACCENT_BAND = { dark: [0.6, 0.82], @@ -213,8 +213,8 @@ export const defaultControlFinish: ControlFinish = { resting: 'none', }; -/** Curated modal-backdrop scrim applied when `scrim` is omitted, black at a mode-aware alpha. */ -export const defaultScrim: Record = { +/** Curated modal-backdrop colour applied when `backdrop` is omitted, black at a mode-aware alpha. */ +export const defaultBackdrop: Record = { dark: 'oklch(0 0 0 / 0.4)', light: 'oklch(0 0 0 / 0.2)', }; @@ -290,8 +290,11 @@ function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { const { color } = input; const defaults = defaultSourceColors[mode]; const neutral = resolveNeutral(color, mode); + const textPrimary = highContrastText(neutral, mode); const colors: ThemeSourceColors = { - accent: resolveAdaptedRole(color.accent, mode, adaptAccent), + accent: resolveAdaptedRole(color.accent, mode, (source, mode, raw) => { + return adaptAccent(source, mode, raw, textPrimary); + }), // The canvas anchor, split from `neutral`'s hue/chroma character: explicit per-mode value wins, // a single value or the opposite side is adapted to the mode canvas lightness, and an entirely // omitted `background` copies the resolved neutral canvas anchor exactly (not a second, @@ -301,7 +304,7 @@ function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { neutral, // 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]), + backdrop: resolveVerbatimRole(color.backdrop, mode, defaultBackdrop[mode]), danger: resolveSourceRole(color.danger, mode, defaults.danger), focus: resolveSourceRole(color.focus, mode, defaults.focus), info: resolveSourceRole(color.info, mode, defaults.info), @@ -415,17 +418,13 @@ function sideOf(input: ColorInput, mode: ColorMode): string | undefined { } /** - * Adapts a single-value accent for one mode: preserve the source hue and chroma, then search the - * vibrant band for a lightness that clears the scale generator's own on-solid gate - * ({@link passesOnSolidGate}). Returns the lightness nearest the mode target, and throws when no - * lightness in the band is accessible. - * - * The gate is the generator's, not a second copy of it. This pre-conditioner cannot be stricter than - * the solid-anchor search it feeds: a lightness it accepts is one `generateFamily` accepts too, and - * the accent it hands over is the one the generator emits. Its band is wider, which - * is what lets it rescue accents the generator's tone-faithful window cannot reach. + * Adapts a single-value accent for one mode: preserve hue and chroma, then search the vibrant band + * for a lightness that clears {@link passesOnSolidGate} against the same `text.primary` production + * generation uses. Returns the lightness nearest the mode target. Throws when none in the band is + * accessible. The band is wider than the generator's tone-faithful window, which is what lets it + * rescue accents that window cannot reach. */ -function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { +function adaptAccent(source: Oklch, mode: ColorMode, raw: string, interactionSource: Oklch): Oklch { const target = ACCENT_TARGET[mode]; const [low, high] = ACCENT_BAND[mode]; const makeSolid = (l: number) => { @@ -435,7 +434,9 @@ function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { h: source.h, }); }; - const passes = (l: number) => passesOnSolidGate({ lightness: l, mode, source }); + const passes = (l: number) => { + return passesOnSolidGate({ interactionSource, lightness: l, source }); + }; if (passes(target)) return makeSolid(target); @@ -453,7 +454,7 @@ function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { throw new Error( `Theme accent "${raw}" has no accessible ${mode} lightness: no vibrant lightness lets ` + `near-white or near-black on-solid text clear ${TEXT_RATIO}:1 across the solid and its ` + - 'hover. Author an explicit { light, dark } accent instead.', + 'generated hover and pressed states. Author an explicit { light, dark } accent instead.', ); } return makeSolid(best); diff --git a/packages/@luke-ui/react/src/theme/diagnostics.ts b/packages/@luke-ui/react/src/theme/diagnostics.ts index da543fb9..85e99525 100644 --- a/packages/@luke-ui/react/src/theme/diagnostics.ts +++ b/packages/@luke-ui/react/src/theme/diagnostics.ts @@ -26,13 +26,9 @@ export interface SolidAnchorDiagnostics { adaptedForOnSolid: boolean; /** The lightness range the solid-anchor search was allowed to explore. */ band: [number, number]; - /** The on-solid contrast achieved against the solid (step 9). */ - onSolidRatioSolid: number; - /** The on-solid contrast achieved against the solid hover (step 10). */ - onSolidRatioSolidHover: number; /** The lightness the solid anchor resolved to. */ resolvedLightness: number; - /** Whether on-solid text clears WCAG AA against the solid and its hover. */ + /** Whether on-solid text clears WCAG AA across the public solid rest, hover, and pressed colours. */ satisfied: boolean; /** The lightness the search preferred: the source lightness (vibrant) or the curated target (neutral). */ targetLightness: number; @@ -51,8 +47,8 @@ export interface FamilyDiagnostics { gamutReductions: Array; /** The colour mode the family was generated for. */ mode: 'light' | 'dark'; - /** The chosen on-solid colour and the contrast it reaches over the solid and its hover. */ - onSolid: { color: Oklch; ratioSolid: number; ratioSolidHover: number }; + /** The chosen on-solid colour and the contrast it reaches over the public solid rest, hover, and pressed. */ + onSolid: { color: Oklch; ratioRest: number; ratioHover: number; ratioPressed: number }; /** The semantic role the family was generated for. */ role: FamilyRole; /** How the step-9 solid anchor was resolved. */ 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 aaed0ae7..e5f50c4d 100644 --- a/packages/@luke-ui/react/src/theme/extend-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/extend-theme.test.ts @@ -59,7 +59,7 @@ describe('theme inheritance', () => { info: { dark: 'oklch(0.72 0.13 255)', light: 'oklch(0.52 0.16 255)' }, neutral: 'oklch(0.5 0.01 260)', neutralStyle: 'cool', - scrim: 'oklch(0 0 0 / 0.3)', + backdrop: 'oklch(0 0 0 / 0.3)', success: { dark: 'oklch(0.74 0.13 150)', light: 'oklch(0.5 0.13 150)' }, warning: { dark: 'oklch(0.78 0.13 80)', light: 'oklch(0.72 0.14 75)' }, }, diff --git a/packages/@luke-ui/react/src/theme/extend-theme.ts b/packages/@luke-ui/react/src/theme/extend-theme.ts index 3ae03b5d..6bae8c25 100644 --- a/packages/@luke-ui/react/src/theme/extend-theme.ts +++ b/packages/@luke-ui/react/src/theme/extend-theme.ts @@ -36,7 +36,7 @@ const COLOR_ROLES = [ 'warning', 'danger', 'focus', - 'scrim', + 'backdrop', ] as const satisfies ReadonlyArray; /** The two keys that spell one neutral decision. `inheritColor` inherits them together. */ @@ -140,7 +140,7 @@ function inheritColor( info: own.info ?? base.info, neutral: ownNeutral ? own.neutral : base.neutral, neutralStyle: ownNeutral ? own.neutralStyle : base.neutralStyle, - scrim: own.scrim ?? base.scrim, + backdrop: own.backdrop ?? base.backdrop, success: own.success ?? base.success, warning: own.warning ?? base.warning, }; diff --git a/packages/@luke-ui/react/src/theme/foundation.ts b/packages/@luke-ui/react/src/theme/foundation.ts index 8d9284b9..4280144b 100644 --- a/packages/@luke-ui/react/src/theme/foundation.ts +++ b/packages/@luke-ui/react/src/theme/foundation.ts @@ -1,7 +1,7 @@ /** * The typed theme-foundation contract accepted by `buildTheme`, plus the curated defaults Luke UI * 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. + * cross this boundary as {@link Oklch}; CSS-text values such as backdrop stay strings. */ import type { Oklch } from './color.js'; @@ -103,7 +103,7 @@ interface ActionControlFinishFoundation { /** * 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 + * `buildTheme` consumes these values as colours, not CSS text. `backdrop` is the exception: it is * emitted verbatim and may carry an alpha channel. */ export interface ThemeSourceColors { @@ -133,7 +133,7 @@ export interface ThemeSourceColors { * 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; + backdrop: string; /** Source colour for the `success` role. */ success: Oklch; /** Source colour for the `warning` role. */ @@ -142,7 +142,7 @@ export interface ThemeSourceColors { /** * 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 + * the resolved canvas anchor and `focus` is the authored keyboard-focus ring. `backdrop` is * deliberately absent, because it is emitted as CSS text rather than parsed. */ export const SOURCE_COLOR_FIELDS = [ diff --git a/packages/@luke-ui/react/src/theme/index.ts b/packages/@luke-ui/react/src/theme/index.ts index b2087744..c9692660 100644 --- a/packages/@luke-ui/react/src/theme/index.ts +++ b/packages/@luke-ui/react/src/theme/index.ts @@ -3,7 +3,12 @@ export type { ThemeContrastFailure, ThemeInheritance } from './build-theme.js'; export { vars } from './contract.css.js'; export { spaceScale, typeStyles } from './contract.js'; export type { FontWeightRole, SpaceStep, TypeStyle } from './contract.js'; -export { defaultControlFinish, defaultDepth, defaultScrim, defineTheme } from './define-theme.js'; +export { + defaultBackdrop, + defaultControlFinish, + defaultDepth, + defineTheme, +} from './define-theme.js'; export type { ColorInput, ControlFinish, diff --git a/packages/@luke-ui/react/src/theme/scale.test.ts b/packages/@luke-ui/react/src/theme/scale.test.ts index 3457daa6..76681c27 100644 --- a/packages/@luke-ui/react/src/theme/scale.test.ts +++ b/packages/@luke-ui/react/src/theme/scale.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import { + ADAPTABLE_MID_TONE, chromaEnvelope, HUE_STRESS_CORPUS, lightnessEnvelope, @@ -9,12 +10,16 @@ import { } from './__fixtures__/radix-scales.js'; import type { Oklch } from './color.js'; import { contrastRatio, parseColor } from './color.js'; -import { SEMANTIC_ROLES } from './contrast-policy.js'; +import { SEMANTIC_ROLES, TEXT_RATIO } from './contrast-policy.js'; import type { FamilyRole } from './scale.js'; import { + FAMILY_RUNG, generateFamily, generateFamilyWithDiagnostics, + highContrastText, + INTERACTION_PRESSED_STRENGTH, MIN_STATE_DELTA, + mixInteractionState, oklabDeltaE, onSolidGateRatio, passesOnSolidGate, @@ -29,20 +34,58 @@ const BACKGROUND: Record = { light: parseColor('oklch(0.99 0.003 250)'), }; -const TEXT_RATIO = 4.5; +// Representative resolved neutral sources. Distinct from BACKGROUND: production derives +// `text.primary` from the neutral source, not from the canvas. +const NEUTRAL: Record = { + dark: parseColor('oklch(0.22 0.01 250)'), + light: parseColor('oklch(0.985 0.01 250)'), +}; + const MODES: ReadonlyArray = ['light', 'dark']; // The one split is geometric rather than semantic: neutral's solid comes from its own // curated dark/light chip band instead of the source lightness, so it is the only role a dead-zone // source cannot make unsatisfiable. const SOURCE_TONED_ROLES = SEMANTIC_ROLES.filter((role) => role !== 'neutral'); -function family(source: string, mode: ColorMode, role: FamilyRole) { - return generateFamily({ background: BACKGROUND[mode], mode, role, source: parseColor(source) }); +function interactionTarget(mode: ColorMode): Oklch { + return highContrastText(NEUTRAL[mode], mode); +} + +/** Production mix target when the family under test is itself the resolved neutral source. */ +function neutralInteractionTarget(source: string | Oklch, mode: ColorMode): Oklch { + return highContrastText(typeof source === 'string' ? parseColor(source) : source, mode); +} + +function family(source: string, mode: ColorMode, role: FamilyRole, interactionSource: Oklch) { + const parsed = parseColor(source); + return generateFamily({ + background: BACKGROUND[mode], + interactionSource, + mode, + role, + source: parsed, + }); +} + +function familyDiagnostics( + source: string | Oklch, + mode: ColorMode, + role: FamilyRole, + interactionSource: Oklch, +) { + const parsed = typeof source === 'string' ? parseColor(source) : source; + return generateFamilyWithDiagnostics({ + background: BACKGROUND[mode], + interactionSource, + mode, + role, + source: parsed, + }); } describe('generateFamily shape', () => { it('returns all twelve steps plus a contrast colour', () => { - const scale = family('#0090ff', 'light', 'accent'); + const scale = family('#0090ff', 'light', 'accent', interactionTarget('light')); for (let step = 1; step <= 12; step++) { const rung = scale[step as 1]; expect(Number.isFinite(rung.l)).toBe(true); @@ -54,14 +97,15 @@ describe('generateFamily shape', () => { }); }); -describe('component state distinctness', () => { +describe('muted-ramp distinctness', () => { it('keeps steps 3-4 and 4-5 at least MIN_STATE_DELTA apart across the corpus', () => { for (const entry of HUE_STRESS_CORPUS) { for (const mode of MODES) { - // The muted ramp is role-independent, but every role runs the solid-anchor search, so the - // corpus covers all six roles. - for (const role of SEMANTIC_ROLES) { - const scale = family(entry.source, mode, role); + // The muted ramp is role-independent. Source-toned roles still each run the solid-anchor + // search, so the corpus covers every role whose solid follows the source tone. Neutral + // derives its mix target from its own source and is covered separately. + for (const role of SOURCE_TONED_ROLES) { + const scale = family(entry.source, mode, role, interactionTarget(mode)); const delta34 = oklabDeltaE(scale[3], scale[4]); const delta45 = oklabDeltaE(scale[4], scale[5]); expect(delta34, `${entry.name} ${mode} ${role} ΔE(3,4)`).toBeGreaterThanOrEqual( @@ -77,18 +121,27 @@ describe('component state distinctness', () => { }); describe('on-solid contrast guarantee', () => { - it('clears 4.5:1 against the solid (9) and its hover (10) for every role across the corpus', () => { + it('clears 4.5:1 against the public solid rest, hover, and pressed colours for every source-toned role across the corpus', () => { for (const entry of HUE_STRESS_CORPUS) { for (const mode of MODES) { - for (const role of SEMANTIC_ROLES) { - const scale = family(entry.source, mode, role); + for (const role of SOURCE_TONED_ROLES) { + const { diagnostics } = familyDiagnostics( + entry.source, + mode, + role, + interactionTarget(mode), + ); + expect( + diagnostics.onSolid.ratioRest, + `${entry.name} ${mode} ${role} contrast vs rest`, + ).toBeGreaterThanOrEqual(TEXT_RATIO); expect( - contrastRatio(scale.contrast, scale[9]), - `${entry.name} ${mode} ${role} contrast vs 9`, + diagnostics.onSolid.ratioHover, + `${entry.name} ${mode} ${role} contrast vs hover`, ).toBeGreaterThanOrEqual(TEXT_RATIO); expect( - contrastRatio(scale.contrast, scale[10]), - `${entry.name} ${mode} ${role} contrast vs 10`, + diagnostics.onSolid.ratioPressed, + `${entry.name} ${mode} ${role} contrast vs pressed`, ).toBeGreaterThanOrEqual(TEXT_RATIO); } } @@ -96,15 +149,16 @@ describe('on-solid contrast guarantee', () => { }); it('reports a satisfied on-solid anchor', () => { - const { diagnostics } = generateFamilyWithDiagnostics({ - background: BACKGROUND.light, - mode: 'light', - role: 'accent', - source: parseColor('#0090ff'), - }); + const { diagnostics } = familyDiagnostics( + '#0090ff', + 'light', + 'accent', + interactionTarget('light'), + ); expect(diagnostics.solidAnchor.satisfied).toBe(true); - expect(diagnostics.solidAnchor.onSolidRatioSolid).toBeGreaterThanOrEqual(TEXT_RATIO); - expect(diagnostics.solidAnchor.onSolidRatioSolidHover).toBeGreaterThanOrEqual(TEXT_RATIO); + expect(diagnostics.onSolid.ratioRest).toBeGreaterThanOrEqual(TEXT_RATIO); + expect(diagnostics.onSolid.ratioHover).toBeGreaterThanOrEqual(TEXT_RATIO); + expect(diagnostics.onSolid.ratioPressed).toBeGreaterThanOrEqual(TEXT_RATIO); }); }); @@ -138,7 +192,11 @@ describe('reference-envelope properties', () => { it(`keeps ${testCase.name} background/border steps inside the ${mode} lightness envelope`, () => { const source = mode === 'light' ? testCase.source : shiftedForDark(testCase.source, testCase.role); - const scale = family(source, mode, testCase.role); + const interactionSource = + testCase.role === 'neutral' + ? neutralInteractionTarget(source, mode) + : interactionTarget(mode); + const scale = family(source, mode, testCase.role, interactionSource); const { chroma, lightness } = envelopes[mode]; for (let step = 1; step <= 8; step++) { const rung = scale[step as 1]; @@ -165,7 +223,7 @@ describe('reference-envelope properties', () => { it('walks the muted ramp monotonically away from the background', () => { for (const mode of MODES) { - const scale = family('#0090ff', mode, 'accent'); + const scale = family('#0090ff', mode, 'accent', interactionTarget(mode)); for (let step = 1; step < 8; step++) { const here = scale[step as 1].l; const next = scale[(step + 1) as 1].l; @@ -179,46 +237,76 @@ describe('reference-envelope properties', () => { it('peaks chroma at the solid, above the background steps', () => { for (const mode of MODES) { - const scale = family('#0090ff', mode, 'accent'); + const scale = family('#0090ff', mode, 'accent', interactionTarget(mode)); for (const step of [1, 2, 3, 4, 5, 6] as const) { - expect(scale[9].c, `${mode} step ${step} vs solid`).toBeGreaterThanOrEqual(scale[step].c); + expect(scale[FAMILY_RUNG.solid].c, `${mode} step ${step} vs solid`).toBeGreaterThanOrEqual( + scale[step].c, + ); } } }); }); -describe('step 12 is a scale-quality rung, not a contract guarantee', () => { - it('is the more extreme text lightness but carries no contrast guarantee', () => { +describe('high-contrast text rung', () => { + it('extends the text ramp past the low-contrast foreground', () => { for (const mode of MODES) { - const scale = family('#0090ff', mode, 'accent'); + const scale = family('#0090ff', mode, 'accent', interactionTarget(mode)); // Light mode: high-contrast text is darker than low-contrast; dark mode: lighter. Either - // way step 12 sits further from the low-contrast rung, extending the ramp. - const extension = mode === 'light' ? scale[11].l - scale[12].l : scale[12].l - scale[11].l; + // way the high-contrast rung sits further from the low-contrast rung, extending the ramp. + const extension = + mode === 'light' + ? scale[FAMILY_RUNG.foreground].l - scale[FAMILY_RUNG.textPrimary].l + : scale[FAMILY_RUNG.textPrimary].l - scale[FAMILY_RUNG.foreground].l; expect(extension).toBeGreaterThan(0); } }); + + it('matches highContrastText(source) and is independent of the solid-anchor mix target', () => { + // Production computes `text.primary` from the resolved neutral source before the solid-anchor + // search. That is only valid if the high-contrast rung does not depend on the mix target. + const source = parseColor('oklch(0.5 0.08 40)'); + for (const mode of MODES) { + const expected = highContrastText(source, mode); + expect( + family('oklch(0.5 0.08 40)', mode, 'neutral', neutralInteractionTarget(source, mode))[ + FAMILY_RUNG.textPrimary + ], + ).toEqual(expected); + // Deliberately mismatched mix target: the high-contrast rung must still equal + // `highContrastText(source)`, not the colour the solid-anchor search mixes toward. + expect( + generateFamily({ + background: BACKGROUND[mode], + interactionSource: { l: 0.5, c: 0, h: 0 }, + mode, + role: 'neutral', + source, + })[FAMILY_RUNG.textPrimary], + ).toEqual(expected); + } + }); }); describe('solid-anchor search', () => { it('honours the source lightness when it already clears the on-solid gate', () => { - const { diagnostics } = generateFamilyWithDiagnostics({ - background: BACKGROUND.dark, - mode: 'dark', - role: 'accent', - source: parseColor('#0090ff'), - }); + const { diagnostics } = familyDiagnostics( + '#0090ff', + 'dark', + 'accent', + interactionTarget('dark'), + ); expect(diagnostics.solidAnchor.adaptedForOnSolid).toBe(false); expect(diagnostics.solidAnchor.resolvedLightness).toBeCloseTo(parseColor('#0090ff').l, 5); }); it('nudges the solid off the source lightness when the source itself fails the gate', () => { const source = parseColor('#3b82f6'); - const { diagnostics } = generateFamilyWithDiagnostics({ - background: BACKGROUND.light, - mode: 'light', - role: 'accent', + const { diagnostics } = familyDiagnostics( source, - }); + 'light', + 'accent', + interactionTarget('light'), + ); expect(diagnostics.solidAnchor.adaptedForOnSolid).toBe(true); expect(diagnostics.solidAnchor.resolvedLightness).not.toBeCloseTo(source.l, 3); // The nudge stays within the tone-faithful window. @@ -233,8 +321,8 @@ describe('solid-anchor search', () => { // and an `info` badge equally able to render a solid. const source = parseColor('oklch(0.51 0.19 150)'); const resolvedLightness = (mode: ColorMode, role: FamilyRole) => { - return generateFamilyWithDiagnostics({ background: BACKGROUND[mode], mode, role, source }) - .diagnostics.solidAnchor.resolvedLightness; + return familyDiagnostics(source, mode, role, interactionTarget(mode)).diagnostics.solidAnchor + .resolvedLightness; }; const anchors = MODES.flatMap((mode) => { return SOURCE_TONED_ROLES.map((role) => [mode, role, resolvedLightness(mode, role)]); @@ -251,37 +339,46 @@ describe('solid-anchor search', () => { it('keeps the neutral solid accessible in both modes', () => { for (const mode of MODES) { - const scale = family('oklch(0.99 0.003 250)', mode, 'neutral'); - expect(contrastRatio(scale.contrast, scale[9]), `neutral ${mode}`).toBeGreaterThanOrEqual( - TEXT_RATIO, + const source = mode === 'light' ? 'oklch(0.99 0.003 250)' : 'oklch(0.18 0.004 250)'; + const { diagnostics } = familyDiagnostics( + source, + mode, + 'neutral', + neutralInteractionTarget(source, mode), ); - expect(contrastRatio(scale.contrast, scale[10]), `neutral ${mode}`).toBeGreaterThanOrEqual( + expect(diagnostics.onSolid.ratioRest, `neutral ${mode}`).toBeGreaterThanOrEqual(TEXT_RATIO); + expect(diagnostics.onSolid.ratioHover, `neutral ${mode}`).toBeGreaterThanOrEqual(TEXT_RATIO); + expect(diagnostics.onSolid.ratioPressed, `neutral ${mode}`).toBeGreaterThanOrEqual( TEXT_RATIO, ); } }); }); -describe('the one on-solid gate', () => { - // `passesOnSolidGate` is the single predicate for "can this solid carry readable text": the - // solid-anchor search below decides on it, and `defineTheme`'s accent pre-conditioner calls it - // rather than keeping a second copy. These tests pin the properties that copy had drifted on. +describe('the on-solid gate', () => { + // `passesOnSolidGate` is the single predicate for whether a solid can carry readable text. The + // solid-anchor search decides on it, and `defineTheme`'s accent pre-conditioner calls it rather + // than keeping a second copy. /** How the solid-anchor search resolved a source, or `null` when it found nothing in the band. */ function resolveAnchor(source: Oklch, mode: ColorMode) { try { - return generateFamilyWithDiagnostics({ - background: BACKGROUND[mode], - mode, - role: 'accent', - source, - }).diagnostics.solidAnchor; + return familyDiagnostics(source, mode, 'accent', interactionTarget(mode)).diagnostics + .solidAnchor; } catch (error) { if (error instanceof ScaleGenerationError) return null; throw error; } } + function gate(source: Oklch, mode: ColorMode, lightness = source.l) { + return { + interactionSource: interactionTarget(mode), + lightness, + source, + }; + } + it('accepts exactly the lightnesses the solid-anchor search honours verbatim', () => { // Swept across the generator's whole vibrant solid range, which contains `defineTheme`'s wider // accent adaptation bands. A lightness the gate accepts must be one the search keeps as-is. One it @@ -302,7 +399,7 @@ describe('the one on-solid gate', () => { anchor !== null && !anchor.adaptedForOnSolid && Math.abs(anchor.resolvedLightness - lightness) < 1e-9; - const gated = passesOnSolidGate({ lightness, mode, source }); + const gated = passesOnSolidGate(gate(source, mode)); if (gated === honoured) continue; disagreements.push( `${mode} oklch(${lightness.toFixed(2)} ${chroma} ${hue}): ` + @@ -316,95 +413,121 @@ describe('the one on-solid gate', () => { }); it('solves past the AA text ratio, so a pair that only just clears 4.5:1 does not pass', () => { - // Light `oklch(0.5575 0.01 0)` reaches 4.53:1 across its solid and hover: enough for a plain 4.5 - // check, short of the headroom the gate solves for so 4-decimal emission cannot round it under. + // Light `oklch(0.5575 0.01 0)` reaches just over 4.5:1 across its public solid states: enough + // for a plain 4.5 check, short of the headroom the gate solves for so 4-decimal emission cannot + // round it under. const source: Oklch = { l: 0.5575, c: 0.01, h: 0, }; - const ratio = onSolidGateRatio({ lightness: source.l, mode: 'light', source }); + const ratio = onSolidGateRatio(gate(source, 'light')); expect(ratio).toBeGreaterThan(TEXT_RATIO); expect(ratio).toBeLessThan(TEXT_RATIO + 0.05); - expect(passesOnSolidGate({ lightness: source.l, mode: 'light', source })).toBe(false); + expect(passesOnSolidGate(gate(source, 'light'))).toBe(false); // And the search agrees: it moves the anchor off this lightness rather than emitting it. expect(resolveAnchor(source, 'light')?.adaptedForOnSolid).toBe(true); }); - it('tests only the solid and its hover, never a deeper pressed state the engine does not generate', () => { - // Light `oklch(0.64 0 0)` clears 4.58:1 across the two states the engine emits. A phantom third - // state 0.09 darker would drag it to 3.88:1 and fail. The pressed solid reuses step 10, so no - // such colour exists and the gate must not invent one. + it('gates the public rest, hover, and pressed solids, not a phantom deeper lightness', () => { const source: Oklch = { l: 0.64, c: 0, h: 0, }; - const phantomPressed = { - l: source.l - 0.09, - c: 0, - h: 0, - }; - const onSolid = family('oklch(0.64 0 0)', 'light', 'accent').contrast; - expect(contrastRatio(onSolid, phantomPressed)).toBeLessThan(TEXT_RATIO); - expect(onSolidGateRatio({ lightness: source.l, mode: 'light', source })).toBeGreaterThan( - TEXT_RATIO, - ); - expect(passesOnSolidGate({ lightness: source.l, mode: 'light', source })).toBe(true); + const towardText = interactionTarget('light'); + const solid = { l: source.l, c: 0, h: 0 }; + const pressed = mixInteractionState(solid, towardText, INTERACTION_PRESSED_STRENGTH); + const phantom = { l: source.l - 0.09, c: 0, h: 0 }; + const onSolid = family( + 'oklch(0.64 0 0)', + 'light', + 'accent', + interactionTarget('light'), + ).contrast; + expect(contrastRatio(onSolid, phantom)).toBeLessThan(TEXT_RATIO); + expect(contrastRatio(onSolid, pressed)).toBeGreaterThan(TEXT_RATIO); + expect(onSolidGateRatio(gate(source, 'light'))).toBeGreaterThan(TEXT_RATIO); + expect(passesOnSolidGate(gate(source, 'light'))).toBe(true); expect(resolveAnchor(source, 'light')?.adaptedForOnSolid).toBe(false); }); }); -describe('unsatisfiable input', () => { - it('throws ScaleGenerationError carrying role and mode when a source tone is a dead zone', () => { - for (const mode of MODES) { - const entry = UNSATISFIABLE_ON_SOLID[mode]; - for (const role of SOURCE_TONED_ROLES) { - let thrown: unknown; - try { - family(entry.source, mode, role); - } catch (error) { - thrown = error; - } - expect(thrown, `${entry.name} ${role}`).toBeInstanceOf(ScaleGenerationError); - const error = thrown as ScaleGenerationError; - expect(error.role).toBe(role); - expect(error.mode).toBe(mode); - expect(error.bestAttempt.step).toBe(9); - expect(error.bestAttempt.onSolidRatio).toBeLessThan(TEXT_RATIO); +describe('dead-zone and adaptable sources', () => { + it('throws ScaleGenerationError carrying role and mode when a dark-mode source tone is a dead zone', () => { + const entry = UNSATISFIABLE_ON_SOLID; + for (const role of SOURCE_TONED_ROLES) { + let thrown: unknown; + try { + family(entry.source, 'dark', role, interactionTarget('dark')); + } catch (error) { + thrown = error; } + expect(thrown, `${entry.name} ${role}`).toBeInstanceOf(ScaleGenerationError); + const error = thrown as ScaleGenerationError; + expect(error.role).toBe(role); + expect(error.mode).toBe('dark'); + expect(error.bestAttempt.step).toBe(9); + expect(error.bestAttempt.onSolidRatio).toBeLessThan(TEXT_RATIO); } }); + it('adapts a reachable light-mode mid-tone into the tone-faithful window', () => { + const { diagnostics } = familyDiagnostics( + ADAPTABLE_MID_TONE.source, + 'light', + 'accent', + interactionTarget('light'), + ); + expect(diagnostics.solidAnchor.adaptedForOnSolid).toBe(true); + expect(diagnostics.solidAnchor.satisfied).toBe(true); + }); + it('does not throw for neutral, whose solid comes from a curated band rather than the source tone', () => { - for (const mode of MODES) { - expect(() => family(UNSATISFIABLE_ON_SOLID[mode].source, mode, 'neutral')).not.toThrow(); - } + expect(() => { + return family( + UNSATISFIABLE_ON_SOLID.source, + 'dark', + 'neutral', + neutralInteractionTarget(UNSATISFIABLE_ON_SOLID.source, 'dark'), + ); + }).not.toThrow(); + expect(() => { + return family( + ADAPTABLE_MID_TONE.source, + 'light', + 'neutral', + neutralInteractionTarget(ADAPTABLE_MID_TONE.source, 'light'), + ); + }).not.toThrow(); }); }); describe('gamut-reduction diagnostics', () => { it('records the rungs whose chroma the sRGB gamut forced down for an out-of-gamut source', () => { - const { diagnostics } = generateFamilyWithDiagnostics({ - background: BACKGROUND.light, - mode: 'light', - role: 'accent', - source: parseColor('oklch(0.7 0.4 195)'), - }); + const { diagnostics } = familyDiagnostics( + 'oklch(0.7 0.4 195)', + 'light', + 'accent', + interactionTarget('light'), + ); expect(diagnostics.gamutReductions.length).toBeGreaterThan(0); - expect(diagnostics.gamutReductions.some((reduction) => reduction.step === 9)).toBe(true); + expect( + diagnostics.gamutReductions.some((reduction) => reduction.step === FAMILY_RUNG.solid), + ).toBe(true); for (const reduction of diagnostics.gamutReductions) { expect(reduction.resolvedChroma).toBeLessThan(reduction.requestedChroma); } }); it('records no reduction for an in-gamut low-chroma neutral', () => { - const { diagnostics } = generateFamilyWithDiagnostics({ - background: BACKGROUND.light, - mode: 'light', - role: 'neutral', - source: parseColor('oklch(0.99 0.003 250)'), - }); + const source = 'oklch(0.99 0.003 250)'; + const { diagnostics } = familyDiagnostics( + source, + 'light', + 'neutral', + neutralInteractionTarget(source, 'light'), + ); expect(diagnostics.gamutReductions).toEqual([]); }); }); diff --git a/packages/@luke-ui/react/src/theme/scale.ts b/packages/@luke-ui/react/src/theme/scale.ts index c837573f..48dd9d96 100644 --- a/packages/@luke-ui/react/src/theme/scale.ts +++ b/packages/@luke-ui/react/src/theme/scale.ts @@ -1,17 +1,14 @@ /** * The private 12-step colour scale generator. `generateFamily` produces a Radix-shaped OKLCH family * (steps 1-12 plus a `contrast` on-solid colour) from a family character `source`, the canvas - * `background`, a colour `mode`, and a semantic `role`. It owns the constrained solid-anchor (step-9) - * search; it is calibrated to testable scale properties rather than to exact Radix reproduction. - * - * It also owns {@link passesOnSolidGate}, the on-solid accessibility gate. `defineTheme`'s accent - * pre-conditioner calls it rather than reimplementing it. It reuses the dependency-free colour math in - * `color.ts` and the shared thresholds in `contrast-policy.ts`. Every semantic role's solid and hover - * must clear 4.5:1 against on-solid text — that invariant lives next to `SEMANTIC_ROLES` there. + * `background`, a colour `mode`, a semantic `role`, and the `text.primary` colour that public hover + * and pressed mix toward. It owns the constrained solid-anchor (step-9) search and + * {@link passesOnSolidGate}, which `defineTheme`'s accent pre-conditioner calls rather than + * reimplementing. */ import type { Oklch } from './color.js'; -import { clampUnit, contrastRatio, gamutMapOklch } from './color.js'; +import { clampUnit, contrastRatio, gamutMapOklch, mixOklab } from './color.js'; import type { SEMANTIC_ROLES } from './contrast-policy.js'; import { RATIO_HEADROOM, TEXT_RATIO } from './contrast-policy.js'; import type { FamilyDiagnostics, GamutReduction, SolidAnchorDiagnostics } from './diagnostics.js'; @@ -26,9 +23,11 @@ export type ScaleStep = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; type ColorMode = 'light' | 'dark'; /** - * A generated 12-step colour family plus its on-solid `contrast` colour. Step roles: 1-2 app/subtle - * backgrounds, 3-5 component surface (normal / hover / active), 6-8 borders (subtle / UI+focus / - * hover), 9-10 solid (9 = anchor, 10 = hover), 11-12 text (low / high contrast). + * A generated 12-step colour family plus its on-solid `contrast` colour. Semantic consumers read + * named rungs via {@link FAMILY_RUNG}: step 3 is the public subtle rest, step 7 the semantic + * border, step 9 the public solid rest, step 11 the resting foreground, and neutral step 12 is + * `text.primary`. Unnamed steps are private scale geometry, not public hover or pressed colours. + * `contrast` is on-solid text that must read over solid rest, hover, and pressed. */ export interface ScaleFamily { 1: Oklch; @@ -43,14 +42,50 @@ export interface ScaleFamily { 10: Oklch; 11: Oklch; 12: Oklch; - /** On-solid text: reads over steps 9 and 10, guaranteed WCAG AA. */ + /** On-solid text: reads over the solid rest, hover, and pressed colours, guaranteed WCAG AA. */ contrast: Oklch; } +/** + * Semantic rungs of the private 12-step family. The generator stays ordinal; mapping and other + * consumers use these names instead of remembering what 3, 7, 9, 11, and 12 mean. Steps 4, 5, and + * 10 stay unnamed: they are private scale geometry, not public hover or pressed colours. + */ +export const FAMILY_RUNG = { + subtle: 3, + decorative: 6, + border: 7, + muted: 8, + solid: 9, + foreground: 11, + textPrimary: 12, +} as const satisfies Record; + +/** Share of `text.primary` mixed into a resting colour to produce the public hover state. */ +export const INTERACTION_HOVER_STRENGTH = 0.05; + +/** Share of `text.primary` mixed into a resting colour to produce the public pressed state. */ +export const INTERACTION_PRESSED_STRENGTH = 0.1; + +/** Mixes a resting colour toward `toward` at `strength`, then maps the result into sRGB. */ +export function mixInteractionState(rest: Oklch, toward: Oklch, strength: number): Oklch { + return gamutMapOklch(mixOklab(rest, toward, strength)); +} + +/** + * The high-contrast text rung (step 12) a family emits for `source` in `mode`. Neutral's rung is + * `text.primary`, computed before any solid-anchor search so every family can gate against it. + */ +export function highContrastText(source: Oklch, mode: ColorMode): Oklch { + return gamutMapOklch(highContrastTextRequest(source, mode)); +} + /** The inputs to {@link generateFamily}. */ export interface GenerateFamilyRequest { /** The resolved canvas anchor. Steps 1-8 ramp away from it toward the solid. */ background: Oklch; + /** The colour public hover and pressed mix toward. Production passes emitted `text.primary`. */ + interactionSource: Oklch; /** The colour mode the family is generated for. */ mode: ColorMode; /** The semantic role. Neutral uses a curated solid band; other roles keep the source tone. */ @@ -61,8 +96,9 @@ export interface GenerateFamilyRequest { /** * Thrown when a family has no lightness in its solid band where a near-white or near-black - * on-solid text clears WCAG AA across the solid and its hover. Carries the `role` and `mode` so the - * caller can name the failing family, plus the best attempt for diagnostics. + * on-solid text clears WCAG AA across the solid and its generated hover and pressed states. Carries + * the `role` and `mode` so the caller can name the failing family, plus the best attempt for + * diagnostics. */ export class ScaleGenerationError extends Error { /** The role whose family could not be generated. */ @@ -77,14 +113,15 @@ export class ScaleGenerationError extends Error { lightness: number; /** The best-attempt step-9 solid colour. */ solid: Oklch; - /** The on-solid contrast the best attempt achieved across the solid and its hover. */ + /** The on-solid contrast the best attempt achieved across rest, hover, and pressed. */ onSolidRatio: number; }; constructor(role: FamilyRole, mode: ColorMode, bestAttempt: ScaleGenerationError['bestAttempt']) { super( `Cannot generate the ${mode} "${role}" family: no solid lightness in the search band lets ` + - 'near-white or near-black on-solid text clear 4.5:1 across the solid and its hover ' + + 'near-white or near-black on-solid text clear 4.5:1 across the solid and its generated ' + + 'hover and pressed states ' + `(best attempt reached ${bestAttempt.onSolidRatio.toFixed(2)}:1 at lightness ` + `${bestAttempt.lightness.toFixed(3)}). Author an explicit, more accessible source colour.`, ); @@ -100,13 +137,13 @@ export class ScaleGenerationError extends Error { const ON_SOLID_TARGET = TEXT_RATIO + RATIO_HEADROOM; /** - * The OKLab ΔE floor between consecutive component states (steps 3-4 and 4-5). The muted ramp's + * The OKLab ΔE floor between consecutive muted-ramp rungs (steps 3-4 and 4-5). The muted ramp's * fixed lightness deltas clear this comfortably for every role, including near-achromatic neutrals. */ export const MIN_STATE_DELTA = 0.015; // Steps 1-8 form a "muted ramp": lightness walks away from the background toward the solid by fixed -// absolute offsets (so component-state distinctness never depends on the anchor lightness), while +// absolute offsets (so consecutive rungs stay distinct independent of the anchor lightness), while // chroma grows from a faint tint to near the solid. `offset` is an absolute OKLCH lightness delta // from the background (its sign is set by the mode: darker in light mode, lighter in dark mode); // `chromaFraction` scales the source chroma and `chromaCap` caps it so the pale near-background @@ -139,10 +176,9 @@ const RAMP_SPEC = { ], } as const satisfies Record>; -// The solid step 10 (hover) offset from step 9: darker in light mode, lighter in dark mode. The -// pressed solid leaf deliberately reuses step 10, so the on-solid gate has exactly these two states -// to clear. There is no third, deeper pressed colour to test. -const SOLID_HOVER_DELTA = 0.05; +// Private step-10 lightness offset from the solid rest. Public hover and pressed mix step 9 toward +// `text.primary`. +const SOLID_STEP_10_DELTA = 0.05; interface SolidBand { /** The inclusive lightness range the search may explore. */ @@ -165,19 +201,26 @@ const NEUTRAL_SOLID = { light: { band: [0.22, 0.45], target: 0.35 }, } as const satisfies Record; -// Text lightness targets: step 11 (low contrast) and step 12 (high contrast). Step 12 is a -// scale-quality rung only — no semantic leaf consumes a high-contrast contract guarantee yet. Light -// `low` sits at 0.49 (not 0.5) so step 11 keeps a small AA margin over the pressed subtle surface -// (step 5) even for the highest-luminance hues — accent/danger text is mapped onto that subtle trio. +// Text lightness targets: step 11 (low contrast) and step 12 (high contrast). Step 12 is also +// `text.primary`, the mix target for public hover and pressed colours. Light `low` sits below 0.5 +// so step 11 keeps AA over generated subtle hover and pressed fills (rest mixed toward text.primary). const TEXT_LIGHTNESS = { dark: { high: 0.94, low: 0.76 }, - light: { high: 0.3, low: 0.49 }, + light: { high: 0.3, low: 0.45 }, } as const satisfies Record; const TEXT_LOW_CHROMA_FRACTION = 0.55; const TEXT_LOW_CHROMA_CAP = 0.13; const TEXT_HIGH_CHROMA_FRACTION = 0.45; const TEXT_HIGH_CHROMA_CAP = 0.1; +function highContrastTextRequest(source: Oklch, mode: ColorMode): Oklch { + return { + l: clampUnit(TEXT_LIGHTNESS[mode].high), + c: Math.max(Math.min(source.c * TEXT_HIGH_CHROMA_FRACTION, TEXT_HIGH_CHROMA_CAP), 0), + h: source.h, + }; +} + // The near-white and near-black candidates the on-solid gate chooses between. const ON_SOLID_WHITE_LIGHTNESS = 0.985; const ON_SOLID_BLACK_LIGHTNESS = 0.18; @@ -185,47 +228,33 @@ const ON_SOLID_BLACK_CHROMA = 0.01; /** A candidate solid the on-solid gate is asked about. */ export interface OnSolidGateRequest { + /** The colour public hover and pressed mix toward. Must match the colour `generateFamily` will use. */ + interactionSource: Oklch; /** The candidate step-9 solid lightness. */ lightness: number; - /** The colour mode, which sets the direction step 10 (hover) moves in. */ - mode: ColorMode; /** The family character. Only its hue and chroma are read; `lightness` supplies the tone. */ source: Oklch; } /** - * The one on-solid accessibility gate: whether the near-white or near-black on-solid text this - * generator would choose clears the AA text ratio (plus the search headroom) across *both* solid - * states a candidate lightness produces: step 9 and its step-10 hover. The pressed solid reuses step - * 10, so there is no third state to test. - * - * `defineTheme`'s accent pre-conditioner calls this rather than reimplementing it, so the - * pre-conditioner can never be stricter than {@link generateFamily}'s solid-anchor search: a - * lightness it accepts is one the search accepts too. + * Whether the near-white or near-black on-solid text this generator would choose clears the AA text + * ratio (plus the search headroom) across the public solid rest, hover, and pressed colours a + * candidate lightness produces. `defineTheme`'s accent pre-conditioner calls this rather than + * reimplementing it. */ export function passesOnSolidGate(request: OnSolidGateRequest): boolean { return onSolidGateRatio(request) >= ON_SOLID_TARGET; } -/** - * The contrast {@link passesOnSolidGate} measures: the better on-solid candidate's minimum ratio - * across the candidate solid and its hover. Exposed for the solid-anchor search's best-attempt - * diagnostics. - */ +/** The minimum on-solid ratio {@link passesOnSolidGate} compares to the AA target plus headroom. */ export function onSolidGateRatio(request: OnSolidGateRequest): number { - const { lightness, mode, source } = request; - const direction = mode === 'light' ? -1 : 1; + const { interactionSource, lightness, source } = request; const solid = gamutMapOklch({ l: clampUnit(lightness), c: source.c, h: source.h, }); - const hover = gamutMapOklch({ - c: source.c, - h: source.h, - l: clampUnit(lightness + direction * SOLID_HOVER_DELTA), - }); - return chooseOnSolid(source.h, [solid, hover]).minRatio; + return chooseOnSolid(source.h, publicSolidStates(solid, interactionSource)).minRatio; } /** @@ -239,8 +268,8 @@ export function generateFamily(request: GenerateFamilyRequest): ScaleFamily { /** * Generates a family together with its family-level {@link FamilyDiagnostics}: the resolved solid - * anchor (authored vs resolved lightness, whether it was adapted for on-solid, achieved ratios), - * the on-solid choice, and any gamut-driven chroma reductions. + * anchor, the on-solid colour and its rest/hover/pressed ratios, and any gamut-driven chroma + * reductions. */ export function generateFamilyWithDiagnostics(request: GenerateFamilyRequest): { family: ScaleFamily; @@ -260,7 +289,7 @@ function buildFamily(request: GenerateFamilyRequest): { family: ScaleFamily; diagnostics: FamilyDiagnostics; } { - const { background, mode, role, source } = request; + const { background, interactionSource, mode, role, source } = request; const hue = source.h; const direction = mode === 'light' ? -1 : 1; const backgroundLightness = clampUnit(background.l); @@ -268,9 +297,9 @@ function buildFamily(request: GenerateFamilyRequest): { const rung = (step: ScaleStep, lightness: number, requestedChroma: number): Oklch => { const mapped = gamutMapOklch({ + l: clampUnit(lightness), c: Math.max(requestedChroma, 0), h: hue, - l: clampUnit(lightness), }); if (requestedChroma - mapped.c > GAMUT_REDUCTION_EPSILON) { reductions.push({ requestedChroma, resolvedChroma: mapped.c, step }); @@ -298,26 +327,22 @@ function buildFamily(request: GenerateFamilyRequest): { const step7 = mutedRung(6); const step8 = mutedRung(7); - // Step 9: the solid anchor, searched so on-solid text clears AA across the solid and its hover. + // Step 9: the solid anchor, searched so on-solid text clears AA across rest, hover, and pressed. const anchor = resolveSolidAnchor(request); const solid = rung(9, anchor.lightness, source.c); - const solidHover = rung(10, anchor.lightness + direction * SOLID_HOVER_DELTA, source.c); + const step10 = rung(10, anchor.lightness + direction * SOLID_STEP_10_DELTA, source.c); + const [solidRest, publicHover, publicPressed] = publicSolidStates(solid, interactionSource); - // The on-solid text: the better of near-white / near-black across the solid and its hover. - const onSolid = chooseOnSolid(hue, [solid, solidHover]); + const onSolid = chooseOnSolid(hue, [solidRest, publicHover, publicPressed]); - // Steps 11-12: text rungs. Step 12 is a scale-quality rung, not a contract guarantee. const text = TEXT_LIGHTNESS[mode]; const lowText = rung( 11, text.low, Math.min(source.c * TEXT_LOW_CHROMA_FRACTION, TEXT_LOW_CHROMA_CAP), ); - const highText = rung( - 12, - text.high, - Math.min(source.c * TEXT_HIGH_CHROMA_FRACTION, TEXT_HIGH_CHROMA_CAP), - ); + const highTextRequest = highContrastTextRequest(source, mode); + const highText = rung(12, highTextRequest.l, highTextRequest.c); const family: ScaleFamily = { 1: step1, @@ -329,7 +354,7 @@ function buildFamily(request: GenerateFamilyRequest): { 7: step7, 8: step8, 9: solid, - 10: solidHover, + 10: step10, 11: lowText, 12: highText, contrast: onSolid.color, @@ -338,8 +363,6 @@ function buildFamily(request: GenerateFamilyRequest): { const solidAnchor: SolidAnchorDiagnostics = { adaptedForOnSolid: anchor.adapted, band: anchor.band, - onSolidRatioSolid: contrastRatio(onSolid.color, solid), - onSolidRatioSolidHover: contrastRatio(onSolid.color, solidHover), resolvedLightness: anchor.lightness, satisfied: onSolid.minRatio >= TEXT_RATIO, targetLightness: anchor.target, @@ -352,8 +375,9 @@ function buildFamily(request: GenerateFamilyRequest): { mode, onSolid: { color: onSolid.color, - ratioSolid: solidAnchor.onSolidRatioSolid, - ratioSolidHover: solidAnchor.onSolidRatioSolidHover, + ratioRest: contrastRatio(onSolid.color, solid), + ratioHover: contrastRatio(onSolid.color, publicHover), + ratioPressed: contrastRatio(onSolid.color, publicPressed), }, role, solidAnchor, @@ -371,10 +395,10 @@ interface ResolvedAnchor { } /** - * Resolves the step-9 solid lightness. Searches the solid band for a lightness whose solid and hover - * both clear the on-solid gate, preferring the lightness nearest the source (vibrant) or the curated - * target (neutral), and throwing when none clears. Every semantic role publishes a solid, so every - * family is searched. + * Resolves the step-9 solid lightness. Searches the solid band for a lightness whose public rest, + * hover, and pressed colours all clear the on-solid gate, preferring the lightness nearest the + * source (vibrant) or the curated target (neutral), and throwing when none clears. Every semantic + * role publishes a solid, so every family is searched. */ function resolveSolidAnchor(request: GenerateFamilyRequest): ResolvedAnchor { const { mode, role, source } = request; @@ -392,9 +416,13 @@ function resolveSolidAnchor(request: GenerateFamilyRequest): ResolvedAnchor { const [low, high] = band; const preferred = clamp(target, low, high); - const gateRatio = (lightness: number): number => onSolidGateRatio({ lightness, mode, source }); + const gateRequest = { + interactionSource: request.interactionSource, + source, + }; + const gateRatio = (lightness: number): number => onSolidGateRatio({ ...gateRequest, lightness }); - if (passesOnSolidGate({ lightness: preferred, mode, source })) { + if (passesOnSolidGate({ ...gateRequest, lightness: preferred })) { return { adapted: false, band, lightness: preferred, target }; } @@ -459,6 +487,14 @@ function minimumRatio(foreground: Oklch, backgrounds: Array): number { return Math.min(...backgrounds.map((background) => contrastRatio(foreground, background))); } +function publicSolidStates(solid: Oklch, toward: Oklch): [Oklch, Oklch, Oklch] { + return [ + solid, + mixInteractionState(solid, toward, INTERACTION_HOVER_STRENGTH), + mixInteractionState(solid, toward, INTERACTION_PRESSED_STRENGTH), + ]; +} + function oklabAxes(color: Oklch): [number, number] { const hueRadians = (color.h * Math.PI) / 180; return [color.c * Math.cos(hueRadians), color.c * Math.sin(hueRadians)]; 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 0c9ffcb1..136943d8 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.test.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.test.ts @@ -6,7 +6,14 @@ import { SEMANTIC_ROLES } from './contrast-policy.js'; import { generateSurfaces } from './elevation.js'; import { defaultSourceColors } from './foundation.js'; import type { FamilyRole, ScaleFamily } from './scale.js'; -import { generateFamily } from './scale.js'; +import { + FAMILY_RUNG, + generateFamily, + highContrastText, + INTERACTION_HOVER_STRENGTH, + INTERACTION_PRESSED_STRENGTH, + mixInteractionState, +} from './scale.js'; import { mapSemanticColors } from './semantic-map.js'; type ColorMode = 'light' | 'dark'; @@ -52,8 +59,15 @@ const SOURCE: Record> = { }; function buildFamilies(mode: ColorMode, background: Oklch): Record { + const textPrimary = highContrastText(parseColor(SOURCE[mode].neutral), mode); const family = (role: FamilyRole) => { - return generateFamily({ background, mode, role, source: parseColor(SOURCE[mode][role]) }); + return generateFamily({ + background, + interactionSource: textPrimary, + mode, + role, + source: parseColor(SOURCE[mode][role]), + }); }; return { accent: family('accent'), @@ -72,14 +86,14 @@ describe('mapSemanticColors', () => { const background = BACKGROUND[mode]; const families = buildFamilies(mode, background); const surfaces = generateSurfaces({ background, mode }); - const scrim = 'oklch(0 0 0 / 0.45)'; + const backdrop = 'oklch(0 0 0 / 0.45)'; const controlBorder = CONTROL_BORDER[mode]; const result = mapSemanticColors({ + backdrop, controlBorder, families, focus: FOCUS, - scrim, surfaces, }); @@ -88,39 +102,65 @@ describe('mapSemanticColors', () => { expect(result['color.surface.recessed']).toBe(formatOklch(surfaces.recessed)); expect(result['color.surface.floating']).toBe(formatOklch(surfaces.floating)); expect(result['color.surface.overlay']).toBe(formatOklch(surfaces.overlay)); - expect(result['color.scrim']).toBe(scrim); - expect(result['color.loadingSkeleton']).toBe(formatOklch(families.neutral[8])); + expect(result['color.overlay.backdrop']).toBe(backdrop); + expect(result['color.loadingSkeleton']).toBe( + formatOklch(families.neutral[FAMILY_RUNG.muted]), + ); // Global text and borders use the neutral family. `border.control` is a solved // contrast boundary, not a scale-step alias, so it aliases the passed-through value. - expect(result['color.text.primary']).toBe(formatOklch(families.neutral[12])); - expect(result['color.text.secondary']).toBe(formatOklch(families.neutral[11])); - expect(result['color.text.disabled']).toBe(formatOklch(families.neutral[8])); - expect(result['color.border.decorative']).toBe(formatOklch(families.neutral[6])); + expect(result['color.text.primary']).toBe( + formatOklch(families.neutral[FAMILY_RUNG.textPrimary]), + ); + expect(result['color.text.secondary']).toBe( + formatOklch(families.neutral[FAMILY_RUNG.foreground]), + ); + expect(result['color.text.disabled']).toBe( + formatOklch(families.neutral[FAMILY_RUNG.muted]), + ); + expect(result['color.border.decorative']).toBe( + formatOklch(families.neutral[FAMILY_RUNG.decorative]), + ); expect(result['color.border.control']).toBe(formatOklch(controlBorder)); expect(result['color.border.focus']).toBe(formatOklch(FOCUS)); - // The shared contract: identical steps for all six roles, keyed to the role's own family. + // The shared contract: identical rest / hover / pressed mapping for every semantic role. for (const role of SEMANTIC_ROLES) { const family = families[role]; - expect(result[`color.background.${role}.subtle.rest`]).toBe(formatOklch(family[3])); - expect(result[`color.background.${role}.subtle.hover`]).toBe(formatOklch(family[4])); - expect(result[`color.background.${role}.subtle.pressed`]).toBe(formatOklch(family[5])); - expect(result[`color.background.${role}.solid.rest`]).toBe(formatOklch(family[9])); - expect(result[`color.background.${role}.solid.hover`]).toBe(formatOklch(family[10])); - // Deliberate dup: pressed reuses the hover value. - expect(result[`color.background.${role}.solid.pressed`]).toBe(formatOklch(family[10])); - expect(result[`color.foreground.${role}.rest`]).toBe(formatOklch(family[11])); - expect(result[`color.foreground.${role}.hover`]).toBe(formatOklch(family[12])); + const textPrimary = families.neutral[FAMILY_RUNG.textPrimary]; + const subtle = family[FAMILY_RUNG.subtle]; + const solid = family[FAMILY_RUNG.solid]; + const foreground = family[FAMILY_RUNG.foreground]; + expect(result[`color.background.${role}.subtle.rest`]).toBe(formatOklch(subtle)); + expect(result[`color.background.${role}.subtle.hover`]).toBe( + formatOklch(mixInteractionState(subtle, textPrimary, INTERACTION_HOVER_STRENGTH)), + ); + expect(result[`color.background.${role}.subtle.pressed`]).toBe( + formatOklch(mixInteractionState(subtle, textPrimary, INTERACTION_PRESSED_STRENGTH)), + ); + expect(result[`color.background.${role}.solid.rest`]).toBe(formatOklch(solid)); + expect(result[`color.background.${role}.solid.hover`]).toBe( + formatOklch(mixInteractionState(solid, textPrimary, INTERACTION_HOVER_STRENGTH)), + ); + expect(result[`color.background.${role}.solid.pressed`]).toBe( + formatOklch(mixInteractionState(solid, textPrimary, INTERACTION_PRESSED_STRENGTH)), + ); + expect(result[`color.foreground.${role}.rest`]).toBe(formatOklch(foreground)); + expect(result[`color.foreground.${role}.hover`]).toBe( + formatOklch(mixInteractionState(foreground, textPrimary, INTERACTION_HOVER_STRENGTH)), + ); + expect(result[`color.foreground.${role}.pressed`]).toBe( + formatOklch(mixInteractionState(foreground, textPrimary, INTERACTION_PRESSED_STRENGTH)), + ); expect(result[`color.foreground.${role}.onSolid`]).toBe(formatOklch(family.contrast)); - expect(result[`color.border.${role}`]).toBe(formatOklch(family[7])); + expect(result[`color.border.${role}`]).toBe(formatOklch(family[FAMILY_RUNG.border])); } }); } }); describe('completeness', () => { - // Every `color.*` leaf, including the passed-through `color.scrim`. + // Every `color.*` leaf, including the passed-through `color.overlay.backdrop`. const colourPaths = flattenThemeContract() .map(([path]) => path) .filter((path) => path.startsWith('color.')); @@ -132,10 +172,10 @@ describe('mapSemanticColors', () => { const surfaces = generateSurfaces({ background, mode }); const result = mapSemanticColors({ + backdrop: 'oklch(0 0 0 / 0.45)', controlBorder: CONTROL_BORDER[mode], families, focus: FOCUS, - scrim: 'oklch(0 0 0 / 0.45)', surfaces, }); @@ -147,22 +187,22 @@ describe('mapSemanticColors', () => { } }); - describe('scrim', () => { - it('passes the authored scrim value through verbatim, alpha channel included', () => { + describe('backdrop', () => { + it('passes the authored backdrop value through verbatim, alpha channel included', () => { const background = BACKGROUND.light; const families = buildFamilies('light', background); const surfaces = generateSurfaces({ background, mode: 'light' }); - const scrim = 'oklch(0 0 0 / 0.5)'; + const backdrop = 'oklch(0 0 0 / 0.5)'; const result = mapSemanticColors({ + backdrop, controlBorder: CONTROL_BORDER.light, families, focus: FOCUS, - scrim, surfaces, }); - expect(result['color.scrim']).toBe(scrim); + expect(result['color.overlay.backdrop']).toBe(backdrop); }); }); }); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts index e5dcebcd..65e5ae85 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.ts @@ -1,8 +1,8 @@ /** * The one default semantic colour mapping. `mapSemanticColors` aliases every generated colour - * contract leaf onto a private scale family's step or a generated surface, per the locked mapping - * table. It is a pure lookup. No colour math happens here, and it never distorts a family or - * surface to make a leaf fit. + * contract leaf onto a private scale family's step, a generated surface, or a generated interaction + * state. Resting role colours are a lookup. Hover and pressed colours mix that rest colour toward + * `text.primary` at fixed strengths. The map never distorts a family or surface to make a leaf fit. * * The role list it keys off comes from `contrast-policy.ts`, which `build-theme.ts`'s validation * matrix reads too, so a role can never be emitted here without being gated there. Values are @@ -17,6 +17,12 @@ 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'; +import { + FAMILY_RUNG, + INTERACTION_HOVER_STRENGTH, + INTERACTION_PRESSED_STRENGTH, + mixInteractionState, +} from './scale.js'; /** Every generated colour contract leaf's CSS value, keyed by its dotted path. */ export type SemanticColorValues = { @@ -36,6 +42,11 @@ type FunctionalColorValues = { /** The inputs to {@link mapSemanticColors}. */ interface MapSemanticColorsRequest { + /** + * `color.overlay.backdrop`'s authored value, passed through verbatim (it may carry an alpha + * channel). + */ + backdrop: string; /** * `color.border.control`'s solved value is a dedicated contrast boundary, not a scale-step alias. * `control-border.ts`'s `solveControlBorder` resolves it against `surfaces.canvas` and @@ -46,16 +57,16 @@ interface MapSemanticColorsRequest { families: Record; /** 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. */ surfaces: GeneratedSurfaces; } /** * 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` is the resolved keyboard-focus source colour. + * semantic mapping table. `families` and `surfaces` are already mode-resolved. `backdrop` passes + * through verbatim. `focus` is the resolved keyboard-focus source colour. Neutral + * {@link FAMILY_RUNG.textPrimary} becomes `text.primary` and is the colour hover and pressed states + * mix toward. */ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticColorValues { return { @@ -65,19 +76,19 @@ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticCo } function mapFunctionalColors(request: MapSemanticColorsRequest): FunctionalColorValues { - const { families, surfaces, scrim, focus, controlBorder } = request; + const { families, surfaces, backdrop, focus, controlBorder } = request; const neutral = families.neutral; 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.overlay.backdrop': backdrop, + 'color.loadingSkeleton': formatOklch(neutral[FAMILY_RUNG.muted]), + 'color.text.primary': formatOklch(neutral[FAMILY_RUNG.textPrimary]), + 'color.text.secondary': formatOklch(neutral[FAMILY_RUNG.foreground]), + 'color.text.disabled': formatOklch(neutral[FAMILY_RUNG.muted]), + 'color.border.decorative': formatOklch(neutral[FAMILY_RUNG.decorative]), 'color.border.control': formatOklch(controlBorder), 'color.border.focus': formatOklch(focus), }; @@ -86,23 +97,34 @@ function mapFunctionalColors(request: MapSemanticColorsRequest): FunctionalColor function mapRoleColorValues(families: Record): { [Path in RoleColorPath]: string; } { + const textPrimary = families.neutral[FAMILY_RUNG.textPrimary]; return pathRecord( SEMANTIC_ROLES.flatMap((role) => { const family = families[role]; + const subtle = interactionStates(family[FAMILY_RUNG.subtle], textPrimary); + const solid = interactionStates(family[FAMILY_RUNG.solid], textPrimary); + const foreground = interactionStates(family[FAMILY_RUNG.foreground], textPrimary); 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.background.${role}.subtle.rest`, subtle.rest), + pathEntry(`color.background.${role}.subtle.hover`, subtle.hover), + pathEntry(`color.background.${role}.subtle.pressed`, subtle.pressed), + pathEntry(`color.background.${role}.solid.rest`, solid.rest), + pathEntry(`color.background.${role}.solid.hover`, solid.hover), + pathEntry(`color.background.${role}.solid.pressed`, solid.pressed), + pathEntry(`color.foreground.${role}.rest`, foreground.rest), + pathEntry(`color.foreground.${role}.hover`, foreground.hover), + pathEntry(`color.foreground.${role}.pressed`, foreground.pressed), pathEntry(`color.foreground.${role}.onSolid`, formatOklch(family.contrast)), - pathEntry(`color.border.${role}`, formatOklch(family[7])), + pathEntry(`color.border.${role}`, formatOklch(family[FAMILY_RUNG.border])), ]; }), ); } + +function interactionStates(rest: Oklch, toward: Oklch) { + return { + rest: formatOklch(rest), + hover: formatOklch(mixInteractionState(rest, toward, INTERACTION_HOVER_STRENGTH)), + pressed: formatOklch(mixInteractionState(rest, toward, INTERACTION_PRESSED_STRENGTH)), + }; +} diff --git a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx index 44781cae..e19408fb 100644 --- a/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx +++ b/packages/@luke-ui/react/src/theme/theme-diagnostics-inspector.tsx @@ -293,14 +293,15 @@ function SolidAnchorSection({ families }: { families: RecordResolved L Band Adapted - On-solid vs solid + On-solid vs rest On-solid vs hover + On-solid vs pressed Satisfied {FAMILY_ROLES.map((role) => { - const { solidAnchor } = families[role]; + const { onSolid, solidAnchor } = families[role]; return (
{role} @@ -310,8 +311,9 @@ function SolidAnchorSection({ families }: { families: Record{`[${solidAnchor.band[0].toFixed(2)}, ${solidAnchor.band[1].toFixed(2)}]`} {solidAnchor.adaptedForOnSolid ? 'yes' : 'no'} - {`${solidAnchor.onSolidRatioSolid.toFixed(2)}:1`} - {`${solidAnchor.onSolidRatioSolidHover.toFixed(2)}:1`} + {`${onSolid.ratioRest.toFixed(2)}:1`} + {`${onSolid.ratioHover.toFixed(2)}:1`} + {`${onSolid.ratioPressed.toFixed(2)}:1`} {solidAnchor.satisfied ? 'yes' : 'no'}
); diff --git a/packages/@luke-ui/react/src/theme/token-board.test.ts b/packages/@luke-ui/react/src/theme/token-board.test.ts index 87c91834..e611a51b 100644 --- a/packages/@luke-ui/react/src/theme/token-board.test.ts +++ b/packages/@luke-ui/react/src/theme/token-board.test.ts @@ -23,14 +23,19 @@ describe('buildTokenTree', () => { expect([...leavesByPath.entries()].sort(byPath)).toEqual([...contractPairs].sort(byPath)); }); - it('nests a top-level colour leaf under its family group, with no intermediate group', () => { + it('nests overlay colour leaves under the overlay group, with backdrop as the only leaf', () => { const tree = buildTokenTree(); const color = tree.children.color; if (color?.kind !== 'group') throw new Error('expected a color group'); - expect(color.children.scrim).toEqual({ - kind: 'leaf', - path: 'color.scrim', - varName: '--luke-color-scrim', + expect(color.children.overlay).toEqual({ + kind: 'group', + children: { + backdrop: { + kind: 'leaf', + path: 'color.overlay.backdrop', + varName: '--luke-color-overlay-backdrop', + }, + }, }); }); diff --git a/packages/@luke-ui/react/src/theme/token-board.tsx b/packages/@luke-ui/react/src/theme/token-board.tsx index 92551948..cff93989 100644 --- a/packages/@luke-ui/react/src/theme/token-board.tsx +++ b/packages/@luke-ui/react/src/theme/token-board.tsx @@ -240,6 +240,20 @@ interface LeafPreviewProps { type PreviewRenderer = (props: LeafPreviewProps) => ReactNode; function ColorPreview({ path, varName }: LeafPreviewProps) { + if (path === 'color.overlay.backdrop') { + return ( +
+ ); + } + return ( { ); }); - it('rejects an unsafe scrim value with a message naming the field', () => { - const unsafeScrim: ThemeFoundation = { + it('rejects an unsafe backdrop value with a message naming the field', () => { + const unsafeBackdrop: ThemeFoundation = { ...tactileFoundation, light: { ...tactileFoundation.light, - color: { ...tactileFoundation.light.color, scrim: 'oklch(0 0 0 / 0.2); } .evil {' }, + color: { ...tactileFoundation.light.color, backdrop: 'oklch(0 0 0 / 0.2); } .evil {' }, }, - name: 'unsafe-scrim', + name: 'unsafe-backdrop', }; - expect(() => buildTheme(unsafeScrim)).toThrow( - 'light.color.scrim: must be a non-empty CSS colour value', + expect(() => buildTheme(unsafeBackdrop)).toThrow( + 'light.color.backdrop: must be a non-empty CSS colour value', ); }); diff --git a/packages/@luke-ui/react/src/theme/validate-foundation.ts b/packages/@luke-ui/react/src/theme/validate-foundation.ts index 3ea9925c..524e53cd 100644 --- a/packages/@luke-ui/react/src/theme/validate-foundation.ts +++ b/packages/@luke-ui/react/src/theme/validate-foundation.ts @@ -13,7 +13,7 @@ import { getThemeClassName } from './theme-class-name.js'; * Whether a value is unsafe to emit verbatim into the generated stylesheet: anything other than a * non-empty string, or a string containing a statement-breaking character (`;`, `{`, `}`). Shared by * every authored-but-unparsed CSS value — the depth box-shadow rungs, the action-control-finish - * background-images, and the scrim colour (deliberately excluded from OKLCH colour parsing because + * background-images, and the backdrop colour (deliberately excluded from OKLCH colour parsing because * its alpha channel does not fit that pattern) — so the rule has one home. Checking `typeof value` * rather than assuming a string keeps this guard correct even when a caller other than `defineTheme` * hands `buildTheme` a foundation with a rung explicitly set to `undefined`. @@ -58,8 +58,8 @@ export function validateFoundation(foundation: ThemeFoundation): void { issues.push(`${mode}.color.${field}: must be an OKLCH colour with lightness 0-1`); } } - if (isUnsafeCssValue(modeFoundation.color.scrim)) { - issues.push(`${mode}.color.scrim: must be a non-empty CSS colour value`); + if (isUnsafeCssValue(modeFoundation.color.backdrop)) { + issues.push(`${mode}.color.backdrop: must be a non-empty CSS colour value`); } for (const [name, value] of Object.entries(modeFoundation.depth)) { if (isUnsafeCssValue(value)) {