From 18093048994b00e91fa20cdac4960475cd33af90 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Thu, 30 Jul 2026 14:17:05 +1000 Subject: [PATCH] Replace intent with the shared semantic colour contract (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace the intent colour contract with shared semantic roles The public contract exposed an asymmetric `color.intent` tree: neutral, accent, and danger received a full interactive ramp while info, success, and warning received a feedback-only kit. That described how components happened to use colour rather than what each colour styles and means. Replace it with a property-first contract shared by all six roles. Every role now gets subtle and solid background ramps with rest/hover/pressed, a resting and a stronger interactive foreground, a guaranteed on-solid foreground, and one state-free border — 60 leaves keyed off a single canonical `SEMANTIC_ROLES` list that generation, mapping, validation, diagnostics, and tooling all read. `FAMILY_REQUIREMENTS` collapses to one uniform capability set, so all six families are now gated on on-solid contrast where only neutral, accent, and danger were before. This moves no existing colour: not one family needed adapting, and steps 3-8, 11, and 12 generate identically for info, success, and warning. The golden diff is additive only. The validation matrix is now uniform per role: 90 hard checks (8 functional text, 60 role foregrounds, 18 on-solid, 2 focus, 2 control) and 12 advisory semantic-border checks per mode. `rest` is an explicit leaf because a path cannot be both a string leaf and the parent of `hover` and `pressed`. Solid pressed deliberately reuses solid hover; components carry the press through depth, finish, or transform. Refs #298, #299, #305 * Migrate components to the shared semantic colour contract Move every consumer off `color.intent` onto the new background, foreground, and border paths. Each old path has an exact new equivalent resolving to an identical value, so this is visually a no-op: 218 of 222 visual captures are pixel-identical, and the four that differ are Token Board captures whose height changed because the contract grew. Button and IconButton keep their subtle, solid, ghost, hover, pressed, disabled, pending, focus, material, and forced-colours behaviour. Button derives its ramp type from the contract now rather than mirroring a hand-flattened shape. Link uses the resting foreground at rest and the stronger hover foreground for both hover and press, since the shared contract carries no separate pressed content colour. Fields and Combobox controls use the state-free accent and danger borders while the functional focus ring stays independent on `border.focus`. Selection stays a component pattern: selected options compose the accent subtle ramp with a weight change and a check icon rather than a public selected colour token. Disabled and pending controls preserve their resting colours and suppress interaction states. Add the coverage the specification asks for and the suites lacked: Button's subtle ramp and its disabled suppression, a Field browser test for the required indicator and error tone, and Combobox's selected-plus-keyboard-focus composition. Note that the advisory semantic borders sit at 1.62-2.19:1, below the 3:1 non-text gate, so they must never be a required state's only cue. The three pre-existing violations of that rule are unchanged here and remain tracked in #247. Refs #298, #300, #301, #302, #303 * Document the shared semantic colour roles Teach one shared role model instead of action and feedback intent groups: the six roles and their meanings, the background and foreground state grammar, the state-free borders, and the contrast guarantees. Publish the old-to-new migration table, which is now the only place the old paths appear. Replace the intent-colours example with a role-colours example that shows subtle and solid swatches per role, so the page demonstrates that every role carries the full capability set. Migrate the raw CSS consumers in the docs app, and fix a mis-cased `--luke-color-intent-neutral-surface-subtleHover` reference that never resolved against a real generated variable. Correct the generation document: the loading skeleton maps to neutral step 8, as implemented and tested, not step 7. Refs #298, #304 --- apps/docs/content/docs/index.mdx | 2 +- apps/docs/content/docs/overview/color.mdx | 103 ++++- apps/docs/content/docs/overview/styling.mdx | 2 +- apps/docs/content/docs/overview/theme.mdx | 2 +- apps/docs/content/docs/theming/authoring.mdx | 57 +-- .../content/docs/theming/token-reference.mdx | 8 +- .../src/examples/box/responsive-layout.tsx | 4 +- .../src/examples/overview/intent-colours.tsx | 82 ---- .../src/examples/overview/role-colours.tsx | 66 +++ .../src/lib/token-reference-generator.test.ts | 4 +- apps/docs/src/styles/app.css | 6 +- docs/STYLING.md | 14 +- docs/THEME_COLOUR_GENERATION.md | 32 +- .../@luke-ui/react/src/icon/icon.stories.tsx | 4 +- .../react/src/icon/icon.visual.test.tsx | 2 +- .../loading-spinner.stories.tsx | 2 +- .../loading-spinner.visual.test.tsx | 2 +- .../react/src/recipes/button.browser.test.ts | 45 +++ .../@luke-ui/react/src/recipes/button.css.ts | 57 ++- .../react/src/recipes/checkbox.css.ts | 30 +- .../src/recipes/combobox.browser.test.ts | 36 +- .../react/src/recipes/combobox.css.ts | 22 +- .../react/src/recipes/field.browser.test.ts | 84 ++++ .../@luke-ui/react/src/recipes/field.css.ts | 4 +- .../@luke-ui/react/src/recipes/link.css.ts | 10 +- .../react/src/recipes/loading-spinner.css.ts | 10 +- .../react/src/recipes/text-input.css.ts | 8 +- .../@luke-ui/react/src/recipes/text.css.ts | 10 +- .../react/src/styles/theme-root.css.ts | 2 +- .../react/src/styles/utilities.stories.tsx | 2 +- .../__fixtures__/v2-goldens/paper.v2.css | 380 +++++++++++------- .../__fixtures__/v2-goldens/tactile.v2.css | 380 +++++++++++------- .../react/src/theme/build-theme.test.ts | 169 ++++++-- .../@luke-ui/react/src/theme/build-theme.ts | 106 +++-- .../@luke-ui/react/src/theme/contract.css.ts | 4 +- .../@luke-ui/react/src/theme/contract.test.ts | 33 ++ packages/@luke-ui/react/src/theme/contract.ts | 135 +++---- .../react/src/theme/contrast-policy.ts | 36 +- .../react/src/theme/define-theme.test.ts | 45 ++- .../@luke-ui/react/src/theme/define-theme.ts | 8 +- .../@luke-ui/react/src/theme/foundation.ts | 8 +- .../@luke-ui/react/src/theme/foundations.ts | 6 +- packages/@luke-ui/react/src/theme/index.tsx | 6 +- .../@luke-ui/react/src/theme/scale.test.ts | 93 +++-- packages/@luke-ui/react/src/theme/scale.ts | 86 ++-- .../react/src/theme/semantic-map.test.ts | 56 +-- .../@luke-ui/react/src/theme/semantic-map.ts | 53 +-- .../react/src/theme/token-board.test.ts | 21 +- .../@luke-ui/react/src/theme/token-board.tsx | 12 +- 49 files changed, 1433 insertions(+), 916 deletions(-) delete mode 100644 apps/docs/src/examples/overview/intent-colours.tsx create mode 100644 apps/docs/src/examples/overview/role-colours.tsx create mode 100644 packages/@luke-ui/react/src/recipes/field.browser.test.ts diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 5ec7e507..e5a31d15 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -33,7 +33,7 @@ spacing, radii, and depth behind every component. Learn what an identity owns and when to author your own. - Use semantic surfaces and intent roles instead of palette values. + Use semantic surfaces and role-based colours instead of palette values. Follow the system setting or force light and dark scopes. diff --git a/apps/docs/content/docs/overview/color.mdx b/apps/docs/content/docs/overview/color.mdx index ec874623..cc759bba 100644 --- a/apps/docs/content/docs/overview/color.mdx +++ b/apps/docs/content/docs/overview/color.mdx @@ -1,6 +1,7 @@ --- title: Colour -description: Use semantic surfaces, content roles, and intents instead of palette values. +description: + Use semantic surfaces, content roles, and the shared role contract instead of palette values. --- Luke UI names colours by their job. A theme maps those roles to values for each identity and colour @@ -28,22 +29,58 @@ import { vars } from '@luke-ui/react/theme'; Choose the role that describes the element's purpose. Do not copy a resolved colour into application CSS, because it will not adapt when the identity or colour mode changes. -## Intent colours +## Semantic roles -Intent roles cover `neutral`, `accent`, `danger`, `info`, `success`, and `warning`. `neutral`, -`accent`, and `danger` are action intents: they render hover and pressed states across `subtle` and -`solid` surfaces, plus matching text, border, and on-solid values where needed. `info`, `success`, -and `warning` are static feedback intents: they render only a `subtle` surface, `border`, and -`text`, with no hover, pressed, solid, or on-solid state. Components choose these values for their -own states. Custom UI can use the same public roles. +Six roles cover the meanings a component needs beyond plain surfaces and text: `neutral`, `accent`, +`info`, `success`, `warning`, and `danger`. Every role offers the same capabilities, so a role's +meaning never limits what it can do: + +- `neutral`: no accent or status meaning, distinct from ordinary surfaces. +- `accent`: preferred, selected, or emphasised. Not a promise of a brand colour. +- `info`: noteworthy factual context. +- `success`: completed, valid, or a positive result. +- `warning`: needs attention but has not failed and is not dangerous. +- `danger`: harm, failure, invalidity, or a critical condition. + +Components choose the role that fits their own tone or state. Custom UI can use the same six roles. + +### Background and foreground + +Each role has a `subtle` and a `solid` background ramp, and each ramp has `rest`, `hover`, and +`pressed` states: + +```tsx +import { vars } from '@luke-ui/react/theme'; + +vars.color.background.warning.subtle.rest; +vars.color.background.warning.solid.hover; +``` + +`rest` is an explicit state rather than an implied default. A token path cannot be both a string +leaf and the parent of `hover` and `pressed`, so resting values need their own name. `solid.pressed` +deliberately reuses the `solid.hover` colour — there is no third, deeper solid rung. A component +carries a pressed look through depth, finish, or a transform instead of inventing another solid +colour. + +Foreground gives each role a resting and a stronger interactive colour, plus a colour guaranteed to +read against that role's solid backgrounds: + +```tsx +vars.color.foreground.warning.rest; +vars.color.foreground.warning.hover; +vars.color.foreground.warning.onSolid; +``` + +There is no foreground `pressed`. A press is carried by the background ramp and other cues, so text +and icons reuse `hover` for a pressed control. ```tsx import { vars } from '@luke-ui/react/theme';
Payment details need review. @@ -51,11 +88,22 @@ import { vars } from '@luke-ui/react/theme'; ``` +### Borders + +Each role also has one state-free border, for example `vars.color.border.warning`. The token +describes meaning; the component decides when to apply it, such as only while invalid or as a +permanent outline. + +A semantic border is measured for contrast but not guaranteed to reach 3:1 — see +[contrast validation](/theming/authoring#contrast-validation). Never rely on one alone to +communicate a required state such as selected, invalid, or checked. Pair it with text, an icon, or +another cue that is separately contrast-gated. + ## Disabled and loading states Use the component's disabled or loading API when it has one. Those states account for interaction @@ -63,6 +111,35 @@ and accessibility as well as colour. Disabled is a state, not a palette: interac with reduced opacity, and only field text recolors, through the public `text.disabled` role. The public `loadingSkeleton` role is available when you are building an equivalent custom element. +## Migrate from `color.intent` + +`color.intent` was the previous asymmetric contract. `neutral`, `accent`, and `danger` rendered a +full interactive ramp, while `info`, `success`, and `warning` rendered only a static subtle kit. It +has been removed: there are no `color.intent` paths and no `--luke-color-intent-*` variables left to +fall back on. If you are upgrading from a release that had them, this table is the old-to-new +reference — move each usage to its `color.background` / `color.foreground` / `color.border` +equivalent: + +| Old path | New path | +| ------------------------------------------- | ---------------------------------------- | +| `color.intent..surface.subtle` | `color.background..subtle.rest` | +| `color.intent..surface.subtleHover` | `color.background..subtle.hover` | +| `color.intent..surface.subtlePressed` | `color.background..subtle.pressed` | +| `color.intent..surface.solid` | `color.background..solid.rest` | +| `color.intent..surface.solidHover` | `color.background..solid.hover` | +| `color.intent..surface.solidPressed` | `color.background..solid.pressed` | +| `color.intent..text` | `color.foreground..rest` | +| `color.intent..textHover` | `color.foreground..hover` | +| `color.intent..onSolid` | `color.foreground..onSolid` | +| `color.intent..border` | `color.border.` | + +CSS variable names followed the same shape, so `--luke-color-intent-danger-surface-solid-hover` +became `--luke-color-background-danger-solid-hover`. + +The current contract also carries capabilities `color.intent` never had: a foreground and border for +`neutral`, and interactive backgrounds, hover foregrounds, and on-solid foregrounds for `info`, +`success`, and `warning`. + ## Continue learning diff --git a/apps/docs/content/docs/overview/styling.mdx b/apps/docs/content/docs/overview/styling.mdx index ac42625d..a37e5e8c 100644 --- a/apps/docs/content/docs/overview/styling.mdx +++ b/apps/docs/content/docs/overview/styling.mdx @@ -163,7 +163,7 @@ selectors or implementation attributes. Use Box and Sprinkles for responsive structure. - Choose semantic surfaces and intent roles for custom UI. + Choose semantic surfaces and roles for custom UI. Browse every public semantic CSS variable. diff --git a/apps/docs/content/docs/overview/theme.mdx b/apps/docs/content/docs/overview/theme.mdx index e7d97b90..8e1c75c8 100644 --- a/apps/docs/content/docs/overview/theme.mdx +++ b/apps/docs/content/docs/overview/theme.mdx @@ -66,7 +66,7 @@ generated palette, or component recipe selectors. Compare identity, colour mode, custom themes, and public tokens. - Learn the semantic roles behind surfaces, content, and intents. + Learn the semantic roles behind surfaces, content, and borders. Use the type scale supplied by the active theme. diff --git a/apps/docs/content/docs/theming/authoring.mdx b/apps/docs/content/docs/theming/authoring.mdx index 5419d500..c214da7c 100644 --- a/apps/docs/content/docs/theming/authoring.mdx +++ b/apps/docs/content/docs/theming/authoring.mdx @@ -64,35 +64,40 @@ only, and one category is not checked. ### 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. +Every text/surface pair the compiler emits is a hard gate, 86 checks in total: + +- `color.text.primary` and `color.text.secondary` against all four elevation surfaces (`canvas`, + `recessed`, `floating`, `overlay`): 8 checks. +- For each of the six semantic roles (`neutral`, `accent`, `info`, `success`, `warning`, `danger`), + its foreground `rest` and `hover` against `canvas`, `recessed`, and that role's own `subtle` rest, + hover, and pressed backgrounds: 60 checks. +- Each role's `onSolid` foreground against its `solid` rest, hover, and pressed backgrounds: 18 + checks. ### 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. +Four more pairs are hard gates, at the WCAG 2.2 non-text ratio: `color.border.focus` and +`color.border.control`, each 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. That brings +the total to 90 hard gates across both ratios. 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. +Each role's border (`color.border.neutral`, `color.border.accent`, and so on for all six roles) is +checked against the canvas and recessed surfaces at 3:1, but only as advisory diagnostics — 12 +checks. 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 role 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. +Because these pairs are not guaranteed to reach 3:1, never rely on a role border alone to +communicate a required state such as selected, invalid, or checked. Pair it with text, an icon, or a +guaranteed-contrast surface change so the state stays legible for a theme whose role border happens +to measure under 3:1. ### Never checked @@ -103,13 +108,13 @@ separator, never as the sole signal for state or meaning. 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. +solid colour. Every one of the six roles is gated this way. 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/apps/docs/content/docs/theming/token-reference.mdx b/apps/docs/content/docs/theming/token-reference.mdx index fd1d156a..c7b5340f 100644 --- a/apps/docs/content/docs/theming/token-reference.mdx +++ b/apps/docs/content/docs/theming/token-reference.mdx @@ -4,8 +4,8 @@ description: Public semantic CSS variables for building custom Luke UI elements. --- `vars` is Luke UI's typed public token contract. Each path resolves to a stable `--luke-*` CSS -variable. For example, `vars.color.intent.danger.surface.solidHover` resolves to -`var(--luke-color-intent-danger-surface-solid-hover)`. +variable. For example, `vars.color.background.danger.solid.hover` resolves to +`var(--luke-color-background-danger-solid-hover)`. Use these variables when a component API or layout utility cannot express a custom element. They adapt to the active identity and colour mode. Generated palette values, component selectors, and @@ -20,8 +20,8 @@ other implementation details are not part of the public styling API. /> Choose tokens by purpose. A card might use `surface.floating`, `radius.surface`, and -`depth.resting`. A warning message might use the warning intent's subtle surface and text. Avoid -carrying resolved colour values into application CSS. +`depth.resting`. A warning message might use the warning role's subtle background and foreground. +Avoid carrying resolved colour values into application CSS. ## Tokens diff --git a/apps/docs/src/examples/box/responsive-layout.tsx b/apps/docs/src/examples/box/responsive-layout.tsx index 3209d819..e611bf26 100644 --- a/apps/docs/src/examples/box/responsive-layout.tsx +++ b/apps/docs/src/examples/box/responsive-layout.tsx @@ -19,7 +19,7 @@ function Item() { justifyContent="center" padding="400" style={{ - backgroundColor: vars.color.intent.neutral.surface.solid, + backgroundColor: vars.color.background.neutral.solid.rest, flex: 1, minBlockSize: '4rem', }} @@ -27,7 +27,7 @@ function Item() { Item diff --git a/apps/docs/src/examples/overview/intent-colours.tsx b/apps/docs/src/examples/overview/intent-colours.tsx deleted file mode 100644 index 325399b5..00000000 --- a/apps/docs/src/examples/overview/intent-colours.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Box } from '@luke-ui/react/box'; -import { Button } from '@luke-ui/react/button'; -import { Text } from '@luke-ui/react/text'; -import { vars } from '@luke-ui/react/theme'; -import { useState } from 'react'; - -const intents = { - neutral: { - border: vars.color.border.decorative, - label: 'Neutral', - surface: vars.color.intent.neutral.surface.subtle, - text: vars.color.text.primary, - }, - accent: { - border: vars.color.intent.accent.border, - label: 'Accent', - surface: vars.color.intent.accent.surface.subtle, - text: vars.color.intent.accent.text, - }, - success: { - border: vars.color.intent.success.border, - label: 'Success', - surface: vars.color.intent.success.surface.subtle, - text: vars.color.intent.success.text, - }, - info: { - border: vars.color.intent.info.border, - label: 'Info', - surface: vars.color.intent.info.surface.subtle, - text: vars.color.intent.info.text, - }, - warning: { - border: vars.color.intent.warning.border, - label: 'Warning', - surface: vars.color.intent.warning.surface.subtle, - text: vars.color.intent.warning.text, - }, - danger: { - border: vars.color.intent.danger.border, - label: 'Danger', - surface: vars.color.intent.danger.surface.subtle, - text: vars.color.intent.danger.text, - }, -} as const; - -type Intent = keyof typeof intents; - -export default function IntentColoursExample() { - const [intent, setIntent] = useState('info'); - const selectedIntent = intents[intent]; - - return ( - - - {(Object.keys(intents) as Array).map((option) => ( - - ))} - - - - {selectedIntent.label} - - - - ); -} diff --git a/apps/docs/src/examples/overview/role-colours.tsx b/apps/docs/src/examples/overview/role-colours.tsx new file mode 100644 index 00000000..bf997435 --- /dev/null +++ b/apps/docs/src/examples/overview/role-colours.tsx @@ -0,0 +1,66 @@ +import { Box } from '@luke-ui/react/box'; +import { Button } from '@luke-ui/react/button'; +import { Text } from '@luke-ui/react/text'; +import { vars } from '@luke-ui/react/theme'; +import { useState } from 'react'; + +const roles = { + neutral: 'Neutral', + accent: 'Accent', + info: 'Info', + success: 'Success', + warning: 'Warning', + danger: 'Danger', +} as const; + +type Role = keyof typeof roles; + +export default function RoleColoursExample() { + const [role, setRole] = useState('info'); + + return ( + + + {(Object.keys(roles) as Array).map((option) => ( + + ))} + + + + + Subtle + + + + + Solid + + + + + ); +} diff --git a/apps/docs/src/lib/token-reference-generator.test.ts b/apps/docs/src/lib/token-reference-generator.test.ts index 9d222447..1a8cc47f 100644 --- a/apps/docs/src/lib/token-reference-generator.test.ts +++ b/apps/docs/src/lib/token-reference-generator.test.ts @@ -5,7 +5,9 @@ test('generates documented leaf token mappings from the public contract', () => const reference = generateTokenReference(); expect(reference).toContain("'color.surface.canvas': 'var(--luke-color-surface-canvas)';\n"); - expect(reference).toContain('Semantic colours for surfaces, content, borders, loading'); + expect(reference).toContain( + 'Semantic colours for surfaces, content, borders, and loading, plus the six shared semantic roles', + ); expect(reference).toContain("'motion.easing.exit': 'var(--luke-motion-easing-exit)';\n"); expect(reference).not.toContain('MapLeafNodes'); }); diff --git a/apps/docs/src/styles/app.css b/apps/docs/src/styles/app.css index d414ff60..185ea756 100644 --- a/apps/docs/src/styles/app.css +++ b/apps/docs/src/styles/app.css @@ -21,11 +21,11 @@ --color-fd-card: var(--luke-color-surface-resting); --color-fd-card-foreground: var(--luke-color-text-primary); --color-fd-border: var(--luke-color-border-decorative); - --color-fd-primary: var(--luke-color-intent-accent-surface-solid); - --color-fd-primary-foreground: var(--luke-color-intent-accent-on-solid); + --color-fd-primary: var(--luke-color-background-accent-solid-rest); + --color-fd-primary-foreground: var(--luke-color-foreground-accent-on-solid); --color-fd-secondary: var(--luke-color-surface-raised); --color-fd-secondary-foreground: var(--luke-color-text-primary); - --color-fd-accent: var(--luke-color-intent-neutral-surface-subtleHover); + --color-fd-accent: var(--luke-color-background-neutral-subtle-hover); --color-fd-accent-foreground: var(--luke-color-text-primary); --color-fd-ring: var(--luke-color-border-focus); --color-fd-overlay: var(--luke-color-surface-overlay); diff --git a/docs/STYLING.md b/docs/STYLING.md index 0e04ba0d..24ca82c0 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -28,8 +28,8 @@ element. Neither step injects styles at runtime. - `theme/foundation.ts`: the internal typed theme-foundation shape `defineTheme` normalises into and the curated colour, radius, and typography defaults. - `theme/color.ts`: OKLCH colour math, sRGB gamut mapping, and WCAG contrast. -- `theme/contrast-policy.ts`: the WCAG ratios, solver headroom and search step, and intent role - groups the generator, the compiler's validation matrix, and the semantic map all read. +- `theme/contrast-policy.ts`: the WCAG ratios, solver headroom and search step, and the canonical + semantic role list the generator, the compiler's validation matrix, and the semantic map all read. - `theme/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 on-solid accessibility gate. @@ -57,11 +57,11 @@ token pair when a generated pair misses WCAG 2.2 AA contrast. A single-value acc adapted per mode through a lightness search; it throws when no lightness in the vibrant band is accessible. The raw `ThemeFoundation` object and `buildTheme` are internal only. -Every colour token is generated from a private 12-step scale per role (neutral, accent, danger, -info, success, warning) plus a mode-aware elevation surface set, then mapped onto the public colour -contract. See [THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for the pipeline, the border -and accent contrast policies, and what changed when this generator replaced the original per-token -solver. +Every colour token is generated from a private 12-step scale per role (neutral, accent, info, +success, warning, danger) plus a mode-aware elevation surface set, then mapped onto the public +colour contract. Every role gets the same background, foreground, on-solid, and border capabilities. +See [THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for the pipeline, the border and accent +contrast policies, and what changed when this generator replaced the original per-token solver. The semantic contract includes `font.100` through `font.900` size steps. Each step groups its font size, line height, letter spacing, and per-font Capsize trims so components cannot combine unrelated diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index 1855e244..73b5c526 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -10,10 +10,12 @@ Per colour mode, `compileTheme` (in `build-theme.ts`): 1. Resolves the source colours and the canvas anchor (`background`, split from `neutral`'s hue/chroma character — see `define-theme.ts`). -2. Generates six private 12-step OKLCH families (`neutral`, `accent`, `danger`, `info`, `success`, - `warning`) with `scale.ts`'s `generateFamily`. Each family carries steps 1-12 plus a `contrast` - on-solid colour, and guarantees only the capabilities its role's `FamilyRequirements` declares - (see the capability matrix in `scale.ts`). +2. Generates six private 12-step OKLCH families (`neutral`, `accent`, `info`, `success`, `warning`, + `danger`) with `scale.ts`'s `generateFamily`. Each family carries steps 1-12 plus a `contrast` + on-solid colour. Every role now guarantees the same capabilities (see `scale.ts`'s + `FAMILY_REQUIREMENTS`) — the public contract gives all six roles identical background, + foreground, on-solid, and border slots, so there is no role that can opt out of a capability + another role emits. 3. Derives the mode-aware elevation surfaces (`canvas`/`recessed`/`floating`/`overlay`) with `elevation.ts`'s `generateSurfaces`. `surfaces.canvas` is always exactly the resolved `background` — canvas IS the background, not a derived value. @@ -75,9 +77,9 @@ advisory group: 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. +- Every semantic role border (`color.border.`, all six roles) 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 @@ -103,8 +105,8 @@ Accent adaptation is forgiving but never sacrifices the AA on-solid guarantee: 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. -`surface.solidPressed` reuses step 10 and carries the press through depth, finish, and transform -instead. +`background..solid.pressed` 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. That gives two guarantees: @@ -118,14 +120,16 @@ generator's tone-faithful window. It can rescue accents, such as a mid-lightness 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. +search headroom, and the search step. It also declares `SEMANTIC_ROLES`, the one canonical role list +used by family generation, the capability matrix, the semantic map, and the validation matrix. +Previously, separate role lists allowed a role added only to the map to emit an ungated colour, +while a role added only to the compiler threw an internal error. One list makes both sides move +together. ## `loadingSkeleton` -`color.loadingSkeleton` maps to the neutral family's step 7 (previously a lighter neutral step), for -better perceptibility of the loading state against typical surfaces. +`color.loadingSkeleton` maps to the neutral family's step 8, for better perceptibility of the +loading state against typical surfaces. ## Alpha is deferred diff --git a/packages/@luke-ui/react/src/icon/icon.stories.tsx b/packages/@luke-ui/react/src/icon/icon.stories.tsx index a0b8aca1..726a4ee0 100644 --- a/packages/@luke-ui/react/src/icon/icon.stories.tsx +++ b/packages/@luke-ui/react/src/icon/icon.stories.tsx @@ -20,8 +20,8 @@ const baseArgs = { const iconSizes: Array> = ['xsmall', 'small', 'medium', 'large']; const colors = { - accent: vars.color.intent.accent.text, - danger: vars.color.intent.danger.text, + accent: vars.color.foreground.accent.rest, + danger: vars.color.foreground.danger.rest, primary: vars.color.text.primary, secondary: vars.color.text.secondary, } as const satisfies Partial, string>>; diff --git a/packages/@luke-ui/react/src/icon/icon.visual.test.tsx b/packages/@luke-ui/react/src/icon/icon.visual.test.tsx index 743c0e17..cc51a88a 100644 --- a/packages/@luke-ui/react/src/icon/icon.visual.test.tsx +++ b/packages/@luke-ui/react/src/icon/icon.visual.test.tsx @@ -48,7 +48,7 @@ test('sizes and glyphs', async () => { for (const appearance of visualAppearances) { test(`semantic content inheritance: ${appearance.theme} ${appearance.mode}`, async () => { const scene = renderVisual( -
+
, appearance, diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx index 4f23da95..d6256469 100644 --- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx @@ -63,7 +63,7 @@ export const Color = meta.story({ }, render: (props) => (
-
+
{colors.map((color) => ( diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx index bcdc6719..3858a07c 100644 --- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx @@ -82,7 +82,7 @@ const themeMatrixStyle = { } satisfies CSSProperties; const spinnerStyle = { - color: vars.color.intent.accent.text, + color: vars.color.foreground.accent.rest, } satisfies CSSProperties; function ThemeMatrixScope({ diff --git a/packages/@luke-ui/react/src/recipes/button.browser.test.ts b/packages/@luke-ui/react/src/recipes/button.browser.test.ts index c74e167f..65d626cd 100644 --- a/packages/@luke-ui/react/src/recipes/button.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/button.browser.test.ts @@ -57,6 +57,43 @@ test('hover raises and press recesses without scale', () => { expect(pressedTransform.startsWith('matrix(1, 0, 0, 1')).toBe(true); }); +test('subtle appearance walks rest, hover, and pressed background colours per tone', () => { + for (const tone of ['neutral', 'accent', 'danger'] as const) { + const control = mountButton({ appearance: 'subtle', tone }); + + const restingColor = getComputedStyle(control).backgroundColor; + expect(restingColor).toBe(resolveColor(control, `--luke-color-background-${tone}-subtle-rest`)); + + control.dataset.hovered = 'true'; + const hoveredColor = getComputedStyle(control).backgroundColor; + expect(hoveredColor).toBe( + resolveColor(control, `--luke-color-background-${tone}-subtle-hover`), + ); + + delete control.dataset.hovered; + control.dataset.pressed = 'true'; + const pressedColor = getComputedStyle(control).backgroundColor; + expect(pressedColor).toBe( + resolveColor(control, `--luke-color-background-${tone}-subtle-pressed`), + ); + + // Rest, hover, and pressed must be three genuinely distinct steps of the ramp. + expect(new Set([restingColor, hoveredColor, pressedColor])).toHaveLength(3); + } +}); + +test('disabled preserves subtle resting colour and suppresses hover and pressed treatment', () => { + const resting = mountButton({ appearance: 'subtle', tone: 'accent' }); + const disabled = mountButton({ appearance: 'subtle', tone: 'accent' }); + disabled.dataset.disabled = 'true'; + disabled.dataset.hovered = 'true'; + disabled.dataset.pressed = 'true'; + + expect(getComputedStyle(disabled).backgroundColor).toBe( + getComputedStyle(resting).backgroundColor, + ); +}); + test('disabled preserves resting material and ignores interaction', () => { const resting = mountButton(); const disabled = mountButton(); @@ -127,3 +164,11 @@ async function setEmulatedMedia(name?: string, value?: string) { features: name === undefined || value === undefined ? [] : [{ name, value }], }); } + +function resolveColor(scope: HTMLElement, variable: string) { + const probe = scope.appendChild(document.createElement('div')); + probe.style.backgroundColor = `var(${variable})`; + const value = getComputedStyle(probe).backgroundColor; + probe.remove(); + return value; +} diff --git a/packages/@luke-ui/react/src/recipes/button.css.ts b/packages/@luke-ui/react/src/recipes/button.css.ts index 08e687dd..1ad76327 100644 --- a/packages/@luke-ui/react/src/recipes/button.css.ts +++ b/packages/@luke-ui/react/src/recipes/button.css.ts @@ -140,70 +140,69 @@ export const button = recipe({ ...appearance( 'neutral', 'solid', - vars.color.intent.neutral.surface, - vars.color.intent.neutral.onSolid, + vars.color.background.neutral, + vars.color.foreground.neutral.onSolid, ), ...appearance( 'accent', 'solid', - vars.color.intent.accent.surface, - vars.color.intent.accent.onSolid, + vars.color.background.accent, + vars.color.foreground.accent.onSolid, ), ...appearance( 'danger', 'solid', - vars.color.intent.danger.surface, - vars.color.intent.danger.onSolid, + vars.color.background.danger, + vars.color.foreground.danger.onSolid, ), - ...appearance('neutral', 'subtle', vars.color.intent.neutral.surface, vars.color.text.primary), + ...appearance('neutral', 'subtle', vars.color.background.neutral, vars.color.text.primary), ...appearance( 'accent', 'subtle', - vars.color.intent.accent.surface, - vars.color.intent.accent.text, + vars.color.background.accent, + vars.color.foreground.accent.rest, ), ...appearance( 'danger', 'subtle', - vars.color.intent.danger.surface, - vars.color.intent.danger.text, + vars.color.background.danger, + vars.color.foreground.danger.rest, ), ghostAppearance('neutral', vars.color.text.primary), - ghostAppearance('accent', vars.color.intent.accent.text), - ghostAppearance('danger', vars.color.intent.danger.text), + ghostAppearance('accent', vars.color.foreground.accent.rest), + ghostAppearance('danger', vars.color.foreground.danger.rest), ], }); export type ButtonVariants = RecipeSelection; type Tone = 'neutral' | 'accent' | 'danger'; -type Surface = { - solid: string; - solidHover: string; - solidPressed: string; - subtle: string; - subtleHover: string; - subtlePressed: string; -}; +/** The subtle/solid background ramp shape shared by every semantic role. */ +type Background = (typeof vars.color.background)[Tone]; -function appearance(tone: Tone, appearance: 'solid' | 'subtle', surface: Surface, color: string) { - const prefix = appearance === 'solid' ? 'solid' : 'subtle'; +function appearance( + tone: Tone, + appearance: 'solid' | 'subtle', + background: Background, + color: string, +) { + const ramp = background[appearance]; return [ { style: { '@media': { '(forced-colors: active)': { backgroundImage: 'none' }, }, - backgroundColor: surface[prefix], + backgroundColor: ramp.rest, backgroundImage: vars.actionControlFinish.resting, color, selectors: { '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { - backgroundColor: surface[`${prefix}Hover`], + backgroundColor: ramp.hover, backgroundImage: vars.actionControlFinish.raised, }, '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { - backgroundColor: surface[`${prefix}Pressed`], + backgroundColor: ramp.pressed, backgroundImage: vars.actionControlFinish.recessed, }, }, @@ -214,7 +213,7 @@ function appearance(tone: Tone, appearance: 'solid' | 'subtle', surface: Surface } function ghostAppearance(tone: Tone, color: string) { - const surface = vars.color.intent[tone].surface; + const subtle = vars.color.background[tone].subtle; return { style: { backgroundColor: 'transparent', @@ -224,11 +223,11 @@ function ghostAppearance(tone: Tone, color: string) { color, selectors: { '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { - backgroundColor: surface.subtleHover, + backgroundColor: subtle.hover, boxShadow: vars.depth.raised, }, '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { - backgroundColor: surface.subtlePressed, + backgroundColor: subtle.pressed, boxShadow: vars.depth.recessed, }, }, diff --git a/packages/@luke-ui/react/src/recipes/checkbox.css.ts b/packages/@luke-ui/react/src/recipes/checkbox.css.ts index ac2a6568..156c339b 100644 --- a/packages/@luke-ui/react/src/recipes/checkbox.css.ts +++ b/packages/@luke-ui/react/src/recipes/checkbox.css.ts @@ -82,7 +82,7 @@ const checkboxConfig = { borderWidth: '1px', boxShadow: 'none', boxSizing: 'border-box', - color: vars.color.intent.accent.onSolid, + color: vars.color.foreground.accent.onSolid, display: 'inline-flex', fontSize: checkboxGlyphSize, fontWeight: vars.font.weight.heading, @@ -107,45 +107,45 @@ const checkboxConfig = { '[data-focus-visible="true"] &': focusRing(vars.color.border.focus), '[data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': { backgroundImage: vars.actionControlFinish.raised, - borderColor: vars.color.intent.accent.border, + borderColor: vars.color.border.accent, }, '[data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': { backgroundImage: vars.actionControlFinish.recessed, - borderColor: vars.color.intent.accent.border, + borderColor: vars.color.border.accent, }, '[data-indeterminate="true"] &': { - backgroundColor: vars.color.intent.accent.surface.solid, - borderColor: vars.color.intent.accent.surface.solid, + backgroundColor: vars.color.background.accent.solid.rest, + borderColor: vars.color.background.accent.solid.rest, }, '[data-indeterminate="true"] &::after': { content: '"−"', opacity: 1, }, '[data-invalid="true"] &': { - borderColor: vars.color.intent.danger.border, + borderColor: vars.color.border.danger, }, '[data-selected="true"] &': { - backgroundColor: vars.color.intent.accent.surface.solid, - borderColor: vars.color.intent.accent.surface.solid, + backgroundColor: vars.color.background.accent.solid.rest, + borderColor: vars.color.background.accent.solid.rest, }, '[data-selected="true"] &::after': { opacity: 1, }, '[data-selected="true"][data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &, [data-indeterminate="true"][data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': { - backgroundColor: vars.color.intent.accent.surface.solidHover, - borderColor: vars.color.intent.accent.surface.solidHover, + backgroundColor: vars.color.background.accent.solid.hover, + borderColor: vars.color.background.accent.solid.hover, }, '[data-selected="true"][data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &, [data-indeterminate="true"][data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': { - backgroundColor: vars.color.intent.accent.surface.solidPressed, - borderColor: vars.color.intent.accent.surface.solidPressed, + backgroundColor: vars.color.background.accent.solid.pressed, + borderColor: vars.color.background.accent.solid.pressed, }, '[data-invalid="true"][data-selected="true"] &, [data-invalid="true"][data-indeterminate="true"] &': { - backgroundColor: vars.color.intent.danger.surface.solid, - borderColor: vars.color.intent.danger.surface.solid, - color: vars.color.intent.danger.onSolid, + backgroundColor: vars.color.background.danger.solid.rest, + borderColor: vars.color.background.danger.solid.rest, + color: vars.color.foreground.danger.onSolid, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts index 48c33839..55609de0 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts @@ -25,7 +25,7 @@ test('the control uses the recessed field material and semantic state grammar', const { control: hovered } = mountControl(); hovered.dataset.hovered = 'true'; expect(getComputedStyle(hovered).borderColor).toBe( - resolveColor(root, '--luke-color-intent-accent-border'), + resolveColor(root, '--luke-color-border-accent'), ); const { control: focused } = mountControl(); @@ -39,7 +39,7 @@ test('the control uses the recessed field material and semantic state grammar', const { control: invalid } = mountControl(); invalid.dataset.invalid = 'true'; expect(getComputedStyle(invalid).borderColor).toBe( - resolveColor(root, '--luke-color-intent-danger-border'), + resolveColor(root, '--luke-color-border-danger'), ); }); @@ -90,7 +90,7 @@ test('in-field actions are quiet inset squares without a permanent divider or tr action.dataset.hovered = 'true'; const hoveredStyle = getComputedStyle(action); expect(hoveredStyle.backgroundColor).toBe( - resolveColor(root, '--luke-color-intent-accent-surface-subtle-hover'), + resolveColor(root, '--luke-color-background-accent-subtle-hover'), ); expect(hoveredStyle.boxShadow).toBe('none'); expect(hoveredStyle.transform).toBe('none'); @@ -99,7 +99,7 @@ test('in-field actions are quiet inset squares without a permanent divider or tr action.dataset.pressed = 'true'; const pressedStyle = getComputedStyle(action); expect(pressedStyle.backgroundColor).toBe( - resolveColor(root, '--luke-color-intent-accent-surface-subtle-pressed'), + resolveColor(root, '--luke-color-background-accent-subtle-pressed'), ); expect(pressedStyle.boxShadow).toBe('none'); expect(pressedStyle.transform).toBe('none'); @@ -118,19 +118,39 @@ test('options keep hover, keyboard focus, selected, and disabled states distinct hovered: 'true', }); const selected = mountItem(root, { selected: 'true' }); + const selectedAndKeyboardFocused = mountItem(root, { + focusVisible: 'true', + focused: 'true', + hovered: 'true', + selected: 'true', + }); const disabled = mountItem(root, { disabled: 'true', focused: 'true' }); expect(getComputedStyle(focused).backgroundColor).toBe( - resolveColor(root, '--luke-color-intent-neutral-surface-subtle'), + resolveColor(root, '--luke-color-background-neutral-subtle-rest'), ); expect(getComputedStyle(hoveredAndFocused).backgroundColor).toBe( - resolveColor(root, '--luke-color-intent-neutral-surface-subtle-hover'), + resolveColor(root, '--luke-color-background-neutral-subtle-hover'), ); expect(getComputedStyle(keyboardFocused).backgroundColor).toBe( - resolveColor(root, '--luke-color-intent-accent-surface-subtle-hover'), + resolveColor(root, '--luke-color-background-accent-subtle-hover'), + ); + expect(getComputedStyle(selected).backgroundColor).toBe( + resolveColor(root, '--luke-color-background-accent-subtle-rest'), + ); + // Selected plus keyboard focus is its own composed step on the ramp, not a + // re-application of either state alone. + expect(getComputedStyle(selectedAndKeyboardFocused).backgroundColor).toBe( + resolveColor(root, '--luke-color-background-accent-subtle-pressed'), ); - const interactiveBackgrounds = [resting, hovered, keyboardFocused, selected].map((item) => { + const interactiveBackgrounds = [ + resting, + hovered, + keyboardFocused, + selected, + selectedAndKeyboardFocused, + ].map((item) => { return getComputedStyle(item).backgroundColor; }); expect(new Set(interactiveBackgrounds)).toHaveLength(interactiveBackgrounds.length); diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index a24ae92f..210d1055 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -83,11 +83,11 @@ const comboboxActionStyles = { '&[data-disabled="true"]': { cursor: 'not-allowed' }, '&[data-focus-visible="true"]': { ...focusRing(vars.color.border.focus) }, '&[data-hovered="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtleHover, + backgroundColor: vars.color.background.accent.subtle.hover, color: vars.color.text.primary, }, '&[data-pressed="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtlePressed, + backgroundColor: vars.color.background.accent.subtle.pressed, color: vars.color.text.primary, }, [descendantDisabledSelector]: { color: vars.color.text.disabled }, @@ -153,13 +153,13 @@ const comboboxConfig = { selectors: { [disabled]: { cursor: 'not-allowed', opacity: 0.55 }, [focusWithin]: { - borderColor: vars.color.intent.accent.border, + borderColor: vars.color.border.accent, ...focusRing(vars.color.border.focus), }, - [hover]: { borderColor: vars.color.intent.accent.border }, - [invalid]: { borderColor: vars.color.intent.danger.border }, + [hover]: { borderColor: vars.color.border.accent }, + [invalid]: { borderColor: vars.color.border.danger }, [invalidFocusWithin]: { - borderColor: vars.color.intent.danger.border, + borderColor: vars.color.border.danger, ...focusRing(vars.color.border.focus), }, [readOnly]: { @@ -390,20 +390,20 @@ const comboboxConfig = { opacity: 0.55, }, '&[data-focused="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.neutral.surface.subtle, + backgroundColor: vars.color.background.neutral.subtle.rest, }, '&[data-hovered="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.neutral.surface.subtleHover, + backgroundColor: vars.color.background.neutral.subtle.hover, }, '&[data-focus-visible="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtleHover, + backgroundColor: vars.color.background.accent.subtle.hover, }, '&[data-selected="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtle, + backgroundColor: vars.color.background.accent.subtle.rest, fontWeight: vars.font.weight.label, }, '&[data-selected="true"][data-focus-visible="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtlePressed, + backgroundColor: vars.color.background.accent.subtle.pressed, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/field.browser.test.ts b/packages/@luke-ui/react/src/recipes/field.browser.test.ts new file mode 100644 index 00000000..93a238e1 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/field.browser.test.ts @@ -0,0 +1,84 @@ +import '@luke-ui/react/themes/tactile.css'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { themeRootClassName } from '../theme/index.js'; +import { tactileThemeClassName } from '../themes/index.js'; +import { cx } from '../utils/index.js'; +import { field } from './field.css.js'; + +let mounted: Array = []; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; +}); + +test('icon necessity indicator marks a required field with the resting danger foreground', () => { + const { root, label } = mountField({ necessityIndicator: 'icon' }); + root.dataset.required = 'true'; + + const indicator = getComputedStyle(label, '::after'); + expect(indicator.content).toBe('"*"'); + expect(indicator.color).toBe(resolveColor(root, '--luke-color-foreground-danger-rest')); +}); + +test('label necessity indicator marks a required field with "(required)" text instead of the asterisk', () => { + const { root, label } = mountField({ necessityIndicator: 'label' }); + root.dataset.required = 'true'; + + const indicator = getComputedStyle(label, '::after'); + expect(indicator.content).toBe('"(required)"'); + // Unlike icon mode, this variant reads as prose rather than a validation cue, + // so it takes the secondary text colour, not the danger foreground. + expect(indicator.color).toBe(resolveColor(root, '--luke-color-text-secondary')); +}); + +test('a non-required field renders neither necessity indicator', () => { + const { label: iconLabel } = mountField({ necessityIndicator: 'icon' }); + const { label: labelLabel } = mountField({ necessityIndicator: 'label' }); + + expect(getComputedStyle(iconLabel, '::after').content).toBe('none'); + expect(getComputedStyle(labelLabel, '::after').content).toBe('none'); +}); + +test('error tone renders the message in the resting danger foreground', () => { + const { root, message } = mountField({ tone: 'error' }); + + expect(getComputedStyle(message).color).toBe( + resolveColor(root, '--luke-color-foreground-danger-rest'), + ); +}); + +test('disabled field text takes the functional disabled colour, not a role colour', () => { + const { root, label, message } = mountField({ tone: 'error' }); + root.dataset.disabled = 'true'; + + const disabledColor = resolveColor(root, '--luke-color-text-disabled'); + expect(getComputedStyle(label).color).toBe(disabledColor); + expect(getComputedStyle(message).color).toBe(disabledColor); + // Disabled must suppress the error tone's danger foreground, not merely add to it. + expect(getComputedStyle(message).color).not.toBe( + resolveColor(root, '--luke-color-foreground-danger-rest'), + ); +}); + +function mountField(options: Parameters[0] = {}) { + const root = document.body.appendChild(document.createElement('div')); + root.className = cx(themeRootClassName, tactileThemeClassName); + root.dataset.colorMode = 'light'; + mounted.push(root); + + const label = root.appendChild(document.createElement('label')); + label.className = field(options).label(); + const message = root.appendChild(document.createElement('p')); + message.className = field(options).message(); + + return { root, label, message }; +} + +function resolveColor(root: HTMLElement, variable: string) { + const probe = root.appendChild(document.createElement('div')); + probe.style.color = `var(${variable})`; + const value = getComputedStyle(probe).color; + probe.remove(); + return value; +} diff --git a/packages/@luke-ui/react/src/recipes/field.css.ts b/packages/@luke-ui/react/src/recipes/field.css.ts index b42e77bd..06382b6a 100644 --- a/packages/@luke-ui/react/src/recipes/field.css.ts +++ b/packages/@luke-ui/react/src/recipes/field.css.ts @@ -56,7 +56,7 @@ const fieldConfig = { label: { selectors: { [`${dataRequiredSelector} &::after`]: { - color: vars.color.intent.danger.text, + color: vars.color.foreground.danger.rest, content: '"*"', marginInlineStart: vars.space[100], }, @@ -84,7 +84,7 @@ const fieldConfig = { }, error: { message: { - color: vars.color.intent.danger.text, + color: vars.color.foreground.danger.rest, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/link.css.ts b/packages/@luke-ui/react/src/recipes/link.css.ts index a4b53b8a..db6f5962 100644 --- a/packages/@luke-ui/react/src/recipes/link.css.ts +++ b/packages/@luke-ui/react/src/recipes/link.css.ts @@ -23,7 +23,7 @@ const base = styleInLayer('recipes', { transition: 'none', }, }, - color: vars.color.intent.accent.text, + color: vars.color.foreground.accent.rest, cursor: 'pointer', font: 'inherit', outlineColor: 'transparent', @@ -74,13 +74,15 @@ export const link = recipe({ }, tone: { accent: { - color: vars.color.intent.accent.text, + color: vars.color.foreground.accent.rest, selectors: { '&[data-hovered="true"]:not([data-disabled="true"])': { - color: vars.color.intent.accent.textHover, + color: vars.color.foreground.accent.hover, }, + // Press reuses the hover foreground: the shared contract carries no separate pressed + // content colour, so the stronger hover value covers both interactive states. '&[data-pressed="true"]:not([data-disabled="true"])': { - color: vars.color.intent.accent.textHover, + color: vars.color.foreground.accent.hover, }, }, }, diff --git a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts index 816cd8f4..67022da1 100644 --- a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts +++ b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts @@ -94,13 +94,13 @@ const loadingSpinnerConfig = { }, variants: { color: { - accent: { root: { color: vars.color.intent.accent.text } }, - danger: { root: { color: vars.color.intent.danger.text } }, - info: { root: { color: vars.color.intent.info.text } }, + accent: { root: { color: vars.color.foreground.accent.rest } }, + danger: { root: { color: vars.color.foreground.danger.rest } }, + info: { root: { color: vars.color.foreground.info.rest } }, primary: { root: { color: vars.color.text.primary } }, secondary: { root: { color: vars.color.text.secondary } }, - success: { root: { color: vars.color.intent.success.text } }, - warning: { root: { color: vars.color.intent.warning.text } }, + success: { root: { color: vars.color.foreground.success.rest } }, + warning: { root: { color: vars.color.foreground.warning.rest } }, }, size: { large: { root: iconSizeVariants.large }, diff --git a/packages/@luke-ui/react/src/recipes/text-input.css.ts b/packages/@luke-ui/react/src/recipes/text-input.css.ts index 0d4472ed..c96bc362 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.css.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.css.ts @@ -72,17 +72,17 @@ const textInputConfig = { opacity: 0.55, }, [focusWithin]: { - borderColor: vars.color.intent.accent.border, + borderColor: vars.color.border.accent, ...focusRing(vars.color.border.focus), }, [hover]: { - borderColor: vars.color.intent.accent.border, + borderColor: vars.color.border.accent, }, [invalid]: { - borderColor: vars.color.intent.danger.border, + borderColor: vars.color.border.danger, }, [invalidFocusWithin]: { - borderColor: vars.color.intent.danger.border, + borderColor: vars.color.border.danger, ...focusRing(vars.color.border.focus), }, [readOnly]: { diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index 2cfdabc8..65cf0fc2 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -46,13 +46,13 @@ const base = styleInLayer('recipes', { }); const colorVariants = { - accent: { color: vars.color.intent.accent.text }, - danger: { color: vars.color.intent.danger.text }, - info: { color: vars.color.intent.info.text }, + accent: { color: vars.color.foreground.accent.rest }, + danger: { color: vars.color.foreground.danger.rest }, + info: { color: vars.color.foreground.info.rest }, primary: { color: vars.color.text.primary }, secondary: { color: vars.color.text.secondary }, - success: { color: vars.color.intent.success.text }, - warning: { color: vars.color.intent.warning.text }, + success: { color: vars.color.foreground.success.rest }, + warning: { color: vars.color.foreground.warning.rest }, } as const; const weightVariants = { diff --git a/packages/@luke-ui/react/src/styles/theme-root.css.ts b/packages/@luke-ui/react/src/styles/theme-root.css.ts index 894d68aa..864bc8d0 100644 --- a/packages/@luke-ui/react/src/styles/theme-root.css.ts +++ b/packages/@luke-ui/react/src/styles/theme-root.css.ts @@ -3,7 +3,7 @@ import { classSelector, lukeUiClassNames } from './class-names.js'; import { globalStyleInLayer } from './layered-style.css.js'; globalStyleInLayer('theme', classSelector(lukeUiClassNames.themeRoot), { - accentColor: vars.color.intent.accent.surface.solid, + accentColor: vars.color.background.accent.solid.rest, color: vars.color.text.primary, fontFamily: vars.font.family.body, ...vars.font[300], diff --git a/packages/@luke-ui/react/src/styles/utilities.stories.tsx b/packages/@luke-ui/react/src/styles/utilities.stories.tsx index 28d81268..dac0f544 100644 --- a/packages/@luke-ui/react/src/styles/utilities.stories.tsx +++ b/packages/@luke-ui/react/src/styles/utilities.stories.tsx @@ -105,7 +105,7 @@ export const WithRenderProp = meta.story({ padding: '400', }); const customStyle = mergeProps(buttonBox, { - style: { backgroundColor: vars.color.intent.accent.surface.solid }, + style: { backgroundColor: vars.color.background.accent.solid.rest }, }); return (