fix(theme): text on glass holds 4.5:1 — light --muted kept at 6:1 on the page, tonal fills opaque

Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8%
fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the
current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item
count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract
now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
This commit is contained in:
2026-10-17 20:06:54 +02:00
parent d74f2a9ca1
commit 3ada34073b
6 changed files with 58 additions and 20 deletions
+32 -8
View File
@@ -4,8 +4,8 @@ import { hexContrast, hueGap, mixHex } from "./colorMath";
import { SCHEMES } from "./theme";
// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
// an accent fill [WCAG 1.4.3];
// - body text and accent text ≥ 4.5:1 on the page, muted text ≥ 6:1 there (the headroom glass costs),
// and the accent's own text ≥ 4.5:1 on an accent fill [WCAG 1.4.3];
// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
@@ -177,6 +177,22 @@ it("text on a solid accent fill uses --accent-fg", () => {
expect(offenders).toEqual([]);
});
// The tints measured below are only what renders while the tonal fills USE them: a translucent
// `bg-fg/8` there passes every token check and still lets the glass under a toolbar or a rail through.
it("a tonal fill under text is an opaque tint", () => {
const sites = [
"components/ui/Badge.tsx",
"components/ui/ItemCount.tsx",
"components/FilesRail.tsx",
];
const offenders = sites.flatMap((f) => {
const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
const bad = [...src.matchAll(/\bbg-(?:fg|accent|warn)\/\d+/g)].map((m) => `${f}: ${m[0]}`);
return /\bbg-tint/.test(src) ? bad : [...bad, `${f}: no bg-tint`];
});
expect(offenders).toEqual([]);
});
describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
it.each(MODES)("%s: backdrop rule and file", (mode) => {
const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
@@ -200,15 +216,23 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
rating: hexContrast(s.rating!, bg),
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
// A Badge (a role, a status, a count, F29): the neutral one's muted text on the fg at 8%, a toned
// one's text on its own colour at 6% (ui/Badge).
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, bg, 0.08)),
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, bg, 0.06)),
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, bg, 0.06)),
// A tonal fill under text (the `tint` tokens: a Badge, the item count, the current rail row) is
// OPAQUE — its colour mixed into --surface, so the glass and what scrolls under it never show
// through: the neutral one's muted text on the fg at 8%, a toned one's text on its own colour at
// 6%, the current row's accent on the accent at 12%.
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, t.surface!, 0.08)),
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.06)),
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, t.surface!, 0.06)),
accentOnCurrent: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.12)),
live: hexContrast(media().live!, bg),
};
const nonText = new Set(["edge", "live"]);
const failing = Object.entries(measured).filter(([k, v]) => v < (nonText.has(k) ? 3 : 4.5));
// Muted text mostly sits on GLASS (a toolbar, a tab row, a panel): the scrim, the blur and the
// backdrop under it cost up to ~1.5 of the ratio a bare --bg gives (pixel-measured over every
// scheme × mode × backdrop), so on the bare page it keeps that headroom over 4.5.
const onGlass = new Set(["muted"]);
const need = (k: string) => (nonText.has(k) ? 3 : onGlass.has(k) ? 6 : 4.5);
const failing = Object.entries(measured).filter(([k, v]) => v < need(k));
expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
});