Files
Danny White a31ca2bad0 fix(ui): make brand text readable across themes (#49871)
## What kind of change does this PR introduce?

Bug fix and design-system update. Resolves DEPR-316.

Follow-up rename (`text-brand` → `text-primary`) is in a dedicated PR
(https://github.com/supabase/supabase/pull/50564) stacked on this one.

## What is the current behavior?

`text-brand` resolves to the canonical bright brand green in places that
need readable text, which fails WCAG AA on light surfaces. A separate
`text-brand-display` token adds another green for large type.

## What is the new behavior?

- `text-brand` maps to accessible `--primary` (light mode darkened to
meet ~4.5:1 AA)
- `--hue` / `--primary-hue` aligned to 157.5
- `text-brand-display` removed; former display callsites use
`text-brand`
- Bright fills/borders stay on `brand-default`
- Design-system colour and typography docs updated

| Before | After |
| --- | --- |
| <img width="514" height="112" alt="CleanShot 2026-09-02 at 11 13
09@2x"
src="https://github.com/user-attachments/assets/4e0138a9-a32d-4e4c-a426-90736706e1e7"
/> | <img width="512" height="138" alt="CleanShot 2026-09-21 at 11 42
05@2x"
src="https://github.com/user-attachments/assets/164cc5b1-a0c5-4e93-95f1-80016641a114"
/> |
| <img width="864" height="266" alt="CleanShot 2026-09-02 at 11 13
53@2x"
src="https://github.com/user-attachments/assets/3c1ca53f-bf9e-431e-bc15-816b4a275b8e"
/> | <img width="882" height="248" alt="CleanShot 2026-09-21 at 11 41
37@2x"
src="https://github.com/user-attachments/assets/24828e7b-ed6b-44cb-b9dc-becc3398bdfc"
/> |
| <img width="782" height="692" alt="CleanShot 2026-09-02 at 11 16
30@2x"
src="https://github.com/user-attachments/assets/fc871977-77bc-47fb-9e0e-9284e0ecd5cc"
/> | <img width="730" height="690" alt="CleanShot 2026-09-21 at 11 42
52@2x"
src="https://github.com/user-attachments/assets/bf479515-d5f9-471e-b82d-f097c0f4b56c"
/> |
| <img width="480" height="306" alt="CleanShot 2026-09-02 at 11 18
53@2x"
src="https://github.com/user-attachments/assets/03f341f4-f02e-44f8-a2b2-8c31670d0427"
/> | <img width="470" height="300" alt="CleanShot 2026-09-21 at 11 43
19@2x"
src="https://github.com/user-attachments/assets/9df18217-d5e6-48b8-ba0b-579d2664b94b"
/> |
| <img width="960" height="300" alt="CleanShot 2026-09-02 at 11 32
04@2x"
src="https://github.com/user-attachments/assets/6b1d9373-7a71-4247-81ff-26441604b09d"
/> | <img width="980" height="306" alt="CleanShot 2026-09-21 at 11 44
13@2x"
src="https://github.com/user-attachments/assets/41ad4784-02ec-4b29-b860-32af9fa79aa8"
/> |
| <img width="924" height="214" alt="CleanShot 2026-09-02 at 11 34
44@2x"
src="https://github.com/user-attachments/assets/1de661fe-c7b6-499b-a94f-e4737436ec79"
/> | <img width="752" height="162" alt="CleanShot 2026-09-21 at 11 44
56@2x"
src="https://github.com/user-attachments/assets/1811890f-0660-4445-84e9-447720954fa1"
/> |

## To test

Test each callsite **in light mode** (dark mode is largely unchanged).

### WWW

-
[Homepage](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/):
“Scale to millions” uses readable brand text (display token is gone)
-
[Careers](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/careers):
small “Careers” eyebrow readable; green dividers stay bright
`brand-default`
-
[Contact](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/contact-us):
email / policy links use readable brand text
-
[Regions](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/regions):
“Ask about early access to BYOC” readable

### Docs

- [Docs
homepage](https://docs-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs):
“DOCS” wordmark and resource links readable
- [Database
overview](https://docs-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/guides/database/overview):
nav / footer brand links readable
- [JavaScript
reference](https://docs-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/reference/javascript/introduction):
active sidebar treatment readable

### Design system

-
[Typography](https://design-system-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/typography):
documents `text-brand` only (no display)
- [Colour
usage](https://design-system-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/color-usage):
`text-brand` vs `bg-brand-default`
- [Design-system
homepage](https://design-system-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/):
brand text examples across themes

### Studio

- [Auth
providers](https://studio-staging-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/dashboard/project/_/auth/providers):
enabled provider badge text readable; status dot stays bright
- [Database
policies](https://studio-staging-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/dashboard/project/_/database/policies?new=true):
template hover text more legible
- [Database
connections](https://studio-staging-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/dashboard/project/_/observability/connections):
“Live” status readable; animated dot stays bright green

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-22 10:07:28 +10:00

264 lines
12 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 ≈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.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 accessible functional green (text, controls). Bright
expressive fills use brand-default. Light theme overrides --primary for AA text. */
--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-default: 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%);
}