Files
supabase/packages/ui/build/css/source/semantic.css
Danny White 5f9ce727c0 fix(ui): systematise control surface fills and hover borders (#48887)
## What kind of change does this PR introduce?

Bug fix / design-system token hygiene for form and selector chrome.

## What is the current behavior?

After opaque default-button fills, text fields, selects, and selector
tiles drifted apart: inputs and selects mixed ad-hoc washes, hover
borders bounced between `border-stronger` / `border-foreground-muted`,
invalid fields had no hover step, and composites like InputGroup leaked
inner hover borders.

Follow-up to #48837 (opaque button fills) where Select rest still felt
darker than Input on forms such as scoped access tokens.

## What is the new behavior?

Named control roles and one interactive border:

| Role | Fill | Rest border | Hover / focus / open |
| --- | --- | --- | --- |
| Field (sunk) | `bg-field` | `border-control` | `border-control-hover`
|
| Raised control | `bg-control-raised` | `border-strong` |
`border-control-hover` |
| Overlaying action | card → popover | `border-strong` |
`border-control-hover` |
| Invalid field | `bg-destructive-200` | `border-destructive-400` |
`border-destructive` |

- `--field` / `--control-raised` / `--border-control-hover` live in
`semantic.css` (source of truth for roles; README points there)
- Input / Textarea / InputGroup / legacy TextArea use the field ladder
(incl. invalid hover)
- Select and empty MultiSelect use raised; filled MultiSelect sinks to
field
- Default + dashed Button, CommandMenu trigger, and radio
card/stacked/large use `border-control-hover`
- Studio selector tiles aligned: Connect mode, role impersonation,
DuckLake modes, compute “Contact us”

| Before and After |
| --- |
| <img width="1576" height="759" alt="Access Tokens Account Supabase"
src="https://github.com/user-attachments/assets/4bbe8b2b-a31a-4d63-80ba-04a1a8a5609d"
/> |
| <img width="1576" height="759" alt="Access Tokens Account Supabase"
src="https://github.com/user-attachments/assets/92271223-4103-4cc6-a7c0-9e4ff71cce30"
/> |

## Additional context

`--control-raised` aliases `--card` today (role name so fill can diverge
later). Rest `border-control` / `border-strong` both still map to
`--input` via compat; the shared interactive step is
`--border-control-hover`.

## To test

1. **[Account → Access
Tokens](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/account/tokens)**
Open Generate / New scoped token. Side-by-side Input, Select,
RadioGroupStacked, MultiSelect. Confirm sunk vs raised fills, shared
hover border, MultiSelect flips to sunk once a value is selected. Leave
a required field empty to check invalid rest → hover → focus.

2. **[Org →
Projects](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/org/_)**
Hover the dashed Status filter. Hover default / filled filter buttons
when active. Confirm hover/open borders match.

3. **[Project →
Connect](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/project/_)**
Open Connect from the header. Mode grid tiles: hover + selected borders
match radio cards (no old muted-foreground ring).

4. **[Project →
Compute](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/project/_/settings/infrastructure)**
(optional)
   Compute size radios + “Contact us” tile hover.
2026-08-18 16:54:25 +10:00

268 lines
13 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Core semantic color system.
*
* Themes override the small set of inputs below; every semantic color is then
* derived from them in OKLCH. The stepped numeric scales (brand / warning /
* destructive / secondary) are NOT derived here — they live as per-theme values
* in the theme files and are mapped to utilities in packages/config/css/theme.css.
*/
:root {
/*
* Hue inputs. A single --hue used to drive both the neutral surface/text ramp
* and the brand, so the two could never drift apart. They are now split so a
* theme can run, say, a cool-gray surface against the green brand:
* --surface-hue tints backgrounds, text and borders (the neutral ramp)
* --primary-hue is the brand hue that --primary is built from
* Both default to --hue (Supabase green ≈159° in OKLCH), so a theme that only
* sets --hue keeps surfaces and brand locked together as before; override
* either one to let them diverge. */
--hue: 159;
--surface-hue: var(--hue);
--primary-hue: var(--hue);
/* Root defaults mirror the dark theme (the app's default appearance). */
--chroma: 0.016;
--surface: 0.17;
--foreground-lightness: 0.95;
/* How far muted/tertiary text sits from --surface toward --foreground-lightness
(0 = surface, 1 = full foreground) at --contrast 0.5; themes override. */
--muted-foreground-level: 0.8;
--tertiary-foreground-level: 0.65;
/*
* Global contrast knob, 0 to 1. Default 0.5 is the comfortable baseline; 0 keeps
* borders and accent barely visible over muted surfaces; 1 pushes them to full
* strength. Foreground hits --foreground-lightness at 0.5; below that it eases
* toward the surface down to --contrast-text-floor at 0. Muted and tertiary
* foreground lightness sits at the theme base level (a surface→foreground mix) at
* 0.5 and eases up toward foreground or down toward half that base as contrast moves.
*
* --contrast-delta is -1 at 0, 0 at 0.5, +1 at 1 — shared offset from baseline;
* --contrast-up / --contrast-down are its positive / negative halves.
* --contrast-border ramps [floor, 1] over --contrast and squares it so most of
* the range lives toward full strength. ((delta + 1) / 2 simplifies to contrast.)
*/
--contrast: 0.5;
--contrast-delta: calc((var(--contrast) - 0.5) / 0.5);
/* positive and negative halves of the delta, reused below */
--contrast-up: max(0, var(--contrast-delta));
--contrast-down: max(0, calc(var(--contrast-delta) * -1));
--contrast-text-floor: 0.9;
--contrast-border-floor: 0.05;
--contrast-text: calc(1 - (1 - var(--contrast-text-floor)) * var(--contrast-down));
--contrast-border-linear: calc(
var(--contrast-border-floor) + (1 - var(--contrast-border-floor)) * var(--contrast)
);
--contrast-border: calc(var(--contrast-border-linear) * var(--contrast-border-linear));
/*
* Surface elevation. Each surface steps its lightness away from --background
* by `--elevation-step * ratio`, where the ratios are shared across themes and
* the step is signed: positive brightens as surfaces rise, negative darkens.
*
* The default step reproduces the historical behavior (≈3% per level mixed
* toward the foreground): dark themes brighten and the light baseline darkens.
* The light theme overrides --elevation-step (and lowers --surface for
* headroom) so its higher surfaces brighten toward white instead — giving a
* single "higher = lighter" model in both modes. 0 = flat (no elevation).
*/
--elevation-1: 1;
--elevation-2: 1.5;
--elevation-3: 2;
--elevation-4: 3;
/* Signed span surface→foreground (+ in dark, in light). */
--tone-span: calc(var(--foreground-lightness) - var(--surface));
--elevation-step: calc(var(--tone-span) * 0.04);
/*
* Foreground overlays for muted / accent / tertiary. Alpha is tuned so each level
* matches the lightness delta of the same elevation ratio on --background; a
* translucent --foreground layer then composites with whatever surface sits
* beneath (lightens in dark, darkens in light). Steps: muted (e1) → accent
* (e2) → tertiary (e3).
*/
/* max(span, -span) is equivalent to abs(span), but works across the browser
versions supported by Tailwind v4. */
--surface-overlay-unit: calc(
var(--elevation-step) / max(var(--tone-span), calc(var(--tone-span) * -1))
);
--muted-alpha: calc(var(--surface-overlay-unit) * var(--elevation-1));
--accent-alpha: calc(var(--surface-overlay-unit) * var(--elevation-2));
--tertiary-alpha: calc(var(--surface-overlay-unit) * var(--elevation-3));
/*
* Status hues stay anchored to amber / red but pull a fraction of the way
* toward the brand --primary-hue for harmony, then clamp to their perceptual
* category so "warning" always reads amber and "destructive" always reads red
* no matter what --primary-hue is. --status-hue-pull: 0 = fixed amber/red,
* 1 = fully brand-relative. At the default --primary-hue the pull term is 0, so
* these resolve to their bases (warning 75 / destructive 25 / info 288).
* --brand-hue-reference is the --primary-hue at which pull is zero; keep it
* equal to the default brand hue.
* Warning sits at 75 (amber), not higher toward yellow — yellow can't darken
* without going olive, so it loses contrast on light surfaces. Info is a violet
* (matching the "no additional charge" badge); like the others it pulls a touch
* with the brand --primary-hue but clamps so it always reads violet.
*/
--brand-hue-reference: 159;
--status-hue-pull: 0.15;
--warning-hue: clamp(
65,
calc(75 + (var(--primary-hue) - var(--brand-hue-reference)) * var(--status-hue-pull)),
95
);
--destructive-hue: clamp(
12,
calc(25 + (var(--primary-hue) - var(--brand-hue-reference)) * var(--status-hue-pull)),
42
);
--info-hue: clamp(
272,
calc(288 + (var(--primary-hue) - var(--brand-hue-reference)) * var(--status-hue-pull)),
302
);
/*
* Expressive (status) colors are deliberately NOT derived from the neutral
* surface→foreground span the way text and surfaces are. An expressive color
* has its own identity; tying its lightness to the theme's text ramp is what
* left warning under-saturated in light mode and too dark in dark mode.
*
* Instead each is anchored to a fixed target lightness near its hue's chroma
* peak and a single shared --expressive-chroma. That chroma is a flat constant,
* deliberately NOT derived from the neutral --chroma knob: status colors must
* stay legible even when the brand is dialed toward grayscale. It sits inside
* sRGB at these lightnesses, so no gamut mapping is required.
*
* The dark-mode lightness anchors live here; light mode runs darker (set in
* light.css) so the fills hold contrast against the near-white surface.
*/
--expressive-chroma: 0.14;
--warning-lightness: 0.8;
--destructive-lightness: 0.75;
--info-lightness: 0.7;
/* shadcn semantic pairs */
--background: oklch(var(--surface) calc(var(--chroma) * 0.5) var(--surface-hue));
--foreground-chroma: calc(var(--chroma) * 0.55);
--foreground: oklch(
calc(var(--surface) + var(--tone-span) * var(--contrast-text)) var(--foreground-chroma)
var(--surface-hue)
);
/* Inverse of --foreground: canvas-coloured text for use on inverted fills
(bg-foreground, bg-black / dark:bg-white, etc). Not the same as
--primary-foreground, which stays dark for the vivid primary fill. */
--foreground-contrast: oklch(var(--surface) calc(var(--chroma) * 0.55) var(--surface-hue));
--card: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-1)) c h);
--card-foreground: var(--foreground);
--popover: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-2)) c h);
--popover-foreground: var(--foreground);
/* Primary is the brand-derived accent: a fixed, vivid Supabase green on
--primary-hue, mode-invariant and decoupled from the --chroma knob so it
holds its identity even as neutrals desaturate. NOTE: the `brand` utility
(--color-brand in theme.css) is deliberately NOT this — it maps to the
per-theme --brand-default scale (master behavior) while we resolve how
`brand` is used across the interface. */
--primary: oklch(0.76 0.15 var(--primary-hue));
--primary-foreground: oklch(
min(var(--surface), var(--foreground-lightness)) calc(var(--chroma) * 0.45) var(--primary-hue)
);
--secondary: oklch(
from var(--background) calc(l + var(--elevation-step) * var(--elevation-3)) c h
);
--secondary-foreground: var(--foreground);
--tertiary: oklch(from var(--foreground) l c h / var(--tertiary-alpha));
--tertiary-foreground-mix: calc(
var(--tertiary-foreground-level) + (1 - var(--tertiary-foreground-level)) * var(--contrast-up) -
var(--tertiary-foreground-level) * 0.5 * var(--contrast-down)
);
--tertiary-foreground: oklch(
calc(var(--surface) + var(--tone-span) * var(--tertiary-foreground-mix))
var(--foreground-chroma) var(--surface-hue)
);
--muted: oklch(from var(--foreground) l c h / var(--muted-alpha));
--muted-foreground-mix: calc(
var(--muted-foreground-level) + (1 - var(--muted-foreground-level)) * var(--contrast-up) -
var(--muted-foreground-level) * 0.5 * var(--contrast-down)
);
--muted-foreground: oklch(
calc(var(--surface) + var(--tone-span) * var(--muted-foreground-mix)) var(--foreground-chroma)
var(--surface-hue)
);
--accent: oklch(from var(--foreground) l c h / var(--accent-alpha));
--accent-foreground: var(--foreground);
/*
* Control surface roles (design sync: sunk fields + raised choosers):
* --field sunk text entry (Input, Textarea, filled MultiSelect)
* --control-raised raised chooser plate (Select, empty MultiSelect)
* --control alias of --control-raised
*
* Alpha overlays relative to the local plate (page, sheet, card) — not solids
* from --background. Page-relative solids sit between canvas (e0) and Sheet
* (popover e2) and vanish on light near the white ceiling.
*
* Field soft always-sink via black. --field-alpha is a theme input (stronger in
* dark.css) because the same absolute opacity is obvious on light plates and
* near-invisible on dark ones. Default is softer than the old foreground/[.026]
* wash. Raised is a soft white lift so selects read button-like without stark
* wells. Borders carry most hover chrome (--border-control-hover). Light raises
* still have little headroom on sheets; sheets matching app background would
* widen that later.
*
* Default Button stays opaque card/popover for occluding chrome (DEPR-636).
* Floating clusters can wrap in a solid Popover as a follow-up.
*/
--field-alpha: 0.015;
--field: oklch(0 0 0 / var(--field-alpha));
--control-raised: oklch(1 0 0 / calc(var(--muted-alpha) * 0.65));
--control: var(--control-raised);
--warning: oklch(var(--warning-lightness) var(--expressive-chroma) var(--warning-hue));
--destructive: oklch(
var(--destructive-lightness) var(--expressive-chroma) var(--destructive-hue)
);
/*
* Text that sits on the solid fill: dark ink on light fills (amber), white on
* dark fills (red). The clamp turns the fill's own lightness into a hard flip
* around L≈0.62 — (0.62 l) is negative for light fills (→ floor 0.12) and
* positive for dark fills (→ ceil 0.99) — so on-colors stay legible and keep
* flipping correctly if the anchors are retuned. A trace of hue chroma keeps
* the ink from reading as flat gray.
*/
--warning-foreground: oklch(
from var(--warning) clamp(0.12, calc((0.62 - l) * 100), 0.99) calc(c * 0.08) h
);
--destructive-foreground: oklch(
from var(--destructive) clamp(0.12, calc((0.62 - l) * 100), 0.99) calc(c * 0.08) h
);
--info: oklch(var(--info-lightness) var(--expressive-chroma) var(--info-hue));
--info-foreground: oklch(
from var(--info) clamp(0.12, calc((0.62 - l) * 100), 0.99) calc(c * 0.08) h
);
/* Status border tokens for subtle / tinted surfaces */
--border-destructive: oklch(from var(--destructive) l c h / 30%);
--border-warning: oklch(from var(--warning) l c h / 30%);
--border-info: oklch(from var(--info) l c h / 30%);
--border-brand: oklch(from var(--primary) l c h / 30%);
--border: oklch(
from var(--foreground) l calc(c * 0.54) h / calc(2% + 20% * var(--contrast-border))
);
--input: oklch(
from var(--foreground) l calc(c * 0.5) h / calc(3% + 38% * var(--contrast-border))
);
/*
* Shared interactive border for control chrome (inputs, selects, default /
* dashed Button, radio cards). Pairs with rest --border-control / --border-strong.
* Stronger than --border-stronger; softer than a full muted-foreground stroke.
*/
--border-control-hover: oklch(
from var(--foreground) l calc(c * 0.5) h / calc(12% + 58% * var(--contrast-border))
);
--ring: oklch(from var(--primary) l c h / 55%);
}