Files
Danny White 0cb8bd95dd feat(studio): add spot colour control to Appearance (#50782)
## Problem

The theme's primary hue can change in CSS, but Appearance had no way to
try other spot colours. That makes it hard to find controls whose colour
still depends on the fixed Supabase brand palette.

## Solution

Add a **Spot color** control under Appearance → Theme colors
(employee-only via ConfigCat `appearanceSpotColor`, targeted to Supabase
Team Email).

### Spot color UX
- Rainbow spectrum track with a thin outline so pale tracks stay visible
- Live trifecta swatches for `--primary-solid`, `--primary`, and
`--primary-bright` (darkest → lightest) next to the degree readout
- Drag updates are rAF-batched so React paint and CSS preview stay to
one frame

### Canvas tint coupling
- `--surface-hue` is derived in CSS as `calc(var(--primary-hue) +
var(--surface-hue-offset))`
- Dark: offset `0` (same hue as spot)
- Light: offset `180` (complementary canvas tint; brand green ≈157.5° →
rose ≈337.5°)
- No JS override of `--surface-hue`. Changing Spot color moves primary
controls and the low-chroma canvas tint together

### Other theme sliders
- Renamed **Color intensity** → **Surface tint** (it only drives the
neutral ramp via `--chroma`, not spot chroma)
- Meaning-shaped tracks for every knob (spectrum, grey→tint, soft→hard,
dark→light, flat→lift)
- Same outline treatment on those tracks

| Before | After |
| --- | --- |
| <img width="1476" height="2174" alt="CleanShot 2026-10-05 at 15 02
21@2x"
src="https://github.com/user-attachments/assets/d08b0fa8-32af-450e-adce-861f59c9d6ca"
/> | <img width="1474" height="2354" alt="CleanShot 2026-10-05 at 14 56
35@2x"
src="https://github.com/user-attachments/assets/9a1e6183-a4ba-4c8e-a458-bb0eea946db8"
/> |
| _Anyone else_ | _With staff flag, custom settings_ |

## Review instructions

1. Confirm ConfigCat flag `appearanceSpotColor` is on for your staff
account (or flip it in the Dev Toolbar).
2. Open `/account/me` → **Appearance → Theme colors**.
3. Without the flag: Spot color is hidden; other theme sliders still
work.
4. With the flag: drag Spot color in light and dark. Primary controls
and canvas tint should move together; Supabase brand assets should stay
fixed.
5. Raise Surface tint and confirm the canvas hue follows the
complementary (light) or same-hue (dark) offset.
6. Refresh, switch modes, and use **Reset** to check persistence and
defaults.
2026-10-06 10:11:26 +11:00

325 lines
15 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. --primary-hue is the spot colour; --surface-hue tints the
* neutral ramp (backgrounds, text, borders). Surface is derived from primary
* plus a theme offset so canvases stay coupled when spot changes:
* dark offset 0 → same hue as spot (subtle brand tint)
* light offset 180 → complementary canvas tint (brand green → rose)
* Themes can still set --surface-hue directly to break the link.
*/
--hue: 157.5;
--primary-hue: var(--hue);
--surface-hue-offset: 0;
--surface-hue: calc(var(--primary-hue) + var(--surface-hue-offset));
/* 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: 157.5;
--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.55;
--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-solid-foreground, which stays light on the deep button plate. */
--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 ink vs primary solid fill:
* --primary Readable branded text / small indicators. Root
* values mirror dark theme (bright). Light theme
* overrides for AA text on light surfaces.
* --primary-solid Deeper button plate (dark mode). Light theme
* aliases these to --primary so ink and buttons
* share one green there.
* --primary-bright keeps rings and selected chrome vivid while sharing the
* hue of --primary. It is brighter than readable ink in light mode.
*
* Hover shifts lightness only (preserves hue/chroma). Mixing with --foreground
* pulls light-mode surface-hue (warm) into the green and reads as yellow.
* Sign of --tone-span: darken in light, lighten in dark.
*/
--primary: oklch(0.76 0.15 var(--primary-hue));
--primary-bright: oklch(from var(--primary) max(l, 0.7) max(c, 0.15) h);
--button-fill-hover-delta: 0.04;
--primary-hover: oklch(
from var(--primary)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
--primary-bright-hover: oklch(
from var(--primary-bright)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
--primary-foreground: oklch(
min(var(--surface), var(--foreground-lightness)) calc(var(--chroma) * 0.45) var(--primary-hue)
);
--primary-solid: oklch(0.43627 0.11 var(--primary-hue));
--primary-solid-hover: oklch(
from var(--primary-solid)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
--primary-solid-foreground: oklch(0.95 0.00275 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));
--warning-hover: oklch(
from var(--warning)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
--destructive: oklch(
var(--destructive-lightness) var(--expressive-chroma) var(--destructive-hue)
);
--destructive-hover: oklch(
from var(--destructive)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
/*
* 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%);
/* Keep the brand border for explicitly Supabase-branded surfaces. */
--border-brand-default: oklch(from hsl(var(--brand-default)) l c h / 30%);
--border-primary-bright: oklch(from var(--primary-bright) 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))
);
/* Focus chrome follows the theme's primary hue without muddy light-mode ink. */
--ring: oklch(from var(--primary-bright) l c h / 55%);
}