From 2c9f536e6f577bbe52d3f7d9cb701fec99fc9870 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 22:01:34 +0000 Subject: [PATCH] Mix hover and pressed fills with color-mix instead of gradient washes. Recipes now assign background-color: color-mix(in srgb, , ) at the shared 5% and 10% strengths. Contrast validation runs that same sRGB mix. Public overlay.hover and overlay.pressed alias the high-contrast neutral source. Co-authored-by: Luke Bennett --- .../content/docs/docs/authoring-a-theme.mdx | 7 +- apps/docs/content/docs/docs/color.mdx | 45 ++++++------- .../content/docs/docs/token-reference.mdx | 8 +-- apps/docs/src/lib/token-purpose-groups.ts | 4 +- apps/docs/src/styles/app.css | 4 +- docs/DOCUMENTATION.md | 2 +- docs/STYLING.md | 5 +- docs/THEME_COLOUR_GENERATION.md | 26 ++++---- .../react/src/button/button.visual.test.tsx | 23 +------ .../src/checkbox/checkbox.visual.test.tsx | 58 ----------------- .../combobox-field.visual.test.tsx | 21 ------ .../icon-button/icon-button.visual.test.tsx | 28 -------- .../react/src/primitives/button/recipe.css.ts | 14 ++-- .../src/primitives/checkbox/recipe.css.ts | 38 +++++++++-- .../src/primitives/combobox/styles.css.ts | 19 ++++-- .../react/src/styles/interaction-fill.ts | 12 ++++ .../src/theme/__fixtures__/radix-scales.ts | 19 +++--- .../react/src/theme/build-theme.test.ts | 8 +-- .../@luke-ui/react/src/theme/color.test.ts | 12 ++-- packages/@luke-ui/react/src/theme/color.ts | 30 ++++----- .../@luke-ui/react/src/theme/contract.test.ts | 3 +- packages/@luke-ui/react/src/theme/contract.ts | 4 +- .../src/theme/contrast-validation.test.ts | 63 +++++++----------- .../react/src/theme/contrast-validation.ts | 65 ++++++++----------- .../@luke-ui/react/src/theme/diagnostics.ts | 2 +- .../src/theme/interaction-overlay.test.ts | 26 ++++++++ .../react/src/theme/interaction-overlay.ts | 41 ++++++++++++ .../@luke-ui/react/src/theme/scale.test.ts | 42 +++++------- packages/@luke-ui/react/src/theme/scale.ts | 26 ++++---- .../react/src/theme/semantic-map.test.ts | 30 ++------- .../@luke-ui/react/src/theme/semantic-map.ts | 26 ++------ .../react/src/theme/stylesheet.test.ts | 9 +-- .../@luke-ui/react/src/theme/token-board.tsx | 18 ++++- 33 files changed, 327 insertions(+), 411 deletions(-) create mode 100644 packages/@luke-ui/react/src/styles/interaction-fill.ts create mode 100644 packages/@luke-ui/react/src/theme/interaction-overlay.test.ts create mode 100644 packages/@luke-ui/react/src/theme/interaction-overlay.ts diff --git a/apps/docs/content/docs/docs/authoring-a-theme.mdx b/apps/docs/content/docs/docs/authoring-a-theme.mdx index d026fe8e..0b8263a1 100644 --- a/apps/docs/content/docs/docs/authoring-a-theme.mdx +++ b/apps/docs/content/docs/docs/authoring-a-theme.mdx @@ -25,8 +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. The interaction tint is generated per mode, not -authored. `radius` is a generative `base` and `multiplier` scale with explicit per-step overrides. +may include alpha, and Luke UI emits it verbatim. Hover and pressed interaction colours are +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,7 +106,7 @@ 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 +- The first-party contracts that mix `color.overlay.hover` or `color.overlay.pressed` 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. diff --git a/apps/docs/content/docs/docs/color.mdx b/apps/docs/content/docs/docs/color.mdx index 574d79b7..83d59ddc 100644 --- a/apps/docs/content/docs/docs/color.mdx +++ b/apps/docs/content/docs/docs/color.mdx @@ -11,26 +11,26 @@ mode. A component then stays consistent, because it does not need to know the un Surface roles describe where an element sits in the interface: -- `canvas`: the page. -- `recessed`: an inset area. -- `floating`: elevated UI, such as menus and cards. -- `overlay`: dialogs and other high-elevation surfaces. This is an opaque colour. +- `color.surface.canvas`: the page. +- `color.surface.recessed`: an inset area. +- `color.surface.floating`: elevated UI, such as menus and cards. +- `color.surface.overlay`: dialogs and other high-elevation surfaces. This is an opaque colour. `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.pressed` are the generated interaction sources recipes mix into the current fill. +They 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. Interaction overlays describe transient +hover and pressed feedback. Mix the overlay source into 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 interaction colour does not replace those states. Do not use overlays 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 `color.overlay.hover` or +`color.overlay.pressed` into that fill. Text uses `primary` and `secondary` roles. Borders distinguish decorative, control, and focus uses. @@ -70,7 +70,7 @@ 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.hover` or `color.overlay.pressed` into that fill: ```tsx import { vars } from '@luke-ui/react/theme'; @@ -89,8 +89,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`. Interaction overlays 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 @@ -122,11 +123,11 @@ 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..surface.subtleHover` | `color.overlay.hover` mixed into `color.background..subtle` | +| `color.intent..surface.subtlePressed` | `color.overlay.pressed` mixed into `color.background..subtle` | +| `color.intent..surface.solid` | `color.background..solid` | +| `color.intent..surface.solidHover` | `color.overlay.hover` mixed into `color.background..solid` | +| `color.intent..surface.solidPressed` | `color.overlay.pressed` mixed into `color.background..solid` | | `color.intent..text` | `color.foreground..rest` | | `color.intent..textHover` | `color.foreground..hover` | | `color.intent..onSolid` | `color.foreground..onSolid` | @@ -138,8 +139,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.hover` or `color.overlay.pressed` into the resting subtle or solid fill. ## Continue learning diff --git a/apps/docs/content/docs/docs/token-reference.mdx b/apps/docs/content/docs/docs/token-reference.mdx index 59b36697..da8fa751 100644 --- a/apps/docs/content/docs/docs/token-reference.mdx +++ b/apps/docs/content/docs/docs/token-reference.mdx @@ -67,10 +67,10 @@ 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. +Pair each depth role with the matching surface role. For example, a menu can use +`color.surface.floating` with `depth.floating`, while a dialog can use `color.surface.overlay` with +`depth.overlay`. `color.overlay` is a different group: the translucent backdrop and the generated +interaction sources. 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.ts b/apps/docs/src/lib/token-purpose-groups.ts index 7ca62b9b..af521c68 100644 --- a/apps/docs/src/lib/token-purpose-groups.ts +++ b/apps/docs/src/lib/token-purpose-groups.ts @@ -88,7 +88,7 @@ const PURPOSE_DEFINITIONS = [ }, { description: - 'Transient hover and pressed washes, plus state effects a control applies to its own material.', + 'Transient hover and pressed interaction sources, plus state effects a control applies to its own material.', id: 'interaction', related: null, showSamples: true, @@ -140,7 +140,7 @@ function resolveColorPurpose(path: string): TokenPurposeId | undefined { return leaf !== undefined && STRUCTURAL_BORDERS.has(leaf) ? 'borders' : 'roles'; } // `backdrop` is a dimming layer, so it belongs with the surfaces; hover and pressed are - // interaction washes. + // interaction sources. 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 9d15a028..b6205202 100644 --- a/apps/docs/src/styles/app.css +++ b/apps/docs/src/styles/app.css @@ -29,10 +29,10 @@ 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-overlay-hover); + --color-fd-accent: var(--luke-color-background-neutral-subtle); --color-fd-accent-foreground: var(--luke-color-text-primary); --color-fd-ring: var(--luke-color-border-focus); - --color-fd-overlay: var(--luke-color-surface-overlay); + --color-fd-overlay: var(--luke-color-overlay-backdrop); } /* Keep Fumadocs' 28px DocsTitle with Luke UI's matching 36px line height */ diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index e2ec6c76..8bd3ffd3 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -50,7 +50,7 @@ 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 +- Hover and pressed mix `color.overlay` 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..15743ec9 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -127,8 +127,9 @@ instead of deriving them from strength multipliers and hidden formulas. Each mode also authors final `background-image` values for `actionControlFinish.resting`, `actionControlFinish.raised`, and `actionControlFinish.recessed`. Button and IconButton layer this -face lighting over their semantic surface colour. Ghost controls and forced-colours rendering do not -use the authored finish. +face lighting over their semantic surface colour. Hover and pressed change `background-color` by +mixing `color.overlay` into that fill. Ghost controls and forced-colours rendering do not use the +authored finish. Use `deriveConcentricRadius(innerRadius, gap)` for rounded elements nested inside another rounded surface. It returns a CSS `calc()` value for the outer radius, so both inputs can be semantic theme diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index cacb8ec0..2cdb0b38 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 aliases the two interaction overlay sources to the high-contrast neutral. 5. Runs the full WCAG 2.2 validation matrix (`validateContrast`), which stays authoritative and throws `ThemeContrastError` on a hard-gate miss. @@ -104,10 +104,9 @@ Accent adaptation is forgiving but never sacrifices the AA on-solid guarantee: `scale.ts`'s `passesOnSolidGate` is the only function that decides whether a solid can carry readable text. It asks whether the near-white or near-black on-solid colour the generator would choose clears the AA text ratio plus the search headroom against the public resting solid (step 9). -Step 10 remains in the private 12-step family so the scale keeps its shape. It is not a hover, -solid-hover, or pressed colour. Public hover and pressed feedback uses `color.overlay` over the -resting solid fill, plus depth, finish, and transform. Those composited pairs are hard-gated by -`validateContrast`. +Step 10 remains in the private 12-step family so the scale keeps its shape. It is not a rendered +interaction colour. Public hover and pressed feedback mixes `color.overlay` into the resting solid +fill, plus depth, finish, and transform. Those mixed fills are hard-gated by `validateContrast`. `defineTheme`'s `adaptAccent` pre-conditioner calls that same function rather than keeping its own copy. That gives two guarantees: @@ -133,13 +132,14 @@ loading state against typical surfaces. ## Interaction overlays -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 +Luke UI generates two interaction overlay sources from the high-contrast neutral +(`families.neutral[12]`). `color.overlay.hover` and `color.overlay.pressed` both alias that source. +Recipes mix it into the current semantic fill at the shared strengths in `interaction-overlay.ts`: +hover at 5% and pressed at 10%, as `color-mix(in srgb, <100-N>%, %)`. 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. -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. +`validateContrast` runs the same sRGB mix the recipes emit, then measures the matching foregrounds +against that 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. diff --git a/packages/@luke-ui/react/src/button/button.visual.test.tsx b/packages/@luke-ui/react/src/button/button.visual.test.tsx index 55fccaa9..1f12b812 100644 --- a/packages/@luke-ui/react/src/button/button.visual.test.tsx +++ b/packages/@luke-ui/react/src/button/button.visual.test.tsx @@ -88,7 +88,7 @@ test('ghost interactive states', async () => { await userEvent.keyboard('{/Space}'); }); -test('ghost interactive states in dark mode', async () => { +test('ghost hover in dark mode', async () => { render(, { appearance: { mode: 'dark', theme: 'tactile' }, }); @@ -96,11 +96,6 @@ test('ghost interactive states in dark mode', async () => { await userEvent.hover(button); await captureVisual(button, 'button/ghost-hover-tactile-dark'); - await userEvent.unhover(button); - await focusViaKeyboard(button); - await userEvent.keyboard('{Space>}'); - await captureVisual(button, 'button/ghost-pressed-tactile-dark'); - await userEvent.keyboard('{/Space}'); }); test('overlay over solid accent', async () => { @@ -120,22 +115,6 @@ test('overlay over solid accent', async () => { await userEvent.keyboard('{/Space}'); }); -test('overlay over solid accent in dark mode', async () => { - render( - , - { - appearance: { mode: 'dark', theme: 'tactile' }, - }, - ); - const button = page.getByRole('button', { name: 'Save' }); - - await userEvent.hover(button); - await captureVisual(button, 'button/solid-accent-hover-tactile-dark'); - await userEvent.unhover(button); -}); - test('overlay over subtle danger', async () => { render(