mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Problem Primary colour serves readable text and selected controls, but those uses need different shades. Light mode needs darker text, while dark mode needs a deeper button fill. Fixed brand green on interactive chrome also prevents a custom primary hue from carrying through the interface. Some slider tracks and selected text are hard to read. ## Solution - Keep `--primary` for accessible text and small selected indicators. Use `--primary-solid` for button fills, which need a deeper shade in dark mode. - Add `--primary-bright` for focus rings, selected control chrome, chart accents, and other interactive highlights. It follows `--primary-hue`; `brand-*` stays fixed for Supabase identity. - Make slider troughs clearer and text selection translucent with theme foreground text. - Document the split in the design-system colour guide. | Before | After | | --- | --- | | <img width="980" height="244" alt="Before: light mode primary controls" src="https://github.com/user-attachments/assets/dfae325d-0dfe-4231-8bcd-3f89c4b9d793" /> | <img width="982" height="204" alt="After: light mode primary controls" src="https://github.com/user-attachments/assets/5fdcb531-a6e3-4549-8a13-9d9a5ebe6e20" /> | | <img width="610" height="120" alt="Before: slider track" src="https://github.com/user-attachments/assets/04f768e0-51e8-4d06-9b97-c52f4a34f122" /> | <img width="622" height="126" alt="After: slider track" src="https://github.com/user-attachments/assets/95127f4e-13dc-4f0f-b63c-cf5d70a28b42" /> | | <img width="652" height="512" alt="Before: dark mode controls" src="https://github.com/user-attachments/assets/3f88de66-90cc-40ee-8cf1-b5f4eb87b09a" /> | <img width="658" height="498" alt="After: dark mode controls" src="https://github.com/user-attachments/assets/906bec30-6ca1-4614-9fb3-6cf5e5feec22" /> | ## Review instructions 1. Compare light and dark mode in the [colour usage guide](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/color-usage#primary-and-brand-colors). Check primary ink, primary-solid, primary-bright, and fixed brand swatches. 2. In Studio, open the ‘new table’ sheet in [Table Editor](https://studio-staging-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/dashboard/project/_/editor). Tab through the new table sheet's fields and toggles. Check the focus rings, selected controls, and the sheet's edges in both themes. You do not need to save a table. 3. Select text in Studio in both themes, including a link or primary-coloured label. The selection and text should remain legible. 4. Check the [Field](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/field) Price Range slider: the unused track should remain visible in both themes. The selected field card border should follow primary-bright. 5. Check the [Button](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/button) and [Radio Group](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/radio-group) previews. In dark mode, `primary` button fill should be deeper than primary [text](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/color-usage#text); selected radios should remain readable.
325 lines
15 KiB
CSS
325 lines
15 KiB
CSS
/*
|
||
* 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 spot hue that functional primary colors use
|
||
* Both default to --hue (Supabase green ≈157.5° 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: 157.5;
|
||
--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: 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%);
|
||
}
|