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
+6 -16
View File
@@ -21,17 +21,9 @@ import {
Youtube,
} from "lucide-react";
import { Btn } from "./batchA";
import { hexContrast, hueGap } from "./labColor";
import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
import {
FADE_ABOVE_PANEL,
FADE_BELOW,
FADE_BELOW_PANEL,
FADE_EDGES_X,
FILLED,
H,
LAYER,
} from "./labTokens";
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
@@ -57,7 +49,7 @@ type SchemeDef = { name: string; dark: Pal; light: Pal };
const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", string>> = {
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
};
const pal = (
@@ -70,7 +62,8 @@ const pal = (
accentFg: string,
): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
// Backgrounds and text are today's (index.css); only the accents are new.
// A literal copy of index.css's scheme tokens, so the four tiles render side by side (the live CSS
// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it.
const SCHEMES: SchemeDef[] = [
{
name: "Midnight · blue",
@@ -458,10 +451,7 @@ function Fades() {
</div>
</Sample>
<Sample label="4 · An overflowing chip row">
<div
className="overflow-x-auto no-scrollbar"
style={{ maskImage: FADE_EDGES_X, WebkitMaskImage: FADE_EDGES_X }}
>
<div className="overflow-x-auto no-scrollbar fade-edges-x">
<div className="flex w-max gap-1.5 px-6">
{CHIPS.map((c) => (
<span key={c} className="chip px-2.5 py-1 text-xs">
-33
View File
@@ -1,33 +0,0 @@
// ── Fixed-palette maths ──────────────────────────────────────────────────────────────────────────
// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on
// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far
// apart, unlike HSL's.
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
export function hexContrast(a: string, b: string): number {
const l = (h: string) => {
const [r = 0, g = 0, bl = 0] = rgb(h).map(lin2);
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
};
const x = l(a);
const y = l(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
function hue(hex: string): number {
const [r = 0, g = 0, b = 0] = rgb(hex).map(lin2);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
}
/** The OKLCH hue angle between two colours, 0..180°. */
export function hueGap(a: string, b: string): number {
const d = Math.abs(hue(a) - hue(b)) % 360;
return Math.min(d, 360 - d);
}
+13 -22
View File
@@ -1,5 +1,6 @@
// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are
// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go.
// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). The colour
// rules are real now (E28 S3a): each constant below names the index.css utility or token that holds
// it, so the lab renders what the app ships. The height ladder moves into ui/Button in S3b.
/** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */
export const H = {
@@ -12,25 +13,15 @@ export const H = {
bar: "h-7",
} as const;
/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */
export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */
export const STRONG = "var(--edge-strong)";
/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath;
* a filled control mixes its own text colour into the fill at 14% / 24%. */
export const LAYER =
"hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]";
export const FILLED =
"bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]";
export const DANGER =
"bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]";
/** B3 state layers (index.css utilities). */
export const LAYER = "state-layer";
export const FILLED = "state-filled";
export const DANGER = "state-danger";
/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal
* strings per colour: Tailwind only generates classes it can read whole. */
export const FADE_BELOW =
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
export const FADE_BELOW_PANEL =
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
export const FADE_ABOVE_PANEL =
"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
export const FADE_EDGES_X =
"linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
/** B9: the header + fade edge (index.css utilities; the host is positioned). */
export const FADE_BELOW = "fade-below";
export const FADE_BELOW_PANEL = "fade-below-panel";
export const FADE_ABOVE_PANEL = "fade-above-panel";