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 (