From 85421ed522ebf06db896fde19bdee87e76e4e9be Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 14 Aug 2026 21:10:46 +1000 Subject: [PATCH] Wash hover and pressed with an opaque interaction tint Replace color.overlay.hover and color.overlay.pressed with a single color.overlay.tint: an opaque ink, black in light mode and white in dark. Strength moves to the call site, because color-mix cannot composite a translucent colour onto a fill and return an opaque result. Add styles/overlay-wash.ts as the one home for the technique. Consumers set background-color and border-color instead of an inset box-shadow, so the existing feedback transition animates the wash, and a wash over a transparent rest fill stays translucent for ghost controls. Restore the Checkbox border cue and adopt the higher control strengths. A selected box's hover went from 1.09% to 5.43% lightness change, with the border back at 9.7%, so the wash is no longer its only state cue. validateContrast now composites the tint directly against a table of the real first-party pairs, rather than parsing its own emitted color-mix string: 88 hard and 12 advisory checks per mode. --- .../content/docs/docs/authoring-a-theme.mdx | 13 +- apps/docs/content/docs/docs/color.mdx | 63 +++++---- .../content/docs/docs/token-reference.mdx | 4 +- .../docs/src/lib/token-purpose-groups.test.ts | 3 +- apps/docs/src/lib/token-purpose-groups.ts | 2 + apps/docs/src/styles/app.css | 4 +- docs/DOCUMENTATION.md | 4 +- docs/STYLING.md | 5 + docs/THEME_COLOUR_GENERATION.md | 33 +++-- .../react/src/primitives/button/recipe.css.ts | 20 ++- .../src/primitives/checkbox/recipe.css.ts | 46 ++++++- .../src/primitives/combobox/styles.css.ts | 19 ++- .../@luke-ui/react/src/styles/overlay-wash.ts | 15 +++ .../react/src/theme/build-theme.test.ts | 4 +- .../@luke-ui/react/src/theme/contract.test.ts | 8 +- packages/@luke-ui/react/src/theme/contract.ts | 17 +-- .../src/theme/contrast-validation.test.ts | 112 +++++++--------- .../react/src/theme/contrast-validation.ts | 124 ++++++++++-------- .../react/src/theme/define-theme.test.ts | 3 +- .../react/src/theme/semantic-map.test.ts | 23 +++- .../@luke-ui/react/src/theme/semantic-map.ts | 27 ++-- .../react/src/theme/stylesheet.test.ts | 12 +- .../react/src/theme/token-board.test.ts | 11 +- .../@luke-ui/react/src/theme/token-board.tsx | 5 +- 24 files changed, 345 insertions(+), 232 deletions(-) create mode 100644 packages/@luke-ui/react/src/styles/overlay-wash.ts diff --git a/apps/docs/content/docs/docs/authoring-a-theme.mdx b/apps/docs/content/docs/docs/authoring-a-theme.mdx index fc363525..a66f9171 100644 --- a/apps/docs/content/docs/docs/authoring-a-theme.mdx +++ b/apps/docs/content/docs/docs/authoring-a-theme.mdx @@ -25,9 +25,9 @@ A basic theme authors an accent colour and a neutral character. Everything else Each colour accepts one string, adapted independently for light and dark, or an explicit `{ light, dark }` pair. Omit either side to fall back to that role's default. Luke UI supplies accessible mode-specific defaults for every optional colour. `color.backdrop` is the one colour that -may include alpha, and Luke UI emits it verbatim. Hover and pressed overlay washes are generated, -not authored. `radius` is a generative `base` and `multiplier` scale with explicit per-step -overrides. `depth` and `actionControlFinish` are optional and deep-partial per mode over curated, +may include alpha, and Luke UI emits it verbatim. The interaction tint is generated per mode, not +authored. `radius` is a generative `base` and `multiplier` scale with explicit per-step overrides. +`depth` and `actionControlFinish` are optional and deep-partial per mode over curated, extremely-subtle defaults. The typography styles and motion values are source-owned and are not authored. @@ -105,9 +105,10 @@ Every text/surface pair the compiler emits is a hard gate: its foreground `rest` and `hover` against `canvas`, `recessed`, and that role's own `subtle` background. - Each role's `onSolid` foreground against its `solid` background. -- The first-party contracts that paint `color.overlay.hover` or `color.overlay.pressed` over a - resting fill: ghost Button foregrounds on canvas and recessed; solid and subtle Button tones; - selected Combobox options on accent subtle; unselected Combobox options on the floating popover. +- The first-party contracts that mix `color.overlay.tint` into a resting fill: ghost Button + foregrounds on canvas and recessed; solid and subtle Button tones; selected Combobox options on + accent subtle; unselected Combobox options on the floating popover; and the Checkbox's stronger + mixes over the accent and danger solid fills. ### Guaranteed at 3:1 diff --git a/apps/docs/content/docs/docs/color.mdx b/apps/docs/content/docs/docs/color.mdx index 574d79b7..2fb45632 100644 --- a/apps/docs/content/docs/docs/color.mdx +++ b/apps/docs/content/docs/docs/color.mdx @@ -16,21 +16,27 @@ Surface roles describe where an element sits in the interface: - `floating`: elevated UI, such as menus and cards. - `overlay`: dialogs and other high-elevation surfaces. This is an opaque colour. -`color.overlay.backdrop` dims content behind a modal. `color.overlay.hover` and -`color.overlay.pressed` are translucent neutral interaction washes. They live on `color.overlay`, -not on `color.surface.overlay`. +`color.overlay.backdrop` dims content behind a modal. `color.overlay.tint` is the interaction ink: +an opaque colour, black in light mode and white in dark mode, that you mix into a fill to darken or +lighten it. Both live on `color.overlay`, not on `color.surface.overlay`. -Semantic backgrounds describe meaning and resting state. Overlay washes describe transient hover and -pressed feedback. Layer the overlay over the fill the control already has: a solid accent button -keeps its accent solid background, a selected option keeps its selected fill, and an invalid control -keeps its danger styling. The overlay does not replace those states. +Semantic backgrounds describe meaning and resting state. The tint describes transient hover and +pressed feedback. Mix it into the fill the control already has, so a solid accent button keeps its +accent solid background, a selected option keeps its selected fill, and an invalid control keeps its +danger styling. The tint does not replace those states. -Do not use overlays for focus-visible or disabled. Focus keeps `color.border.focus`. Disabled is a +Because the result is a colour rather than a layer, it animates. Assign it to `background-color` or +`border-color` and the control's existing transition carries it. + +Do not use the tint for focus-visible or disabled. Focus keeps `color.border.focus`. Disabled is a state, not an interaction wash. Prefer a component's state API when the component has one. When you style a custom interactive -element, keep the semantic background for meaning and apply `color.overlay.hover` or -`color.overlay.pressed` on top. +element, keep the semantic background for meaning and mix the tint into it: + +```css +background-color: color-mix(in oklab, var(--luke-color-overlay-tint) 5%, var(--fill)); +``` Text uses `primary` and `secondary` roles. Borders distinguish decorative, control, and focus uses. @@ -70,14 +76,14 @@ stays constant, so only the role and mode change its colours. ### Background and foreground Each role has a `subtle` and a `solid` background. Those fills are resting colours. Transient hover -and pressed feedback uses `color.overlay.hover` and `color.overlay.pressed` over that fill: +and pressed feedback mixes `color.overlay.tint` into that fill: ```tsx import { vars } from '@luke-ui/react/theme'; vars.color.background.warning.subtle; vars.color.background.warning.solid; -vars.color.overlay.hover; +`color-mix(in oklab, ${vars.color.overlay.tint} 5%, ${vars.color.background.warning.solid})`; ``` Foreground gives each role a resting and a stronger interactive colour, plus a colour guaranteed to @@ -89,8 +95,9 @@ vars.color.foreground.warning.hover; vars.color.foreground.warning.onSolid; ``` -There is no foreground `pressed`. Overlay washes and other non-colour cues carry a pressed look, so -text and icons reuse `hover` for a pressed control whose content colour changes, such as a link. +There is no foreground `pressed`. A stronger tint mix and other non-colour cues carry a pressed +look, so text and icons reuse `hover` for a pressed control whose content colour changes, such as a +link. ### Borders @@ -119,18 +126,18 @@ variables to fall back on. If you upgrade from a release that had them, this tab reference. Move each usage to its `color.background` / `color.foreground` / `color.border` equivalent: -| Old path | New path | -| ------------------------------------------- | ------------------------------------------------------------- | -| `color.intent..surface.subtle` | `color.background..subtle` | -| `color.intent..surface.subtleHover` | `color.overlay.hover` over `color.background..subtle` | -| `color.intent..surface.subtlePressed` | `color.overlay.pressed` over `color.background..subtle` | -| `color.intent..surface.solid` | `color.background..solid` | -| `color.intent..surface.solidHover` | `color.overlay.hover` over `color.background..solid` | -| `color.intent..surface.solidPressed` | `color.overlay.pressed` over `color.background..solid` | -| `color.intent..text` | `color.foreground..rest` | -| `color.intent..textHover` | `color.foreground..hover` | -| `color.intent..onSolid` | `color.foreground..onSolid` | -| `color.intent..border` | `color.border.` | +| Old path | New path | +| ------------------------------------------- | ------------------------------------------------------ | +| `color.intent..surface.subtle` | `color.background..subtle` | +| `color.intent..surface.subtleHover` | 5% `color.overlay.tint` in `background..subtle` | +| `color.intent..surface.subtlePressed` | 10% `color.overlay.tint` in `background..subtle` | +| `color.intent..surface.solid` | `color.background..solid` | +| `color.intent..surface.solidHover` | 5% `color.overlay.tint` in `background..solid` | +| `color.intent..surface.solidPressed` | 10% `color.overlay.tint` in `background..solid` | +| `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` became `--luke-color-background-danger-solid`. There are no `--luke-color-background-*-hover` or @@ -138,8 +145,8 @@ CSS variable names followed the same shape, so `--luke-color-intent-danger-surfa The current contract also carries capabilities `color.intent` never had. It adds a foreground and border for `neutral`. It also adds on-solid foregrounds for `info`, `success`, and `warning`. Hover -and pressed background ramps from `color.intent` have no replacement leaves: layer -`color.overlay.hover` or `color.overlay.pressed` over the resting subtle or solid fill. +and pressed background ramps from `color.intent` have no replacement leaves: mix +`color.overlay.tint` into the resting subtle or solid fill instead. ## Continue learning diff --git a/apps/docs/content/docs/docs/token-reference.mdx b/apps/docs/content/docs/docs/token-reference.mdx index 59b36697..d3b886ec 100644 --- a/apps/docs/content/docs/docs/token-reference.mdx +++ b/apps/docs/content/docs/docs/token-reference.mdx @@ -69,8 +69,8 @@ Depth describes how a surface sits above or within the interface. Pair each depth role with the matching surface role. For example, a menu can use `surface.floating` with `depth.floating`, while a dialog can use `surface.overlay` with `depth.overlay`. -`color.overlay` is a different group: the translucent backdrop and interaction washes. It is not a -surface colour and not a shadow. +`color.overlay` is a different group: the translucent backdrop and the opaque interaction tint. It +is not a surface colour and not a shadow. Components already apply depth to their own states. Use these variables only for custom surfaces. diff --git a/apps/docs/src/lib/token-purpose-groups.test.ts b/apps/docs/src/lib/token-purpose-groups.test.ts index e5e3a37b..763806b4 100644 --- a/apps/docs/src/lib/token-purpose-groups.test.ts +++ b/apps/docs/src/lib/token-purpose-groups.test.ts @@ -45,8 +45,7 @@ test('splits the colour family across the purposes it serves', () => { expect(purposeOf.get('color.surface.canvas')).toBe('surfaces'); expect(purposeOf.get('color.overlay.backdrop')).toBe('surfaces'); - expect(purposeOf.get('color.overlay.hover')).toBe('interaction'); - expect(purposeOf.get('color.overlay.pressed')).toBe('interaction'); + expect(purposeOf.get('color.overlay.tint')).toBe('interaction'); expect(purposeOf.get('color.text.secondary')).toBe('content'); expect(purposeOf.get('color.loadingSkeleton')).toBe('content'); expect(purposeOf.get('color.border.focus')).toBe('borders'); diff --git a/apps/docs/src/lib/token-purpose-groups.ts b/apps/docs/src/lib/token-purpose-groups.ts index c1f4368e..d1e2456b 100644 --- a/apps/docs/src/lib/token-purpose-groups.ts +++ b/apps/docs/src/lib/token-purpose-groups.ts @@ -139,6 +139,8 @@ function resolveColorPurpose(path: string): TokenPurposeId | undefined { if (section === 'border') { return leaf !== undefined && STRUCTURAL_BORDERS.has(leaf) ? 'borders' : 'roles'; } + // `backdrop` is a dimming layer, so it belongs with the surfaces; `tint` is the ink components mix + // into a fill for hover and pressed feedback. if (section === 'overlay') { return leaf === 'backdrop' ? 'surfaces' : 'interaction'; } diff --git a/apps/docs/src/styles/app.css b/apps/docs/src/styles/app.css index efe41206..656a23cf 100644 --- a/apps/docs/src/styles/app.css +++ b/apps/docs/src/styles/app.css @@ -29,7 +29,9 @@ code > * { --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-background-neutral-subtle); + /* Fumadocs paints this as the sidebar hover and active surface, so it takes the interaction wash + rather than a resting semantic fill. */ + --color-fd-accent: color-mix(in oklab, var(--luke-color-overlay-tint) 5%, transparent); --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/DOCUMENTATION.md b/docs/DOCUMENTATION.md index e2ec6c76..64b8c100 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -50,8 +50,8 @@ Keep the detail that changes the reader's code: - `Button` sizes a nested `Icon`, so an icon needs no `size` prop. - A field component takes no plain `ref`, so `inputRef` is the only way to reach the control. -- Hover and pressed use `color.overlay` over the current semantic fill, so a custom control should - not pick a different background token to mean hovered or pressed. +- Hover and pressed mix `color.overlay.tint` into the current semantic fill, so a custom control + should not pick a different background token to mean hovered or pressed. Cut the detail that only explains the mechanism: diff --git a/docs/STYLING.md b/docs/STYLING.md index 9320bb80..518d15cb 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -25,6 +25,11 @@ with no class and no JS required. Neither step injects styles at runtime. `composeInputStateSelectors`, `descendantDisabledSelector`) field recipes compose. 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/overlay-wash.ts`: `overlayWash(fill, percent)`, the one place hover and pressed feedback + is built. It mixes `color.overlay.tint` into the fill a control already rests on and returns a + plain colour string, so a recipe assigns it to `background-color` or `border-color` and the + control's existing transition animates it. It is named `.ts`, not `.css.ts`, because it emits no + CSS. - `styles/invalid-indicator.ts`: the shared invalid-state `exclamationTriangle` icon, rendered as a CSS mask in two sizes. `invalidIndicatorIcon` (plus `invalidIndicatorIconForcedColors`) is the in-control icon `primitives/combobox/styles.css.ts` applies under its own invalid selector's diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index 82d5891d..0741e748 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -20,7 +20,7 @@ Per colour mode, `compileTheme` (in `build-theme.ts`): `background` — canvas IS the background, not a derived value. 4. Applies the one default semantic mapping (`semantic-map.ts`'s `mapSemanticColors`) that aliases every colour contract leaf onto a family step or a generated surface, passes the authored - backdrop through, and generates the two interaction overlay washes. + backdrop through, and generates the mode's interaction tint. 5. Runs the full WCAG 2.2 validation matrix (`validateContrast`), which stays authoritative and throws `ThemeContrastError` on a hard-gate miss. @@ -105,7 +105,7 @@ 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 step 9 and step 10. Step 10 is a private scale rung used by that gate. It is not a public hover token. Public hover and pressed -feedback uses `color.overlay` over the resting solid fill, plus depth, finish, and transform. +feedback mixes `color.overlay.tint` into the resting solid fill, plus depth, finish, and transform. `defineTheme`'s `adaptAccent` pre-conditioner calls that same function rather than keeping its own copy. That gives two guarantees: @@ -129,15 +129,30 @@ compiler threw an internal error. One list makes both sides move together. `color.loadingSkeleton` maps to the neutral family's step 8, for better perceptibility of the loading state against typical surfaces. -## Interaction overlays +## The interaction tint + +Luke UI generates one interaction ink, `color.overlay.tint`: pure black in light mode and pure white +in dark mode. It is opaque, and a component mixes it into the fill it already rests on with +`color-mix()`. `styles/overlay-wash.ts`'s `overlayWash(fill, percent)` is the one place that shape +lives. + +The strength belongs at the call site rather than in the token, because `color-mix()` cannot +composite a translucent colour onto a fill and return an opaque result: mixing an alpha-0.05 colour +into an opaque one at weight `p` yields alpha `0.05p + (1 - p)`, which reaches 1 only when `p` is 0. +An opaque ink plus a per-call percentage gives an opaque result the control can animate. Button +hovers at 5% and presses at 10% on every appearance; a Checkbox, whose box is too small to read a +faint wash, uses 20% on its border and 5% or 10% on its unchecked fill, and 15% or 20% on both once +it is selected. + +Two mechanisms were considered and rejected. A `background-image` gradient cannot be transitioned, +because `background-image` does not interpolate. An inset `box-shadow` is clipped to the padding +box, so it never tints the 1px border a control draws. -Luke UI generates two narrow semantic interaction overlays from the high-contrast neutral (family -step 12), mixed with transparent: `color.overlay.hover` at 5% and `color.overlay.pressed` at 10%. The authored modal backdrop stays separate as `color.overlay.backdrop`. There is no generated per-family alpha track, and the public contract has no per-role background hover or pressed leaves. -`validateContrast` treats each wash as a translucent colour (the `color-mix()` with transparent), -paints it over the resting fills first-party components actually use, then measures the matching -foregrounds against that opaque result. A `color-mix()` value is never parsed as an opaque colour. +`validateContrast` mixes the tint into each fill first-party components actually use, at the +percentage those components use, then measures the matching foreground against the opaque result. The pairs are ghost Button foregrounds over canvas and recessed, solid and subtle Button tones, -selected Combobox options over accent subtle, and unselected Combobox options over floating. +selected Combobox options over accent subtle, unselected Combobox options over floating, and the +Checkbox's stronger mixes over the accent and danger solid fills. diff --git a/packages/@luke-ui/react/src/primitives/button/recipe.css.ts b/packages/@luke-ui/react/src/primitives/button/recipe.css.ts index ed50d7cb..c9ca11f3 100644 --- a/packages/@luke-ui/react/src/primitives/button/recipe.css.ts +++ b/packages/@luke-ui/react/src/primitives/button/recipe.css.ts @@ -1,12 +1,10 @@ import { styleInLayer } from '../../styles/layered-style.css.js'; +import { overlayWash } from '../../styles/overlay-wash.js'; import type { RecipeSelection } from '../../styles/recipe.js'; import { recipe } from '../../styles/recipe.js'; import { vars } from '../../theme/contract.css.js'; import { FONT_METRIC_SCALE } from '../../theme/font-metric-scale.js'; -const hoverOverlay = `inset 0 0 0 100vmax ${vars.color.overlay.hover}`; -const pressedOverlay = `inset 0 0 0 100vmax ${vars.color.overlay.pressed}`; - const base = styleInLayer('recipes', { '@media': { '(forced-colors: active)': { @@ -74,7 +72,7 @@ const base = styleInLayer('recipes', { opacity: vars.interaction.disabledOpacity, }, '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { - boxShadow: `${vars.depth.raised}, ${hoverOverlay}`, + boxShadow: vars.depth.raised, transform: 'translateY(-1px)', }, '&[data-pending="true"]': { @@ -82,7 +80,7 @@ const base = styleInLayer('recipes', { opacity: vars.interaction.disabledOpacity, }, '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { - boxShadow: `${vars.depth.recessed}, ${pressedOverlay}`, + boxShadow: vars.depth.recessed, transform: 'translateY(1px)', }, }, @@ -191,9 +189,11 @@ function appearance( color, selectors: { '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { + backgroundColor: overlayWash(fill, 5), backgroundImage: vars.actionControlFinish.raised, }, '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { + backgroundColor: overlayWash(fill, 10), backgroundImage: vars.actionControlFinish.recessed, }, }, @@ -211,6 +211,16 @@ function ghostAppearance(tone: Tone, color: string) { borderColor: 'transparent', boxShadow: 'none', color, + selectors: { + '&[data-hovered="true"]:not([data-disabled="true"]):not([data-pending="true"])': { + backgroundColor: overlayWash('transparent', 5), + boxShadow: vars.depth.raised, + }, + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-pending="true"])': { + backgroundColor: overlayWash('transparent', 10), + boxShadow: vars.depth.recessed, + }, + }, }, variants: { appearance: 'ghost' as const, tone }, }; diff --git a/packages/@luke-ui/react/src/primitives/checkbox/recipe.css.ts b/packages/@luke-ui/react/src/primitives/checkbox/recipe.css.ts index 3f8b3265..161e0bf2 100644 --- a/packages/@luke-ui/react/src/primitives/checkbox/recipe.css.ts +++ b/packages/@luke-ui/react/src/primitives/checkbox/recipe.css.ts @@ -1,5 +1,6 @@ import { createVar, fallbackVar } from '@vanilla-extract/css'; import { focusRing, restingFocusRing } from '../../styles/focus-ring.js'; +import { overlayWash } from '../../styles/overlay-wash.js'; import type { RecipeSelection, SlottedConfigInput } from '../../styles/recipe.js'; import { recipe } from '../../styles/recipe.js'; import { textLineHeight } from '../../text/recipe.css.js'; @@ -104,8 +105,7 @@ const checkboxConfig = { lineHeight: 1, ...restingFocusRing(), transitionDuration: vars.motion.duration.feedback, - transitionProperty: - 'background-color, background-image, border-color, box-shadow, color, opacity', + transitionProperty: 'background-color, background-image, border-color, color, opacity', transitionTimingFunction: vars.motion.easing.standard, selectors: { '&::after': { @@ -116,14 +116,18 @@ const checkboxConfig = { opacity: vars.interaction.disabledOpacity, }, '[data-focus-visible="true"] &': focusRing(vars.color.border.focus), - // Overlay is the hover/pressed cue. Semantic borders stay on rest, selected, and invalid. + // A small box carries a wash poorly, so the border takes the stronger mix and the fill the + // weaker one. Each state washes the fill it already rests on, so the accent identity of a + // selected box and the danger identity of an invalid one both survive hover and press. '[data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': { + backgroundColor: overlayWash(vars.color.surface.canvas, 5), backgroundImage: vars.actionControlFinish.raised, - boxShadow: `inset 0 0 0 100vmax ${vars.color.overlay.hover}`, + borderColor: overlayWash(vars.color.border.control, 20), }, '[data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': { + backgroundColor: overlayWash(vars.color.surface.canvas, 10), backgroundImage: vars.actionControlFinish.recessed, - boxShadow: `inset 0 0 0 100vmax ${vars.color.overlay.pressed}`, + borderColor: overlayWash(vars.color.border.control, 20), }, '[data-indeterminate="true"] &': { backgroundColor: vars.color.background.accent.solid, @@ -149,6 +153,38 @@ const checkboxConfig = { borderColor: vars.color.background.danger.solid, color: vars.color.foreground.danger.onSolid, }, + '[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: overlayWash(vars.color.background.accent.solid, 15), + borderColor: overlayWash(vars.color.background.accent.solid, 15), + }, + '[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: overlayWash(vars.color.background.accent.solid, 20), + borderColor: overlayWash(vars.color.background.accent.solid, 20), + }, + '[data-invalid="true"][data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': + { + backgroundImage: vars.actionControlFinish.raised, + borderColor: overlayWash(vars.color.background.danger.solid, 20), + }, + '[data-invalid="true"][data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': + { + backgroundImage: vars.actionControlFinish.recessed, + borderColor: overlayWash(vars.color.background.danger.solid, 20), + }, + '[data-invalid="true"][data-selected="true"][data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &, [data-invalid="true"][data-indeterminate="true"][data-hovered="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': + { + backgroundColor: overlayWash(vars.color.background.danger.solid, 15), + borderColor: overlayWash(vars.color.background.danger.solid, 15), + color: vars.color.foreground.danger.onSolid, + }, + '[data-invalid="true"][data-selected="true"][data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &, [data-invalid="true"][data-indeterminate="true"][data-pressed="true"]:not([data-disabled="true"]):not([data-readonly="true"]) &': + { + backgroundColor: overlayWash(vars.color.background.danger.solid, 20), + borderColor: overlayWash(vars.color.background.danger.solid, 20), + color: vars.color.foreground.danger.onSolid, + }, }, }, }, diff --git a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts index a457ca79..1b77f642 100644 --- a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts +++ b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts @@ -13,6 +13,7 @@ import { } from '../../styles/invalid-indicator.js'; import { styleInLayer } from '../../styles/layered-style.css.js'; import { overlayEnterTransition, overlayExitTransition } from '../../styles/overlay-motion.js'; +import { overlayWash } from '../../styles/overlay-wash.js'; import type { SlottedConfigInput } from '../../styles/recipe.js'; import { recipe } from '../../styles/recipe.js'; import { vars } from '../../theme/contract.css.js'; @@ -78,17 +79,17 @@ const comboboxActionStyles = { order: 1, transform: 'none', transitionDuration: vars.motion.duration.feedback, - transitionProperty: 'background-color, box-shadow, color', + transitionProperty: 'background-color, color', transitionTimingFunction: vars.motion.easing.standard, selectors: { '&[data-disabled="true"]': { cursor: 'not-allowed' }, '&[data-hovered="true"]:not([data-disabled="true"])': { - boxShadow: `inset 0 0 0 100vmax ${vars.color.overlay.hover}`, + backgroundColor: overlayWash('transparent', 5), color: vars.color.text.primary, }, '&[data-pressed="true"]:not([data-disabled="true"])': { - boxShadow: `inset 0 0 0 100vmax ${vars.color.overlay.pressed}`, + backgroundColor: overlayWash('transparent', 10), color: vars.color.text.primary, }, [descendantDisabledSelector]: { color: vars.color.text.disabled }, @@ -361,7 +362,7 @@ const comboboxConfig = { ...restingFocusRing('-2px'), transform: 'none', transitionDuration: vars.motion.duration.feedback, - transitionProperty: 'background-color, box-shadow, color, opacity, outline-color', + transitionProperty: 'background-color, color, opacity, outline-color', transitionTimingFunction: vars.motion.easing.standard, selectors: { @@ -371,10 +372,10 @@ const comboboxConfig = { opacity: vars.interaction.disabledOpacity, }, '&[data-focused="true"]:not([data-disabled="true"])': { - boxShadow: `inset 0 0 0 100vmax ${vars.color.overlay.hover}`, + backgroundColor: overlayWash('transparent', 5), }, '&[data-hovered="true"]:not([data-disabled="true"])': { - boxShadow: `inset 0 0 0 100vmax ${vars.color.overlay.hover}`, + backgroundColor: overlayWash('transparent', 5), }, '&[data-focus-visible="true"]:not([data-disabled="true"])': { outlineColor: vars.color.border.focus, @@ -383,6 +384,12 @@ const comboboxConfig = { backgroundColor: vars.color.background.accent.subtle, fontWeight: vars.font.weight.label, }, + // A selected option already rests on the accent fill, so its wash goes over that fill + // rather than over the transparent one an unselected option rests on. + '&[data-selected="true"][data-focused="true"]:not([data-disabled="true"]), &[data-selected="true"][data-hovered="true"]:not([data-disabled="true"])': + { + backgroundColor: overlayWash(vars.color.background.accent.subtle, 5), + }, }, }, mobileInputGroup: { diff --git a/packages/@luke-ui/react/src/styles/overlay-wash.ts b/packages/@luke-ui/react/src/styles/overlay-wash.ts new file mode 100644 index 00000000..8c4c3c20 --- /dev/null +++ b/packages/@luke-ui/react/src/styles/overlay-wash.ts @@ -0,0 +1,15 @@ +import { vars } from '../theme/contract.css.js'; + +/** + * Mixes the mode's opaque interaction ink into a resting fill, for hover and pressed feedback. + * + * The result is a plain colour, so callers assign it to `backgroundColor` or `borderColor` and let + * the existing feedback transition animate it. Two alternatives do not work: a `background-image` + * gradient cannot be transitioned, because `background-image` does not interpolate; and an inset + * `box-shadow` is clipped to the padding box, so it never tints the border. Where the resting fill + * is `transparent` the mix stays translucent, which is the wanted result for a ghost control, and + * `background-color` still animates. + */ +export function overlayWash(fill: string, percent: number): string { + return `color-mix(in oklab, ${vars.color.overlay.tint} ${percent}%, ${fill})`; +} 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 1ee10d78..fab3a4e6 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -171,8 +171,8 @@ describe('bundled themes meet WCAG 2.2 AA', () => { }); it(`${foundation.name} keeps dark accent subtle legible for primary text`, () => { - // Combobox selected options paint `text.primary` on `background.accent.subtle`. The overlay - // matrix gates that pair with a wash on top; this recomputes the resting fill in dark mode. + // Combobox selected options paint `text.primary` on `background.accent.subtle`. The wash + // matrix gates that pair with tint mixed in; this recomputes the resting fill in dark mode. const { mediaDark } = splitBlocks(buildTheme(foundation)); const textPrimary = parseColor(extractValue(mediaDark, '--luke-color-text-primary')); const subtle = parseColor(extractValue(mediaDark, '--luke-color-background-accent-subtle')); diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts index c8549847..060bb497 100644 --- a/packages/@luke-ui/react/src/theme/contract.test.ts +++ b/packages/@luke-ui/react/src/theme/contract.test.ts @@ -83,17 +83,15 @@ describe('theme contract', () => { } }); - it('exposes overlay backdrop, hover, and pressed, and does not emit scrim', () => { + it('exposes overlay backdrop and tint, and does not emit scrim', () => { const pairs = flattenThemeContract(); const byPath = new Map(pairs); expect(byPath.get('color.overlay.backdrop')).toBe('--luke-color-overlay-backdrop'); - expect(byPath.get('color.overlay.hover')).toBe('--luke-color-overlay-hover'); - expect(byPath.get('color.overlay.pressed')).toBe('--luke-color-overlay-pressed'); + expect(byPath.get('color.overlay.tint')).toBe('--luke-color-overlay-tint'); expect(vars.color.overlay).toEqual({ backdrop: 'var(--luke-color-overlay-backdrop)', - hover: 'var(--luke-color-overlay-hover)', - pressed: 'var(--luke-color-overlay-pressed)', + tint: 'var(--luke-color-overlay-tint)', }); expect(byPath.has('color.scrim')).toBe(false); expect(pairs.some(([, varName]) => varName === '--luke-color-scrim')).toBe(false); diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index 9ccd3f5f..81b3e69e 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -13,8 +13,8 @@ const typeStyle = { /** * The background capabilities every semantic role gets, spread once per role so the six roles cannot - * drift apart. Subtle and solid are resting fills. Transient hover and pressed feedback uses - * `color.overlay`, not per-role state leaves. + * drift apart. Subtle and solid are resting fills. Transient hover and pressed feedback mixes + * `color.overlay.tint` into one of those fills, not per-role state leaves. */ const roleBackground = { subtle: null, @@ -23,7 +23,8 @@ const roleBackground = { /** * The content capabilities every semantic role gets. There is no `pressed` foreground: press is - * carried by `color.overlay.pressed` and non-colour cues, so text and icons reuse `hover`. + * carried by a stronger `color.overlay.tint` mix and non-colour cues, so text and icons reuse + * `hover`. */ const roleForeground = { rest: null, @@ -143,13 +144,13 @@ export const themeContractTree = { overlay: null, }, /** - * Modal dimming (`backdrop`) and generated translucent interaction washes (`hover`, - * `pressed`). + * Modal dimming (`backdrop`, authored and emitted verbatim, so it may carry alpha) and the + * generated opaque interaction ink (`tint`) components mix into a resting fill for hover and + * pressed feedback. */ overlay: { backdrop: null, - hover: null, - pressed: null, + tint: null, }, loadingSkeleton: null, text: { @@ -158,7 +159,7 @@ export const themeContractTree = { /** Dedicated muted text (form fields), not opacity. Emits `--luke-color-text-disabled`. */ disabled: null, }, - /** Subtle and solid resting backgrounds. Hover and pressed use `color.overlay`. */ + /** Subtle and solid resting backgrounds. Hover and pressed mix in `color.overlay.tint`. */ background: { neutral: { ...roleBackground }, accent: { ...roleBackground }, diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts index 7ee1bab7..3b035eff 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts @@ -29,7 +29,6 @@ describe('buildTheme contrast failures', () => { const { baseLight, mediaDark } = splitBlocks(buildTheme(tactileFoundation)); const block = mode === 'dark' ? mediaDark : baseLight; return { - block, values: Object.fromEntries( flattenThemeContract() .filter(([path]) => path.startsWith('color.')) @@ -108,61 +107,45 @@ describe('buildTheme contrast failures', () => { expect(error.message.split('\n').length).toBe(error.failures.length + 1); }); - for (const mode of ['light', 'dark'] as const) { - it(`rejects an interaction overlay wash in ${mode} mode whose composited surface misses the text ratio`, () => { - // Strengthens the emitted pressed wash rather than moving the canvas: a lighter dark canvas - // fails uncomposited accent and danger pairs first, so it cannot isolate this gate. - const { values } = modeColorValues(mode); - const pressed = values['color.overlay.pressed']; - if (pressed === undefined) throw new Error('expected color.overlay.pressed'); - expect(pressed).toContain('10%'); - values['color.overlay.pressed'] = pressed.replace(' 10%, transparent', ' 80%, transparent'); - - const { failures } = validateContrast(mode, values); - expect(failures.length).toBeGreaterThan(0); - expect( - failures.every((failure) => failure.background.startsWith('color.overlay.pressed over ')), - ).toBe(true); - expect( - failures.some((failure) => { - return ( - failure.foreground === 'color.foreground.danger.rest' && - failure.background === 'color.overlay.pressed over color.surface.canvas' && - failure.ratio < 4.5 - ); - }), - ).toBe(true); - expect( - failures.some((failure) => { - return failure.foreground === 'color.foreground.accent.rest' && failure.ratio < 4.5; - }), - ).toBe(true); - }); - } + it('rejects an interaction wash whose composited fill misses the text ratio', () => { + // A light theme's tint is meant to darken; a white one lightens the accent fill towards the pale + // label it carries. Only the tint moves, so every resulting failure has to be a washed pair — + // moving a fill or a foreground instead would fail the resting pairs first and prove nothing + // about this gate. The Checkbox's 20% mix is the strongest wash and so the first to give way. + const { values } = modeColorValues('light'); + values['color.overlay.tint'] = 'oklch(1 0 0)'; - it('does not parse an interaction overlay as an opaque colour', () => { - const { block, values } = modeColorValues('dark'); - values['color.overlay.hover'] = extractValue(block, '--luke-color-text-primary'); - expect(() => validateContrast('dark', values)).toThrow( - /"color.overlay.hover" must be color-mix\(in oklab/, + const { failures } = validateContrast('light', values); + expect(failures.length).toBeGreaterThan(0); + expect(failures.every((failure) => failure.background.startsWith('color.overlay.tint '))).toBe( + true, ); + expect( + failures.some((failure) => { + return ( + failure.foreground === 'color.foreground.accent.onSolid' && + failure.background === 'color.overlay.tint 20% over color.background.accent.solid' && + failure.ratio < 4.5 + ); + }), + ).toBe(true); }); - it('measures ghost overlay contrast from the painted sRGB result', () => { + it('measures wash contrast from the painted sRGB result', () => { const { values } = modeColorValues('light'); values['color.surface.canvas'] = 'oklch(1 0 0)'; - values['color.overlay.hover'] = 'color-mix(in oklab, oklch(0 0 0) 50%, transparent)'; + values['color.overlay.tint'] = 'oklch(0 0 0)'; values['color.text.primary'] = 'oklch(0 0 0)'; const { checks } = validateContrast('light', values); const check = checks.find((candidate) => { return ( candidate.foreground === 'color.text.primary' && - candidate.background === 'color.overlay.hover over color.surface.canvas' + candidate.background === 'color.overlay.tint 10% over color.surface.canvas' ); }); expect(check).toBeDefined(); - const grayLuminance = ((0.5 + 0.055) / 1.055) ** 2.4; + const grayLuminance = ((0.9 + 0.055) / 1.055) ** 2.4; expect(check?.ratio).toBeCloseTo((grayLuminance + 0.05) / 0.05, 5); }); }); @@ -181,9 +164,10 @@ describe('contrast validation matrix', () => { // Hard checks `validateContrast` runs once, not per role: functional primary/secondary text // against the 4 elevation surfaces (8), the focus ring and `border.control` boundaries // against the 2 base surfaces (4), `danger.solid` against the 2 base surfaces (2), and - // interaction overlays over real component fills (ghost 12 + solid 6 + subtle 6 + - // combobox selected 2 + combobox unselected 2 = 28). - const OVERLAY_HARD_CHECKS = 28; + // interaction washes over real component fills. The Button washes run at both 5% and 10% + // (ghost 12 + solid 6 + subtle 6 + combobox selected 2 + combobox unselected 2 = 28), and the + // Checkbox adds its stronger 15% and 20% mixes over accent and danger solid (4). + const OVERLAY_HARD_CHECKS = 28 + 4; const NON_PER_ROLE_HARD_CHECKS = 8 + 4 + 2 + OVERLAY_HARD_CHECKS; const expectedHard = @@ -231,24 +215,28 @@ describe('contrast validation matrix', () => { hard: expectedHard, mode, overlayBackgrounds: [ - 'color.overlay.hover over color.background.accent.solid', - 'color.overlay.hover over color.background.accent.subtle', - 'color.overlay.hover over color.background.danger.solid', - 'color.overlay.hover over color.background.danger.subtle', - 'color.overlay.hover over color.background.neutral.solid', - 'color.overlay.hover over color.background.neutral.subtle', - 'color.overlay.hover over color.surface.canvas', - 'color.overlay.hover over color.surface.floating', - 'color.overlay.hover over color.surface.recessed', - 'color.overlay.pressed over color.background.accent.solid', - 'color.overlay.pressed over color.background.accent.subtle', - 'color.overlay.pressed over color.background.danger.solid', - 'color.overlay.pressed over color.background.danger.subtle', - 'color.overlay.pressed over color.background.neutral.solid', - 'color.overlay.pressed over color.background.neutral.subtle', - 'color.overlay.pressed over color.surface.canvas', - 'color.overlay.pressed over color.surface.floating', - 'color.overlay.pressed over color.surface.recessed', + 'color.overlay.tint 10% over color.background.accent.solid', + 'color.overlay.tint 10% over color.background.accent.subtle', + 'color.overlay.tint 10% over color.background.danger.solid', + 'color.overlay.tint 10% over color.background.danger.subtle', + 'color.overlay.tint 10% over color.background.neutral.solid', + 'color.overlay.tint 10% over color.background.neutral.subtle', + 'color.overlay.tint 10% over color.surface.canvas', + 'color.overlay.tint 10% over color.surface.floating', + 'color.overlay.tint 10% over color.surface.recessed', + 'color.overlay.tint 15% over color.background.accent.solid', + 'color.overlay.tint 15% over color.background.danger.solid', + 'color.overlay.tint 20% over color.background.accent.solid', + 'color.overlay.tint 20% over color.background.danger.solid', + 'color.overlay.tint 5% over color.background.accent.solid', + 'color.overlay.tint 5% over color.background.accent.subtle', + 'color.overlay.tint 5% over color.background.danger.solid', + 'color.overlay.tint 5% over color.background.danger.subtle', + 'color.overlay.tint 5% over color.background.neutral.solid', + 'color.overlay.tint 5% over color.background.neutral.subtle', + 'color.overlay.tint 5% over color.surface.canvas', + 'color.overlay.tint 5% over color.surface.floating', + 'color.overlay.tint 5% over color.surface.recessed', ], overlayForegrounds: [ 'color.foreground.accent.hover', diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.ts b/packages/@luke-ui/react/src/theme/contrast-validation.ts index 6f22f251..cd2d11db 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.ts @@ -19,17 +19,70 @@ const GHOST_FOREGROUNDS = [ const BUTTON_TONES = ['neutral', 'accent', 'danger'] as const; +/** The two surfaces a control can rest directly on. */ +const BASE_SURFACES = ['color.surface.canvas', 'color.surface.recessed'] as const; + const SUBTLE_BUTTON_FOREGROUND = { accent: 'color.foreground.accent.hover', danger: 'color.foreground.danger.hover', neutral: 'color.text.primary', } as const; -const INTERACTION_OVERLAYS = ['color.overlay.hover', 'color.overlay.pressed'] as const; +/** One first-party pair that paints content over a fill washed with `color.overlay.tint`. */ +interface WashedPair { + /** The fill the control rests on, which the tint is mixed into. */ + fill: string; + /** The token path of the content painted on top. */ + foreground: string; + /** How much tint the component's recipe mixes in. */ + percent: number; +} -/** The emitted hover and pressed overlay shape: an opaque OKLCH mixed with transparent in OKLab. */ -const INTERACTION_OVERLAY_VALUE = - /^color-mix\(in oklab, (oklch\([^)]+\)) (\d+(?:\.\d+)?)%, transparent\)$/; +/** + * The washes first-party recipes actually paint, so the validated pairs track the component code. + * Button hovers at 5% and presses at 10% on every appearance; Checkbox uses the stronger 15% and 20% + * mixes a small control needs. + */ +const WASHED_PAIRS: ReadonlyArray = [ + ...[5, 10].flatMap((percent) => [ + // Ghost Button and IconButton: a transparent rest fill on canvas or recessed, so the wash lands + // on the surface itself. + ...BASE_SURFACES.flatMap((fill) => + GHOST_FOREGROUNDS.map((foreground) => ({ fill, foreground, percent })), + ), + // Solid and subtle Button and IconButton tones, over their own resting fill. + ...BUTTON_TONES.flatMap((tone) => [ + { + fill: `color.background.${tone}.solid`, + foreground: `color.foreground.${tone}.onSolid`, + percent, + }, + { + fill: `color.background.${tone}.subtle`, + foreground: SUBTLE_BUTTON_FOREGROUND[tone], + percent, + }, + ]), + // Combobox: a selected option rests on the accent subtle fill, an unselected one on the + // floating popover. + { fill: 'color.background.accent.subtle', foreground: 'color.text.primary', percent }, + { fill: 'color.surface.floating', foreground: 'color.text.primary', percent }, + ]), + // Checkbox: a selected or indeterminate box, and its invalid counterpart, wash their own solid + // fill while keeping the matching on-solid glyph. + ...[15, 20].flatMap((percent) => [ + { + fill: 'color.background.accent.solid', + foreground: 'color.foreground.accent.onSolid', + percent, + }, + { + fill: 'color.background.danger.solid', + foreground: 'color.foreground.danger.onSolid', + percent, + }, + ]), +]; type ColorMode = 'light' | 'dark'; @@ -53,7 +106,7 @@ interface ValidationResult { } /** - * Runs the full semantic validation matrix over the emitted (rounded) colour values: 84 hard checks + * Runs the full semantic validation matrix over the emitted (rounded) colour values: 88 hard checks * and 12 advisory checks per mode. Every pair is recorded as a {@link ContrastCheck}, and the hard * ones populate `failures` (which `compileTheme` raises as a * {@link import('./build-theme.js').ThemeContrastError}). @@ -61,9 +114,10 @@ interface ValidationResult { * Hard at the AA text ratio: functional primary and secondary text against all four elevation * surfaces; every role's resting and hover foreground against the base surfaces and that role's own * subtle fill; every role's on-solid foreground against its solid fill; and the real component - * contracts that paint `overlay.hover` / `overlay.pressed` over a resting fill (ghost Button on - * canvas and recessed, solid and subtle Button tones, selected Combobox options on accent subtle, - * unselected Combobox options on floating). Hard at the non-text ratio: the authored focus ring and + * contracts that mix `overlay.tint` into a resting fill (ghost Button on canvas and recessed, solid + * and subtle Button tones, selected Combobox options on accent subtle, unselected Combobox options + * on floating, and the Checkbox's stronger mixes over accent and danger solid). Hard at the non-text + * ratio: the authored focus ring and * `border.control`, which is `control-border.ts`'s solved boundary rather than a scale-step alias; * and `danger.solid` against the base surfaces, because it is the only role fill that carries a * required state's boundary (the invalid field boundary). This last gate is deliberately not @@ -110,7 +164,7 @@ export function validateContrast( const surfacePaths = ['canvas', 'recessed', 'floating', 'overlay'].map( (surface) => `color.surface.${surface}`, ); - const basePaths = ['color.surface.canvas', 'color.surface.recessed']; + const basePaths = BASE_SURFACES; // Functional text vs every mapped elevation surface: 8 checks. for (const text of ['color.text.primary', 'color.text.secondary']) { @@ -146,50 +200,16 @@ export function validateContrast( check(`color.border.${role}`, background, UI_RATIO, false); } } - // Interaction overlays composite over the resting fill the control already has. Mix with - // transparent sets alpha; painting then source-overs that colour. Do not parse the `color-mix()` - // string as an opaque colour. Pairs follow first-party recipes, not every theoretical combination. - const checkOverlay = (foreground: string, overlayPath: string, surface: string) => { - const overlayValue = colorValues[overlayPath]; - if (overlayValue === undefined) throw new Error(`buildTheme did not generate "${overlayPath}"`); - const composited = compositeOverlay(overlayValue, overlayPath, colorAt(surface)); - checkResolved(foreground, `${overlayPath} over ${surface}`, composited, TEXT_RATIO, true); - }; - for (const overlayPath of INTERACTION_OVERLAYS) { - // Ghost Button / IconButton: transparent rest on canvas or recessed. - for (const surface of basePaths) { - for (const foreground of GHOST_FOREGROUNDS) { - checkOverlay(foreground, overlayPath, surface); - } - } - // Solid Button / IconButton, and selected or invalid Checkbox, which reuse the same on-solid - // pairing over accent or danger solid. - for (const tone of BUTTON_TONES) { - checkOverlay( - `color.foreground.${tone}.onSolid`, - overlayPath, - `color.background.${tone}.solid`, - ); - checkOverlay(SUBTLE_BUTTON_FOREGROUND[tone], overlayPath, `color.background.${tone}.subtle`); - } - // Combobox selected option: primary text on accent subtle. Unselected focused or hovered - // option: primary text on the floating popover. - checkOverlay('color.text.primary', overlayPath, 'color.background.accent.subtle'); - checkOverlay('color.text.primary', overlayPath, 'color.surface.floating'); + // Interaction washes mix the opaque tint into the resting fill the control already has. Browsers + // composite in gamma-encoded sRGB, which is what `compositeOver` does. Pairs come from the + // `WASHED_PAIRS` table, which follows first-party recipes rather than every theoretical + // combination: 32 checks. + const tint = colorAt('color.overlay.tint'); + for (const { foreground, fill, percent } of WASHED_PAIRS) { + const washed = compositeOver(tint, colorAt(fill), percent / 100); + const background = `color.overlay.tint ${percent}% over ${fill}`; + checkResolved(foreground, background, washed, TEXT_RATIO, true); } return { checks, failures }; } - -/** Reads an emitted `color-mix(..., transparent)` overlay and paints it over an opaque surface. */ -function compositeOverlay(overlayValue: string, overlayPath: string, surface: Oklch): Oklch { - const match = INTERACTION_OVERLAY_VALUE.exec(overlayValue); - const sourceValue = match?.[1]; - const percentText = match?.[2]; - if (sourceValue === undefined || percentText === undefined) { - throw new Error( - `"${overlayPath}" must be color-mix(in oklab, oklch(...) N%, transparent); received "${overlayValue}"`, - ); - } - return compositeOver(parseColor(sourceValue), surface, Number(percentText) / 100); -} 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 09089800..f2889bc7 100644 --- a/packages/@luke-ui/react/src/theme/define-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts @@ -307,8 +307,7 @@ describe('defineTheme emits the full contract for the bundled themes', () => { expect(emitted.size).toBe(contractNames.length); expect([...emitted].sort()).toEqual([...contractNames].sort()); expect(emitted.has('--luke-color-overlay-backdrop')).toBe(true); - expect(emitted.has('--luke-color-overlay-hover')).toBe(true); - expect(emitted.has('--luke-color-overlay-pressed')).toBe(true); + expect(emitted.has('--luke-color-overlay-tint')).toBe(true); expect(emitted.has('--luke-color-scrim')).toBe(false); expect(emitted.has('--luke-color-text-disabled')).toBe(true); }); 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 3cc256b3..45466c85 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.test.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.test.ts @@ -88,11 +88,8 @@ describe('mapSemanticColors', () => { expect(result['color.surface.floating']).toBe(formatOklch(surfaces.floating)); expect(result['color.surface.overlay']).toBe(formatOklch(surfaces.overlay)); expect(result['color.overlay.backdrop']).toBe(backdrop); - expect(result['color.overlay.hover']).toBe( - `color-mix(in oklab, ${formatOklch(families.neutral[12])} 5%, transparent)`, - ); - expect(result['color.overlay.pressed']).toBe( - `color-mix(in oklab, ${formatOklch(families.neutral[12])} 10%, transparent)`, + expect(result['color.overlay.tint']).toBe( + mode === 'light' ? 'oklch(0 0 0)' : 'oklch(1 0 0)', ); expect(result['color.loadingSkeleton']).toBe(formatOklch(families.neutral[8])); @@ -179,5 +176,21 @@ describe('mapSemanticColors', () => { expect(result['color.overlay.backdrop']).toBe(backdrop); }); + + it('emits an opaque tint that flips with the mode', () => { + const tintFor = (mode: 'light' | 'dark') => { + const background = BACKGROUND[mode]; + return mapSemanticColors({ + backdrop: 'oklch(0 0 0 / 0.5)', + controlBorder: CONTROL_BORDER[mode], + families: buildFamilies(mode, background), + mode, + surfaces: generateSurfaces({ background, mode }), + })['color.overlay.tint']; + }; + + expect(tintFor('light')).toBe('oklch(0 0 0)'); + expect(tintFor('dark')).toBe('oklch(1 0 0)'); + }); }); }); diff --git a/packages/@luke-ui/react/src/theme/semantic-map.ts b/packages/@luke-ui/react/src/theme/semantic-map.ts index bf653118..a6c5d67b 100644 --- a/packages/@luke-ui/react/src/theme/semantic-map.ts +++ b/packages/@luke-ui/react/src/theme/semantic-map.ts @@ -1,8 +1,8 @@ /** * The one default semantic colour mapping. `mapSemanticColors` aliases every generated colour * contract leaf onto a private scale family's step or a generated surface, per the locked mapping - * table. It is a pure lookup aside from two generated interaction overlays mixed from the - * high-contrast neutral. It never distorts a family or surface to make a leaf fit. + * table. It is a pure lookup aside from the generated interaction ink, which is pure black in light + * mode and pure white in dark mode. It never distorts a family or surface to make a leaf fit. * * The role list it keys off comes from `contrast-policy.ts`, which `build-theme.ts`'s validation * matrix reads too, so a role can never be emitted here without being gated there. Values are @@ -41,19 +41,25 @@ interface MapSemanticColorsRequest { surfaces: GeneratedSurfaces; } -/** Mixes the high-contrast neutral foreground into a translucent interaction wash. */ -function interactionOverlay(foreground: Oklch, percent: 5 | 10): string { - return `color-mix(in oklab, ${formatOklch(foreground)} ${percent}%, transparent)`; -} +/** + * The opaque interaction ink for each mode. A component mixes it into its own resting fill at a + * small percentage, so the ink itself has to be opaque: `color-mix()` cannot composite a translucent + * colour onto a fill and still return an opaque result. Pure black darkens a light interface and + * pure white lightens a dark one, which is what an interaction wash should do in each mode. + */ +const OVERLAY_TINT: Record = { + dark: { c: 0, h: 0, l: 1 }, + light: { c: 0, h: 0, l: 0 }, +}; /** * Resolves every colour contract leaf onto the private families and surfaces, per the locked * semantic mapping table. `families` and `surfaces` are already mode-resolved. `backdrop` passes - * through verbatim. Hover and pressed overlays mix `families.neutral[12]` with transparent. - * `focus` defaults to the accent family's step 8 when the theme author omits it. + * through verbatim. `color.overlay.tint` is the mode's opaque interaction ink. `focus` defaults to + * the accent family's step 8 when the theme author omits it. */ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticColorValues { - const { families, surfaces, backdrop, focus, controlBorder } = request; + const { families, surfaces, backdrop, focus, controlBorder, mode } = request; const neutral = families.neutral; const values: Record = {}; @@ -63,8 +69,7 @@ export function mapSemanticColors(request: MapSemanticColorsRequest): SemanticCo values['color.surface.floating'] = formatOklch(surfaces.floating); values['color.surface.overlay'] = formatOklch(surfaces.overlay); values['color.overlay.backdrop'] = backdrop; - values['color.overlay.hover'] = interactionOverlay(neutral[12], 5); - values['color.overlay.pressed'] = interactionOverlay(neutral[12], 10); + values['color.overlay.tint'] = formatOklch(OVERLAY_TINT[mode]); values['color.loadingSkeleton'] = formatOklch(neutral[8]); // Functional text and borders: neutral only, and distinct from the six shared roles that share the diff --git a/packages/@luke-ui/react/src/theme/stylesheet.test.ts b/packages/@luke-ui/react/src/theme/stylesheet.test.ts index ef6d390d..055942b3 100644 --- a/packages/@luke-ui/react/src/theme/stylesheet.test.ts +++ b/packages/@luke-ui/react/src/theme/stylesheet.test.ts @@ -106,21 +106,14 @@ describe('buildTheme output', () => { } it('emits every colour value in OKLCH', () => { - const colorMixVarNames = new Set([ - '--luke-color-overlay-hover', - '--luke-color-overlay-pressed', - ]); const colorVarNames = pairs .filter(([path]) => path.startsWith('color.')) .map(([, varName]) => varName); for (const block of [blocks.baseLight, blocks.mediaDark]) { const nonOklch = colorVarNames.filter((varName) => { - return !colorMixVarNames.has(varName) && !extractValue(block, varName).startsWith('oklch('); + return !extractValue(block, varName).startsWith('oklch('); }); expect(nonOklch).toEqual([]); - for (const varName of colorMixVarNames) { - expect(extractValue(block, varName).startsWith('color-mix(in oklab,')).toBe(true); - } } }); @@ -131,8 +124,7 @@ describe('buildTheme output', () => { expect(css).toContain('--luke-color-border-danger'); expect(css).toContain('--luke-color-loading-skeleton'); expect(css).toContain('--luke-color-overlay-backdrop'); - expect(css).toContain('--luke-color-overlay-hover'); - expect(css).toContain('--luke-color-overlay-pressed'); + expect(css).toContain('--luke-color-overlay-tint'); expect(css).toContain('--luke-color-text-disabled'); expect(css).toContain('--luke-color-foreground-accent-hover'); expect(css).toContain('--luke-depth-raised'); diff --git a/packages/@luke-ui/react/src/theme/token-board.test.ts b/packages/@luke-ui/react/src/theme/token-board.test.ts index 965d4ee2..6fa1f7e9 100644 --- a/packages/@luke-ui/react/src/theme/token-board.test.ts +++ b/packages/@luke-ui/react/src/theme/token-board.test.ts @@ -35,15 +35,10 @@ describe('buildTokenTree', () => { path: 'color.overlay.backdrop', varName: '--luke-color-overlay-backdrop', }, - hover: { + tint: { kind: 'leaf', - path: 'color.overlay.hover', - varName: '--luke-color-overlay-hover', - }, - pressed: { - kind: 'leaf', - path: 'color.overlay.pressed', - varName: '--luke-color-overlay-pressed', + path: 'color.overlay.tint', + varName: '--luke-color-overlay-tint', }, }, }); diff --git a/packages/@luke-ui/react/src/theme/token-board.tsx b/packages/@luke-ui/react/src/theme/token-board.tsx index 5ab194a0..adb2c229 100644 --- a/packages/@luke-ui/react/src/theme/token-board.tsx +++ b/packages/@luke-ui/react/src/theme/token-board.tsx @@ -240,7 +240,10 @@ interface LeafPreviewProps { type PreviewRenderer = (props: LeafPreviewProps) => ReactNode; function ColorPreview({ path, varName }: LeafPreviewProps) { - if (path.startsWith('color.overlay.')) { + // `overlay.backdrop` is the one colour leaf that may carry alpha, so it is layered over the canvas + // to show what it actually looks like in place. Every other leaf, `overlay.tint` included, is + // opaque and reads correctly on its own. + if (path === 'color.overlay.backdrop') { return (