From 62b384b6e761ebd70e36077140e9eb13c80345fb Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 26 Jul 2026 11:37:32 +1000 Subject: [PATCH] Fix the theming and styling docs that no longer match the code (#276) * Fix the theming and styling docs that no longer match the code Every entry point into the theme and styling layers misled a reader, because the docs were written before the colour engine was rewritten. AGENTS.md tells agents to read these docs before changing code, so a wrong doc generates wrong code. foundation.ts and define-theme.ts both said the background input is not consumed and that the canvas still comes from neutral. buildTheme now takes background directly as the canvas anchor for every family's ramp and for the elevation surfaces, so anyone tracing the canvas was sent to the wrong module. The same overclaim had also leaked into the public JSDoc in theme/index.tsx, which promised a single-value-accent throw and listed an incomplete set of gates for ThemeContrastError. Internal stage numbers are gone repo-wide, per the documentation guide. The authoring page told theme authors that non-text pairs must reach 3:1. That is not what the compiler does. It now separates what is guaranteed from what is only measured: text and on-solid pairs are hard-gated at 4.5:1, border.focus and border.control at 3:1, the five intent border pairs are measured but cannot fail the build, and decorative borders are never checked at all. An advisory border must never be the only cue for a state, so the page says so. color.background and ThemeGenerationError are documented for the first time. The page no longer promises a "no accessible lightness" error for a single-value accent. A sweep of the on-solid gate at the accent adaptation targets found no failing hue or chroma, so the search loop and its throw are unreachable under the current constants and the error cannot fire. Authors are pointed at the reachable path instead. Whether to keep that search as defence-in-depth or delete it is an accent-policy decision, left alone here. The styling guide told authors to call recipeInLayer, which no longer exists, and never documented the recipe engine they actually have to use. It now covers single-part and slotted recipes, how variant types are derived rather than hand-maintained, and the shared input-state selectors. The testing guide's visual test example used a Button tone that does not exist, so the canonical template did not compile. Docs and comments only: no non-comment source line changed. * Make theming and styling copy easier to read --- apps/docs/content/docs/theming/authoring.mdx | 59 ++++++++- docs/STYLING.md | 115 +++++++++++++++++- docs/TESTING.md | 2 +- docs/THEME_COLOUR_GENERATION.md | 68 +++++------ .../react/src/theme/build-theme.test.ts | 14 +-- .../@luke-ui/react/src/theme/build-theme.ts | 28 ++--- .../react/src/theme/contrast-policy.ts | 16 +-- .../react/src/theme/define-theme.test.ts | 8 +- .../@luke-ui/react/src/theme/define-theme.ts | 11 +- .../@luke-ui/react/src/theme/diagnostics.ts | 6 +- .../@luke-ui/react/src/theme/foundation.ts | 5 +- packages/@luke-ui/react/src/theme/index.tsx | 15 +-- .../@luke-ui/react/src/theme/scale.test.ts | 8 +- packages/@luke-ui/react/src/theme/scale.ts | 6 +- .../react/src/theme/semantic-map.test.ts | 8 +- .../@luke-ui/react/src/theme/semantic-map.ts | 12 +- .../src/theme/theme-diagnostics.stories.tsx | 9 +- 17 files changed, 273 insertions(+), 117 deletions(-) diff --git a/apps/docs/content/docs/theming/authoring.mdx b/apps/docs/content/docs/theming/authoring.mdx index e1215f3e..5419d500 100644 --- a/apps/docs/content/docs/theming/authoring.mdx +++ b/apps/docs/content/docs/theming/authoring.mdx @@ -16,6 +16,8 @@ A basic theme authors an accent colour and a neutral character; everything else - `color.accent`, the required brand or interaction accent - `color.neutral` or `color.neutralStyle` (`'cool'`, `'neutral'`, or `'warm'`) for the neutral canvas anchor +- 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` - optional `typography`, `radius`, `depth`, and `actionControlFinish` overrides @@ -56,13 +58,58 @@ Applications are responsible for loading any non-system font files selected by t ## Contrast validation -`defineTheme` calculates the generated colour values in OKLCH, maps them to the sRGB gamut, and -checks WCAG 2.2 AA contrast. Text pairs must reach 4.5:1. Non-text UI pairs must reach 3:1. A -single-value accent that has no accessible lightness in a mode throws before contrast validation -runs; author an explicit `{ light, dark }` accent instead. +`defineTheme` calculates the generated colour values in OKLCH, maps them to the sRGB gamut, and runs +a WCAG 2.2 validation matrix over the result. Some pairs are hard requirements, some are measured +only, and one category is not checked. -When validation fails, `defineTheme` throws `ThemeContrastError`. Its `failures` array identifies -each colour mode and token pair, so the stylesheet is not written with an invalid combination. +### Guaranteed at 4.5:1 + +Every text/surface pair the compiler emits is a hard gate: `color.text.primary` and +`color.text.secondary` against all four elevation surfaces (`canvas`, `recessed`, `floating`, +`overlay`), `color.text.primary` against the neutral subtle trio, each action or border/text +intent's `text` (and accent's `textHover`) against the base surfaces and its own subtle trio, +feedback intents' `text` against the base surfaces and their subtle surface, and every intent's +`onSolid` text against its solid, solidHover, and solidPressed surfaces. + +### Guaranteed at 3:1 + +Two non-text pairs are also hard gates: `color.border.focus` and `color.border.control` must both +reach 3:1 against the canvas and recessed surfaces. `border.control` is a dedicated solved colour +for this boundary, not a scale-step alias that happens to pass. + +If any hard gate fails, `defineTheme` throws `ThemeContrastError`. Its `failures` array identifies +each colour mode and token pair, so the stylesheet is never written with an invalid combination. + +### Measured only, cannot fail the build + +The five `color.intent.*.border` pairs (`accent`, `danger`, `info`, `success`, `warning`) are +checked against the base surfaces at 3:1, but only as advisory diagnostics. A miss is recorded and +reported, but never thrown. These borders use the same subtle scale step as ordinary separators, so +an author can choose a neutral character whose intent borders read as gentle separators rather than +assertive boundaries. Diagnostics record whether each check is a hard gate or advisory-only +(`ContrastCheck.hard`), so tooling can tell the two apart without guessing from the token path. + +Because these pairs are not guaranteed to reach 3:1, do not rely on an intent border alone to +communicate an error, warning, or success state. Pair it with text, an icon, or a +guaranteed-contrast surface change so the state is still legible for a theme whose intent border +happens to measure under 3:1. + +### Never checked + +`color.border.decorative` has no contrast check at all, in any mode. Treat it as a pure visual +separator, never as the sole signal for state or meaning. + +### When generation itself fails + +Separately from contrast validation, `compileTheme` (and so `defineTheme`) can throw +`ThemeGenerationError` when a role that must guarantee on-solid text cannot reach an accessible +solid colour. For a single-value accent, `defineTheme` holds the hue and chroma while searching for +a lightness that clears the same on-solid gate the generator enforces internally. An explicit +`{ light, dark }` accent skips that adaptation and is used verbatim for each side. If its authored +lightness sits in an on-solid dead zone (no near-white or near-black text reaches 4.5:1 against it), +`compileTheme` throws `ThemeGenerationError` naming the failing role and mode. Author an explicit +`{ light, dark }` accent when you need exact control over its lightness. Otherwise, author a single +value and let `defineTheme` adapt it. ## Next steps diff --git a/docs/STYLING.md b/docs/STYLING.md index 0613fd9e..c41c8d91 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -12,6 +12,12 @@ element. Neither step injects styles at runtime. - `styles/reset.css.ts`: reset scoped to `.luke-ui-reset`. - `styles/theme-root.css.ts`: base typography and text colour scoped to `.luke-ui-theme`. - `recipes/`: component recipes exported from `@luke-ui/react/recipes`. +- `recipes/recipe.ts`: the internal `recipe()` engine shared by every component recipe, plus the + `RecipeSelection` helper that derives a recipe's variant type. +- `recipes/input-states.ts`: the shared field control-state selectors (`inputStates`, + `composeInputStateSelectors`, `descendantDisabledSelector`) field recipes compose and extend. It + is named `.ts`, not `.css.ts`, because it emits no CSS. Each field recipe's `.css.ts` module + composes its plain data and functions. - `styles/`: public layout utilities exported from `@luke-ui/react/styles`. - `theme/contract.ts`: the semantic token tree, its `--luke-*` variable naming, and the source-owned `fontSizeSteps` typography step keys. @@ -26,7 +32,7 @@ element. Neither step injects styles at runtime. groups the generator, the compiler's validation matrix, and the semantic map all read. - `theme/scale.ts`: the private 12-step family generator (`generateFamily`), including the constrained step-9 solid-anchor search, the per-role capability guarantees, and - `passesOnSolidGate` — the one on-solid accessibility gate. + `passesOnSolidGate`, the on-solid accessibility gate. - `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 @@ -106,11 +112,17 @@ specificity. | `recipes` | Component styles, variants, and compound variants. | | `utilities` | One-off layout and override escape hatches. | -Use `styleInLayer`, `recipeInLayer`, and `globalStyleInLayer` from `styles/layered-style.css.ts`. -These helpers keep styles inside a named layer. +Use `styleInLayer` and `globalStyleInLayer` from `styles/layered-style.css.ts` to place a plain +Vanilla Extract style for a recipe with no variants in a named layer (see +`recipes/loading-skeleton.css.ts`). A variant-driven recipe instead calls `recipe()` from +`recipes/recipe.ts`, which wraps every base, variant, and compound-variant style it is given in the +`recipes` layer. A recipe can still pre-build a static `base` with `styleInLayer('recipes', …)` and +hand the resulting class string to `recipe()`, which passes a string value through unchanged rather +than wrapping it again. Text's Capsize trim declarations use logical properties for the pseudo-element margins and are -emitted through `recipeInLayer`, so they remain owned by `recipes` with the rest of the Text recipe. +authored as one of the Text recipe's `recipe()` compound-variant styles, so they remain owned by +`recipes` through that same layering rather than a dedicated helper. Overrides that should beat component recipes belong in the `utilities` layer. Use `!important` only when a style must also beat consumer un-layered styles or inline styles. Layers cannot beat those. @@ -136,6 +148,101 @@ import { button, link } from '@luke-ui/react/recipes'; Recipes are component-specific. Keep them separate from general layout utilities. +Every recipe is built with the internal `recipe()` engine from `recipes/recipe.ts`. It is not part +of the public package entry. Component authors inside `@luke-ui/react` use it to define a new +recipe. Consumers only ever call the built recipe functions it returns (`button`, `text`, and so +on). `recipe()` wraps every base, variant, and compound-variant style it is given in the `recipes` +cascade layer itself, so a recipe author does not add layering by hand. + +### Single-part recipes + +A single-part recipe takes `base`, `variants`, `defaultVariants`, and `compoundVariants`, and +returns a function that takes a variant selection and returns one class string: + +```ts +export const button = recipe({ + base, + defaultVariants: { appearance: 'solid', size: 'medium', tone: 'neutral' }, + variants: { + appearance: { ghost: {}, solid: {}, subtle: {} }, + size: { + /* … */ + }, + tone: { accent: {}, danger: {}, neutral: {} }, + }, + compoundVariants: [ + /* … */ + ], +}); +``` + +See `recipes/button.css.ts` for the full recipe this abbreviates. + +### Slotted recipes + +A recipe whose component has multiple styled parts takes `slots` instead of `base`. Each variant +value maps to per-slot styles, and the built recipe takes a variant selection and returns one +function per slot, each accepting an optional extra class to merge: + +```tsx +export const combobox = recipe({ + slots: { control: '…', root: '…', textInput: '…' /* … */ }, + variants: { + /* per-slot styles keyed by variant value */ + }, +} as const satisfies SlottedConfigInput); + +const { root, control } = combobox({ size: 'medium' }); +
+
…
+
; +``` + +See `recipes/combobox.css.ts` for a complete slotted recipe. Apply +`as const satisfies SlottedConfigInput` at the definition site: `as const` preserves the literal +slot names and variant values `recipe()` infers, and `satisfies` type-checks every slot and variant +style against `StyleRule` where it is written. + +Compound variants are single-part only: `button` and `text` both use `compoundVariants` on their +single-part config. A slotted config has no `compoundVariants` field. + +### Deriving variant types + +Never hand-maintain a recipe's variant type. Derive it from the built recipe with +`RecipeSelection`: + +```ts +export type ButtonVariants = RecipeSelection; +``` + +Do not cast a hand-written variant interface onto a recipe's selection parameter. If the exported +type and the recipe definition can drift, something is wrong with how the type was produced, not +with the recipe. + +### Shared input-state selectors + +Field-style recipes (`text-input.css.ts`, `combobox.css.ts`) share one definition of what "hovered", +"focused", "disabled", "invalid", and "read-only" mean for a control, from +`recipes/input-states.ts`: + +```ts +import { + composeInputStateSelectors, + descendantDisabledSelector, + inputStates, +} from './input-states.js'; + +const { disabled, focusWithin, hover, invalid, readOnly } = composeInputStateSelectors(inputStates); +``` + +`inputStates` is the base attribute/pseudo-class selector for each state. +`composeInputStateSelectors` combines them into the mutually exclusive selectors a recipe applies to +its styles (for example, `hover` deliberately excludes an element that is also focused or +read-only). A recipe with a more complex anatomy can widen a state before composing it, the way +`combobox.css.ts` extends `disabled` and `invalid` to also match its trigger button. +`descendantDisabledSelector` styles a part (an adornment or trigger) when an ancestor control is +disabled. + ## Styling utilities Styling utilities are public and exported from `@luke-ui/react/styles`. They provide token-aware, diff --git a/docs/TESTING.md b/docs/TESTING.md index 5329b566..058aefac 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -129,7 +129,7 @@ import { captureVisual, renderVisual } from '../test-utils/render-visual.js'; import { Button } from './index.js'; test('keyboard focus ring', async () => { - const locator = renderVisual(); + const locator = renderVisual(); // Tab so the browser applies `:focus-visible`; a programmatic `.focus()` would not. await userEvent.tab(); await captureVisual(locator, 'button/focus-visible'); diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index 5d8b2ac2..1855e244 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -70,57 +70,57 @@ WCAG 2.2 SC 1.4.11 requires 3:1 contrast only for visual information required to component or state, not every authored border, so v2 splits border tokens into a hard gate and an advisory group: -- **`color.border.control` is a hard gate**, solved as a dedicated contrast boundary (not a - scale-step alias) at ≥3:1 against both `canvas` and `recessed`, in both modes. It is frequently - the sole resting boundary of a form control, so it cannot be left at the softer step-7 aesthetic. -- **`color.border.decorative` and every intent border** (`color.intent.*.border`) are **advisory**: - measured (where measured at all) but not build-gated, and deliberately kept subtle, Radix-style - separators below 3:1. `color.border.focus` is the other hard gate — the keyboard-focus ring stays - build-time validated at ≥3:1. +- `color.border.control` is a hard gate. It is a dedicated contrast boundary, not a scale-step + alias, and must reach ≥3:1 against both `canvas` and `recessed` in both modes. It is frequently + the sole resting boundary of a form control, so it cannot use the softer step-7 aesthetic. +- `color.border.decorative` is not checked. It is a deliberately subtle, Radix-style separator below + 3:1. +- Every intent border (`color.intent.*.border`) is an advisory 3:1 check. A miss does not gate the + build. `color.border.focus` is the other hard gate. The keyboard-focus ring stays build-time + validated at ≥3:1. Component invariant (tracked separately, [#247](https://github.com/lukebennett88/luke-ui/issues/247)): any component that uses an advisory -border as the **sole** cue for a required state (invalid, selected, checked) must add another 3:1 or -non-colour cue — error text or an icon, a border-thickness/shape change, or a separately gated -indicator. Advisory borders were never guaranteed to be visible enough on their own. +border as the sole cue for a required state (invalid, selected, checked) must add another 3:1 or +non-colour cue: error text or an icon, a border-thickness or shape change, or a separately gated +indicator. Advisory borders are not guaranteed to be visible enough on their own. ## Accent policy 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 ever sees it, so the common case never - throws at build time. -- An **explicit per-mode** accent (`accent: { light, dark }`) is used verbatim — the author asked - for exact control. 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 mode. +- 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. +- 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 + mode. ## One gate for on-solid contrast `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: +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. `surface.solidPressed` reuses step 10 and carries the press through depth, finish, and transform instead. `defineTheme`'s `adaptAccent` pre-conditioner calls that same function rather than keeping its own -copy. Two consequences hold structurally, not by two implementations agreeing: - -- The pre-conditioner can never be stricter than the solid-anchor search, so "a single-value accent - never fails the build" holds for one reason instead of two. -- The accent the pre-conditioner picks is the accent the generator emits — the search honours it - verbatim rather than quietly re-solving for a different lightness. - -The pre-conditioner is still load bearing and is not redundant: its adaptation band is deliberately -wider than the generator's tone-faithful window, so it rescues accents (a mid-lightness red, say) -that the generator alone would report as unsatisfiable. - -The shared thresholds themselves — the 4.5 text ratio, the 3:1 non-text ratio, the search headroom -and the search step — are declared once in `contrast-policy.ts`, along with the intent role groups -both the validation matrix and the semantic map key off. Splitting those role lists is what made the -failure asymmetric: a role added to the map alone emitted an ungated colour, while one added to the -compiler alone threw an internal error. +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. + +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. + +`contrast-policy.ts` declares the shared thresholds: the 4.5 text ratio, the 3:1 non-text ratio, the +search headroom, and the search step. It also declares the intent role groups used by both the +validation matrix and the semantic map. 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. ## `loadingSkeleton` 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 cdefc0f0..775e64c3 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -546,9 +546,9 @@ describe('compileTheme diagnostics', () => { 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`. The per-intent borders are the only advisory checks — deliberately subtle - // Radix-style separators below 3:1 (theme-v2 border-contrast policy). The "Theme/Diagnostics" - // inspector splits its tables on this flag rather than pattern-matching token paths. + // `border.control`. The per-intent borders are the only advisory checks. + // `color.border.decorative` is not measured. The "Theme/Diagnostics" inspector uses this flag + // instead of matching token paths. const { diagnostics } = compileTheme(tactileFoundation); const advisoryBorders = ['accent', 'danger', 'info', 'success', 'warning'].map( (intent) => `color.intent.${intent}.border`, @@ -559,8 +559,8 @@ describe('compileTheme diagnostics', () => { 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 have thrown before `compileTheme` returned, so a - // recorded hard check that did not pass would mean the flag disagrees with the compiler. + // 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)), @@ -684,8 +684,8 @@ describe('bundled theme identity', () => { }); // v2 regression goldens: the exact `buildTheme` output for the bundled themes under the wired-in -// scale/elevation/semantic-map pipeline (Stage 6, #238). Asserted byte-identical so any later -// generator change is a reviewed, deliberate diff. +// scale/elevation/semantic-map pipeline (#238). Asserted byte-identical so any later generator +// change is a reviewed, deliberate diff. describe('v2 regression goldens', () => { const v2Goldens = { paper: new URL('./__fixtures__/v2-goldens/paper.v2.css', import.meta.url), diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index f0e77e42..5d943b03 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -320,11 +320,11 @@ function buildModeColors(mode: ColorMode, modeFoundation: ThemeModeFoundation): } /** - * Solves `color.border.control` as a dedicated contrast boundary (Stage 6 Option B), rather than a - * subtle step-7 alias: neutral steps 7-8 land at roughly 1.6-2.7:1 against the base surfaces, well + * 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, lighter in - * dark mode — until the candidate clears 3:1 (plus headroom) against BOTH `canvas` and `recessed`, + * 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. @@ -376,13 +376,13 @@ interface ValidationResult { /** * Runs the full semantic validation matrix over the emitted (rounded) colour values. Every pair is - * recorded as a {@link ContrastCheck}; the AA text/on-solid pairs, the authored focus ring, and + * recorded as a {@link ContrastCheck}. The AA text/on-solid pairs, the authored focus ring, and * `border.control` are hard gates that populate `failures` (which `compileTheme` raises as a * {@link ThemeContrastError}). `border.control` is `solveControlBorder`'s dedicated boundary, not a - * scale-step alias, so it is hard-gated at 3:1 against both base surfaces (Stage 6 Option B). The - * generated neutral/intent borders (decorative and the per-intent border) still map to the - * Radix-style step 6/7 (a subtle separator) and stay advisory checks only — v2 deliberately keeps - * those below the old solver's 3:1 for the reference scale's softer look. + * scale-step alias, so it is hard-gated at 3:1 against both base surfaces. The per-intent borders + * use the Radix-style step 6/7, a subtle separator, and are advisory checks only. + * `color.border.decorative` is not checked. V2 deliberately keeps these borders below the old + * solver's 3:1 for the reference scale's softer look. */ function validateContrast(mode: ColorMode, colorValues: SemanticColorValues): ValidationResult { const failures: Array = []; @@ -395,8 +395,8 @@ function validateContrast(mode: ColorMode, colorValues: SemanticColorValues): Va const check = (foreground: string, background: string, required: number, hard: boolean) => { const ratio = contrastRatio(colorAt(foreground), colorAt(background)); const passes = ratio >= required; - // `hard` is recorded on the check itself, so tooling reads the compiler's own decision instead of - // re-deriving it from token paths. + // `hard` is recorded on the check itself, so tooling reads the compiler's own decision rather + // than re-deriving it from token paths. checks.push({ background, foreground, hard, passes, ratio, required }); if (hard && !passes) failures.push({ background, foreground, mode, ratio, required }); }; @@ -454,9 +454,9 @@ function validateContrast(mode: ColorMode, colorValues: SemanticColorValues): Va } // The keyboard-focus ring is authored and focus-visibility critical, so it stays a hard 3:1 gate. for (const background of basePaths) check('color.border.focus', background, UI_RATIO, true); - // border.control is a solved contrast boundary (Stage 6 Option B): hard-gated at 3:1 against both - // base surfaces in both modes. Decorative and intent borders (step 6/7) stay advisory Radix-style - // separators below 3:1. + // border.control is a solved contrast boundary: hard-gated at 3:1 against both + // base surfaces in both modes. Intent borders use the Radix-style step 6/7 and are advisory checks + // below 3:1. `color.border.decorative` is not checked. for (const background of basePaths) check('color.border.control', background, UI_RATIO, true); for (const intent of [...BORDER_AND_TEXT_INTENTS, ...FEEDBACK_INTENTS]) { for (const background of basePaths) { diff --git a/packages/@luke-ui/react/src/theme/contrast-policy.ts b/packages/@luke-ui/react/src/theme/contrast-policy.ts index d1071ebd..08bce1aa 100644 --- a/packages/@luke-ui/react/src/theme/contrast-policy.ts +++ b/packages/@luke-ui/react/src/theme/contrast-policy.ts @@ -5,13 +5,13 @@ * the build-time validation matrix and `border.control` solver (`build-theme.ts`). * * The intent role groups live here too, because they decide both which contract leaves the semantic - * map emits (`semantic-map.ts`) and which pairs the validation matrix gates (`build-theme.ts`). Split - * across the two modules the failure was asymmetric and half silent: adding a role to the map alone - * emitted an ungated colour, while adding it to the compiler alone threw an internal error. One - * declaration makes both sides move together. + * map emits (`semantic-map.ts`) and which pairs the validation matrix gates (`build-theme.ts`). When + * the lists were split across two modules, failures were asymmetric and only partly reported. Adding + * a role to the map alone emitted an ungated colour, while adding it to the compiler alone threw an + * internal error. One declaration makes both sides move together. * - * Dependency-free on purpose — it is the leaf both the generator and the compiler import, so it can - * never close an import cycle. + * This module has no dependencies. Both the generator and compiler import it, so it cannot close an + * import cycle. */ /** The WCAG 2.2 AA contrast ratio text must clear against the surface behind it. */ @@ -43,7 +43,7 @@ export const ACTION_INTENTS = ['neutral', 'accent', 'danger'] as const; export const FEEDBACK_INTENTS = ['info', 'success', 'warning'] as const; /** - * Action intents that additionally expose a border and low-contrast text. Neutral does not — its - * borders and text are the global neutral leaves instead. + * Action intents that additionally expose a border and low-contrast text. Neutral does not because + * its borders and text are the global neutral leaves instead. */ export const BORDER_AND_TEXT_INTENTS = ['accent', 'danger'] as const; 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 ebbb9d76..e9dda6f9 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts @@ -98,16 +98,16 @@ describe('defineTheme accent pre-conditioning shares the generator gate', () => 'oklch(0.7 0.15 320)', 'oklch(0.6 0.12 160)', 'oklch(0.5 0.2 270)', - // Tones the generator alone cannot reach: their whole tone-faithful window is an on-solid dead - // zone, so only the pre-conditioner's wider band rescues them. + // Tones the generator cannot reach through its tone-faithful window, which is an on-solid dead + // zone. The pre-conditioner's wider band rescues them. 'oklch(0.62 0.19 27)', 'oklch(0.55 0.2 258)', ]; 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, so it can never be stricter than the solver and the solver never quietly re-searches - // what it chose: the emitted solid IS the pre-conditioned accent, not a second guess at it. + // 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) => { return (['light', 'dark'] as const).map((mode) => { const foundation = normalizeTheme({ color: { accent }, name: 'accent-gate' }); diff --git a/packages/@luke-ui/react/src/theme/define-theme.ts b/packages/@luke-ui/react/src/theme/define-theme.ts index 83de6b8b..9a519898 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.ts @@ -259,9 +259,8 @@ function resolveColors(input: ThemeInput, mode: ColorMode): ThemeSourceColors { // 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, - // independent adaptation of the neutral source). Resolved and carried in the foundation only — - // `buildModeColors` still derives its canvas from `neutral`, so output stays byte-identical - // until #235/#236 wire `background` through the generator. + // independent adaptation of the neutral source). `buildModeColors` takes this resolved value + // directly as the canvas anchor for every family's ramp and the elevation surfaces. background: resolveOptionalModeColour(color.background, mode, neutral), // Emitted verbatim; a single string applies to both modes, an omitted side falls back to the // curated mode-aware default. @@ -368,9 +367,9 @@ function sideOf(input: ColorInput, mode: ColorMode): string | undefined { * ({@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, so this pre-conditioner can never 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 the wider of the two, which + * 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. */ function adaptAccent(source: Oklch, mode: ColorMode, raw: string): Oklch { diff --git a/packages/@luke-ui/react/src/theme/diagnostics.ts b/packages/@luke-ui/react/src/theme/diagnostics.ts index 96e74be1..7e42c31f 100644 --- a/packages/@luke-ui/react/src/theme/diagnostics.ts +++ b/packages/@luke-ui/react/src/theme/diagnostics.ts @@ -1,9 +1,9 @@ /** * The theme compiler's diagnostics data model. Family-level diagnostics are populated by the scale - * generator ({@link import('./scale.js').generateFamilyWithDiagnostics}) in Stage 3. The theme-level + * generator ({@link import('./scale.js').generateFamilyWithDiagnostics}). The theme-level * {@link ThemeDiagnostics} (complete, only for a fully compiled theme) and * {@link ThemeGenerationDiagnostics} (partial, for a build that failed part-way) are consumed by - * `compileTheme` / `ThemeGenerationError` in `build-theme.ts` (Stage 6). + * `compileTheme` / `ThemeGenerationError` in `build-theme.ts`. */ import type { Oklch } from './color.js'; @@ -66,7 +66,7 @@ export interface FamilyDiagnostics { /** * One WCAG pair the semantic validation matrix checked while compiling a theme mode. Records the * factual ratio, whether it clears the required minimum, and whether the compiler treats it as a hard - * gate — so tooling reads that classification instead of inferring it from token paths. + * gate. Tooling reads that classification instead of inferring it from token paths. */ export interface ContrastCheck { /** Token path of the foreground colour, for example `color.text.primary`. */ diff --git a/packages/@luke-ui/react/src/theme/foundation.ts b/packages/@luke-ui/react/src/theme/foundation.ts index 2baa8328..6563cfd0 100644 --- a/packages/@luke-ui/react/src/theme/foundation.ts +++ b/packages/@luke-ui/react/src/theme/foundation.ts @@ -111,8 +111,9 @@ export interface ThemeSourceColors { /** * Required. The canvas anchor, resolved per mode from an explicit `background`, an adapted * opposite-mode `background`, or (when `background` is entirely omitted) a copy of the resolved - * `neutral` canvas anchor. Not yet consumed by `buildTheme`, which still derives its canvas - * directly from `neutral`; carried here for the scale/elevation stages (#235/#236) to consume. + * `neutral` canvas anchor. `buildTheme` takes this value directly as the canvas anchor: every + * family's ramp and the elevation surfaces (including `color.surface.canvas`) are generated + * against `background`, not `neutral`. */ background: string; /** Required. The brand or interaction accent colour. */ diff --git a/packages/@luke-ui/react/src/theme/index.tsx b/packages/@luke-ui/react/src/theme/index.tsx index 92592577..fb7b16b2 100644 --- a/packages/@luke-ui/react/src/theme/index.tsx +++ b/packages/@luke-ui/react/src/theme/index.tsx @@ -12,10 +12,12 @@ export { vars } from './contract.css.js'; /** * `themeClassName(name)` returns the identity class for a theme name. `ThemeContrastError` is thrown - * by `defineTheme` when a resolved pair misses WCAG 2.2 AA (4.5:1 for text, 3:1 for the focus ring); - * it carries every failing mode-and-pair in its `failures` array. `ThemeGenerationError` is thrown - * when a role that must guarantee on-solid contrast (an inaccessible explicit per-mode accent, for - * example) cannot reach an accessible solid; it names the failing `role` and `mode`. + * by `defineTheme` when a hard-gated pair misses WCAG 2.2 AA: 4.5:1 for text/on-solid pairs, 3:1 for + * the focus ring and `border.control`. The five `intent.*.border` pairs are measured but advisory + * only and cannot trigger this error. It carries every failing mode-and-pair in its `failures` + * array. `ThemeGenerationError` is thrown when a role that must guarantee on-solid contrast (an + * inaccessible explicit per-mode accent, for example) cannot reach an accessible solid. It names the + * failing `role` and `mode`. */ export { ThemeContrastError, ThemeGenerationError, themeClassName } from './build-theme.js'; @@ -26,9 +28,8 @@ export type { ThemeContrastFailure } from './build-theme.js'; * `defineTheme(input)` is the curated authoring entry point: it normalises a small {@link ThemeInput} * (accent + neutral character, with everything else defaulting) into the per-mode foundation and * compiles it through `buildTheme`. It adapts single-value accents and neutrals per mode, generates - * the radius scale, and merges optional materials over curated defaults. It throws when a - * single-value accent has no accessible lightness, and otherwise throws the same - * {@link ThemeContrastError} as `buildTheme`. + * the radius scale, and merges optional materials over curated defaults. It throws the same + * {@link ThemeContrastError} and {@link ThemeGenerationError} as `buildTheme`. */ export { defineTheme } from './define-theme.js'; diff --git a/packages/@luke-ui/react/src/theme/scale.test.ts b/packages/@luke-ui/react/src/theme/scale.test.ts index 810a9478..e8c08e0e 100644 --- a/packages/@luke-ui/react/src/theme/scale.test.ts +++ b/packages/@luke-ui/react/src/theme/scale.test.ts @@ -295,8 +295,8 @@ describe('the one on-solid gate', () => { 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 - // rejects must be re-searched or reported unsatisfiable. That is the invariant that makes the + // accent adaptation bands. A lightness the gate accepts must be one the search keeps as-is. One it + // rejects must be re-searched or reported unsatisfiable. This invariant makes the // pre-conditioner unable to be stricter than the solver. const disagreements: Array = []; for (const mode of MODES) { @@ -336,8 +336,8 @@ describe('the one on-solid gate', () => { 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. + // 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. const source: Oklch = { c: 0, h: 0, l: 0.64 }; const phantomPressed = { c: 0, h: 0, l: source.l - 0.09 }; const onSolid = family('oklch(0.64 0 0)', 'light', 'accent').contrast; diff --git a/packages/@luke-ui/react/src/theme/scale.ts b/packages/@luke-ui/react/src/theme/scale.ts index d77ccac5..1fca70ad 100644 --- a/packages/@luke-ui/react/src/theme/scale.ts +++ b/packages/@luke-ui/react/src/theme/scale.ts @@ -5,7 +5,7 @@ * search and the capability-based role guarantees; it is calibrated to testable scale properties * rather than to exact Radix reproduction. * - * It also owns {@link passesOnSolidGate}, the one on-solid accessibility gate — `defineTheme`'s accent + * 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`, and never distorts a family to satisfy * a guarantee the public contract does not consume. @@ -209,7 +209,7 @@ const RAMP_SPEC = { // 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. +// to clear. There is no third, deeper pressed colour to test. const SOLID_HOVER_DELTA = 0.05; interface SolidBand { @@ -264,7 +264,7 @@ export interface OnSolidGateRequest { /** * 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 + * 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 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 bb202d73..5d101892 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.test.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.test.ts @@ -91,8 +91,8 @@ describe('mapSemanticColors', () => { expect(result['color.scrim']).toBe(scrim); expect(result['color.loadingSkeleton']).toBe(formatOklch(families.neutral[7])); - // Global text / borders: neutral only. `border.control` is a solved contrast boundary - // (Stage 6 Option B), not a scale-step alias, so it aliases the passed-through value. + // 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])); @@ -147,8 +147,8 @@ describe('mapSemanticColors', () => { }); describe('completeness', () => { - // Every generated colour leaf (every `color.*` path except the passed-through `color.scrim`) - // must receive a value. + // Every generated colour leaf must receive a value. This includes every `color.*` path except the + // passed-through `color.scrim`. const generatedColourPaths = flattenThemeContract().filter( ([path]) => path.startsWith('color.') && path !== 'color.scrim', ); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts index dc37ddc6..e5d9d116 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.ts @@ -1,7 +1,7 @@ /** * 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 + * table. It is a pure lookup. No colour math happens here, and it never distorts a family or * surface to make a leaf fit. * * The intent role groups it keys off come from `contrast-policy.ts`, which `build-theme.ts`'s @@ -29,9 +29,9 @@ interface MapSemanticColorsRequest { /** The generated elevation surface set, already resolved for `mode`. */ surfaces: GeneratedSurfaces; /** - * `color.border.control`'s solved value: a dedicated contrast boundary (Stage 6 Option B), not a - * scale-step alias. Resolved by `build-theme.ts`'s `solveControlBorder` against `surfaces.canvas` - * and `surfaces.recessed` before this map runs; passed through verbatim here. + * `color.border.control`'s solved value is a dedicated contrast boundary, not a scale-step alias. + * `build-theme.ts`'s `solveControlBorder` resolves it against `surfaces.canvas` and + * `surfaces.recessed` before this map runs, then this function passes it through verbatim. */ controlBorder: Oklch; /** The authored scrim value, passed through verbatim (it may carry an alpha channel). */ @@ -44,8 +44,8 @@ interface MapSemanticColorsRequest { /** * Resolves every colour contract leaf onto the private families and surfaces, per the locked - * semantic mapping table. `families`/`surfaces` are already mode-resolved; `scrim` passes through - * verbatim; `focus` defaults to the accent family's step 8 when the theme author omits it. + * semantic mapping table. `families` and `surfaces` are already mode-resolved. `scrim` passes through + * verbatim. `focus` defaults to the accent family's step 8 when the theme author omits it. */ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticColorValues { const { families, surfaces, scrim, focus, controlBorder } = request; diff --git a/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx b/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx index 5fc00430..2a85e2ef 100644 --- a/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx +++ b/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx @@ -190,7 +190,7 @@ const meta = preview.meta({ * Read-only inspector over `compileTheme`'s diagnostics data model for the bundled themes: the * private 12-step families, the generated elevation surfaces, the step-9 solid-anchor search, the * full WCAG 2.2 validation matrix (split into hard gates and advisory-only checks), and any sRGB - * gamut reductions. Not public API — a diagnostic view over an already-built data model. + * gamut reductions. This diagnostic view uses an already-built data model and is not public API. */ export const Inspector = meta.story({ play: async ({ canvas }) => { @@ -216,7 +216,8 @@ function ThemeDiagnosticsInspector() {

Theme diagnostics

The private scale families, generated surfaces, solid-anchor search, and WCAG 2.2 - validation matrix `compileTheme` resolved for a bundled theme. Read-only — not public API. + validation matrix `compileTheme` resolved for a bundled theme. It is read-only and not + public API.

@@ -381,11 +382,11 @@ function ContrastChecksSection({ checks }: { checks: Array }) { return ( -- 2.51.2