feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)

Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
This commit is contained in:
2026-09-24 04:33:13 +02:00
parent 9c4a999e54
commit ad136c3fe8
26 changed files with 326 additions and 851 deletions
+110 -95
View File
@@ -46,7 +46,6 @@
--z-index-overlay: 50;
--z-index-popover: 60;
--z-index-tooltip: 100;
--z-index-tuner: 9999;
}
/* Extra-small text steps in rem, so these labels scale with --font-scale like every other
@@ -62,6 +61,90 @@
--text-3xs: 0.625rem; /* 10px @ base */
}
/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
lab both read, so a decided value is never restated as an arbitrary class. ===== */
/* B3: a transparent control's hover / pressed layer. */
@utility state-layer {
&:hover {
background-color: var(--hover);
}
&:active {
background-color: var(--press);
}
}
/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */
@utility state-filled {
background-color: var(--accent);
color: var(--accent-fg);
&:hover {
background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent));
}
&:active {
background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent));
}
}
@utility state-danger {
background-color: var(--danger);
color: var(--danger-fg);
&:hover {
background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger));
}
&:active {
background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
}
}
/* A4: a control's ≥ 3:1 edge. */
@utility edge-strong {
border-color: var(--edge-strong);
}
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
@utility fade-below {
&::after {
content: "";
pointer-events: none;
position: absolute;
inset-inline: 0;
top: 100%;
height: 1rem;
background: linear-gradient(to bottom, var(--bg), transparent);
}
}
@utility fade-below-panel {
&::after {
content: "";
pointer-events: none;
position: absolute;
inset-inline: 0;
top: 100%;
height: 1rem;
background: linear-gradient(to bottom, var(--surface), transparent);
}
}
@utility fade-above-panel {
&::before {
content: "";
pointer-events: none;
position: absolute;
inset-inline: 0;
bottom: 100%;
height: 1rem;
background: linear-gradient(to top, var(--surface), transparent);
}
}
/* B9: the clipped ends of an overflowing chip row. */
@utility fade-edges-x {
mask-image: linear-gradient(
to right,
transparent,
#000 1.75rem,
#000 calc(100% - 1.75rem),
transparent
);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -225,22 +308,6 @@ html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body {
),
url("/backdrops/slate.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/youtube.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/starship.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
background-image:
linear-gradient(
@@ -273,22 +340,6 @@ html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body {
),
url("/backdrops/light/slate.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/youtube.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/starship.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
background-image:
linear-gradient(
@@ -320,11 +371,17 @@ html {
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
font-size: round(calc(16px * var(--font-scale)), 1px);
background: var(--bg);
/* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
hoverable row (the .selectable recipe + DataTable rows). */
--hover: color-mix(in srgb, var(--fg) 9%, transparent);
/* State layers (docs/DESIGN.md B3): hover and pressed are the text colour laid over whatever the
control sits on — the page, a card or glass — so they read in every scheme and theme (it lightens
on dark, darkens on light). Never an opacity dim or a bg-card swap (invisible on a light card).
Applied through the state-layer / state-filled / state-danger utilities below. */
--hover: color-mix(in srgb, var(--fg) 10%, transparent);
--press: color-mix(in srgb, var(--fg) 16%, transparent);
/* A control's edge (DESIGN.md A4): ≥ 3:1 against the page in every scheme and mode, where --border
(a decorative divider) is not. theme.contract.test.ts holds the ratio. */
--edge-strong: color-mix(in srgb, var(--fg) 55%, var(--bg));
/* The coarse-pointer target (DESIGN.md A3): one token, read by .touch-target and the primitives. */
--touch-target: 2.75rem;
}
body {
@@ -598,8 +655,8 @@ body {
would be the one control that ignores the user's text-size setting. */
@media (pointer: coarse) {
.touch-target {
min-width: 2.75rem;
min-height: 2.75rem;
min-width: var(--touch-target);
min-height: var(--touch-target);
}
}
@@ -858,7 +915,7 @@ html[data-scheme="midnight"][data-theme="light"] {
--accent-fg: #ffffff;
}
/* Forest — dark slate with teal/green accent */
/* Forest — dark green-slate with a cyan accent (E28 B2: teal read as success) */
html[data-scheme="forest"][data-theme="dark"] {
--bg: #0a1512;
--surface: #0f1f1a;
@@ -866,7 +923,7 @@ html[data-scheme="forest"][data-theme="dark"] {
--border: #1f3a30;
--fg: #e6f0ec;
--muted: #90b1a4;
--accent: #2dd4bf;
--accent: #22d3ee;
--accent-fg: #04110d;
}
html[data-scheme="forest"][data-theme="light"] {
@@ -876,11 +933,11 @@ html[data-scheme="forest"][data-theme="light"] {
--border: #d6e7e0;
--fg: #102a22;
--muted: #4d6b60;
--accent: #0d9488;
--accent: #0e7490;
--accent-fg: #ffffff;
}
/* Slate — neutral grey with warm orange accent (muted in dark) */
/* Slate — neutral grey with a fuchsia accent (E28 B2: orange read as warn) */
html[data-scheme="slate"][data-theme="dark"] {
--bg: #15171c;
--surface: #1b1e25;
@@ -888,8 +945,8 @@ html[data-scheme="slate"][data-theme="dark"] {
--border: #2c313b;
--fg: #e7e9ee;
--muted: #9aa1ad;
--accent: #e8913c;
--accent-fg: #1a1206;
--accent: #e879f9;
--accent-fg: #1a0620;
}
html[data-scheme="slate"][data-theme="light"] {
--bg: #f6f7f9;
@@ -898,55 +955,11 @@ html[data-scheme="slate"][data-theme="light"] {
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #5d636e;
--accent: #d97706;
--accent: #a21caf;
--accent-fg: #ffffff;
}
/* YouTube — near-black with red accent */
html[data-scheme="youtube"][data-theme="dark"] {
--bg: #0f0f0f;
--surface: #181818;
--card: #1f1f1f;
--border: #303030;
--fg: #f1f1f1;
--muted: #aaaaaa;
--accent: #ff3b46;
--accent-fg: #ffffff;
}
html[data-scheme="youtube"][data-theme="light"] {
--bg: #ffffff;
--surface: #f9f9f9;
--card: #ffffff;
--border: #e5e5e5;
--fg: #0f0f0f;
--muted: #606060;
--accent: #ff0033;
--accent-fg: #ffffff;
}
/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
html[data-scheme="starship"][data-theme="dark"] {
--bg: #050b16;
--surface: #0b1728;
--card: #101f36;
--border: #1f3557;
--fg: #dfeaff;
--muted: #8aa6cc;
--accent: #38b6ff;
--accent-fg: #04101f;
}
html[data-scheme="starship"][data-theme="light"] {
--bg: #eef4fb;
--surface: #ffffff;
--card: #ffffff;
--border: #d3e0f0;
--fg: #0b1b30;
--muted: #4a5f7d;
--accent: #0b6bd6;
--accent-fg: #ffffff;
}
/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #030a06;
--surface: #08130d;
@@ -954,7 +967,7 @@ html[data-scheme="matrix"][data-theme="dark"] {
--border: #163d27;
--fg: #cfe8d6;
--muted: #6fae86;
--accent: #46b87c;
--accent: #a3e635;
--accent-fg: #04170c;
}
html[data-scheme="matrix"][data-theme="light"] {
@@ -964,7 +977,7 @@ html[data-scheme="matrix"][data-theme="light"] {
--border: #cfe6d6;
--fg: #08130b;
--muted: #4c6b56;
--accent: #12a150;
--accent: #4d7c0f;
--accent-fg: #ffffff;
}
@@ -986,7 +999,9 @@ html[data-theme="dark"] {
--info-fg: #08172e;
}
html[data-theme="light"] {
--danger: #dc2626;
/* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted
light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
--danger: #b91c1c;
--danger-fg: #ffffff;
--warn: #b45309;
--warn-fg: #ffffff;