feat(design): Matrix toned down to moss, text on an accent fill held to --accent-fg

The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the
moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed
white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use
--accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent.
This commit is contained in:
2026-09-24 04:49:07 +02:00
parent ad136c3fe8
commit 7d480e2ad0
14 changed files with 59 additions and 39 deletions
+2 -1
View File
@@ -70,7 +70,7 @@ style guide.
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (lime). Each earns its
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4).
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
@@ -302,3 +302,4 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
+5 -5
View File
@@ -1,13 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#cfe8a0" stop-opacity=".9"/><stop offset="100%" stop-color="#cfe8a0" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#a3e635" stop-opacity=".45"/><stop offset="100%" stop-color="#a3e635" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b8dca8" stop-opacity=".85"/><stop offset="100%" stop-color="#b8dca8" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#96d69c" stop-opacity=".8"/><stop offset="100%" stop-color="#96d69c" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#d9e2c4" stop-opacity=".9"/><stop offset="100%" stop-color="#d9e2c4" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#cdd6bf" stop-opacity=".85"/><stop offset="100%" stop-color="#cdd6bf" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c3cfb3" stop-opacity=".8"/><stop offset="100%" stop-color="#c3cfb3" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#eaf5ec"/>
<rect width="1600" height="1000" fill="#eef0e8"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+5 -5
View File
@@ -1,13 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#3a5a0f" stop-opacity=".95"/><stop offset="100%" stop-color="#3a5a0f" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#a3e635" stop-opacity=".45"/><stop offset="100%" stop-color="#a3e635" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1a3a14" stop-opacity=".9"/><stop offset="100%" stop-color="#1a3a14" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#34431f" stop-opacity=".95"/><stop offset="100%" stop-color="#34431f" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1d2a18" stop-opacity=".9"/><stop offset="100%" stop-color="#1d2a18" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#22301c" stop-opacity=".85"/><stop offset="100%" stop-color="#22301c" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#020805"/>
<rect width="1600" height="1000" fill="#0a0c09"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -594,7 +594,7 @@ export default function PlexInfo({
{onPlay && (
<button
onClick={onPlay}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
{inProgress ? t("plex.resume") : t("plex.play")}
+1 -1
View File
@@ -569,7 +569,7 @@ export default function PlexPlaylistView({
{items.length > 0 && (
<button
onClick={() => onPlay(order[0]!, order)}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
>
<Play className="h-5 w-5 fill-current" />
{t("plex.playlist.playAll")}
+1 -1
View File
@@ -86,7 +86,7 @@ export function UpNextCard({
<div className="mt-auto flex items-center gap-2 pt-2.5">
<button
onClick={onAdvance}
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg hover:brightness-110"
>
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
+1 -1
View File
@@ -140,7 +140,7 @@ export function PlexPosterCard({
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-x-0 top-[30%] flex justify-center">
<div
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-white shadow-xl transition-transform duration-200 group-hover:scale-100"
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
style={{
background:
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
+1 -1
View File
@@ -144,7 +144,7 @@ export function PrefToggle({
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
className={`absolute top-0.5 h-3 w-3 rounded-full transition-all ${on ? "left-3.5 bg-accent-fg" : "left-0.5 bg-white"}`}
/>
</span>
</button>
+2 -2
View File
@@ -43,8 +43,8 @@ export function Switch({
className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-4.5" : "left-0.5"
className={`absolute top-0.5 w-4 h-4 rounded-full shadow transition-all ${
checked ? "left-4.5 bg-accent-fg" : "left-0.5 bg-white"
}`}
/>
</FocusSafeButton>
+2 -2
View File
@@ -104,7 +104,7 @@ function TouchRow() {
<div className="flex min-h-11 items-center justify-between rounded-lg bg-card/60 px-3">
<span className="text-sm">Dark mode</span>
<span className="relative h-5 w-9 rounded-full bg-accent">
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-accent-fg" />
</span>
</div>
</div>
@@ -136,7 +136,7 @@ function NativeSet() {
<label className={clsx(row, "justify-between")}>
Hide Shorts
<span className="relative h-5 w-9 rounded-full bg-accent">
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-accent-fg" />
</span>
</label>
<label className="flex flex-col gap-1 px-2 text-sm">
+4 -4
View File
@@ -81,9 +81,9 @@ const SCHEMES: SchemeDef[] = [
light: pal("#f6f7f9", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e", "#a21caf", "#ffffff"),
},
{
name: "Matrix · lime",
dark: pal("#030a06", "#08130d", "#163d27", "#cfe8d6", "#6fae86", "#a3e635", "#04170c"),
light: pal("#f0f7f1", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56", "#4d7c0f", "#ffffff"),
name: "Matrix · moss",
dark: pal("#0b0e0b", "#111510", "#2a3226", "#d9ded6", "#8f9a8a", "#86a05a", "#0c1208"),
light: pal("#f4f5f0", "#ffffff", "#dfe3d8", "#161a14", "#5a6352", "#556b2f", "#ffffff"),
},
];
@@ -472,7 +472,7 @@ export function BatchB() {
id="b2"
code="B2"
title="The scheme set"
rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix lime. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix moss. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
>
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]">
{SCHEMES.flatMap((s) =>
+15 -13
View File
@@ -960,24 +960,26 @@ html[data-scheme="slate"][data-theme="light"] {
}
/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */
/* Matrix is moss, not neon (owner, 2026-09-24): the accent sits at Midnight's lightness (OKLCH L 0.67)
with half the old lime's chroma, and the neutrals are barely tinted, so the page doesn't glow green. */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #030a06;
--surface: #08130d;
--card: #0b1c12;
--border: #163d27;
--fg: #cfe8d6;
--muted: #6fae86;
--accent: #a3e635;
--accent-fg: #04170c;
--bg: #0b0e0b;
--surface: #111510;
--card: #161b15;
--border: #2a3226;
--fg: #d9ded6;
--muted: #8f9a8a;
--accent: #86a05a;
--accent-fg: #0c1208;
}
html[data-scheme="matrix"][data-theme="light"] {
--bg: #f0f7f1;
--bg: #f4f5f0;
--surface: #ffffff;
--card: #ffffff;
--border: #cfe6d6;
--fg: #08130b;
--muted: #4c6b56;
--accent: #4d7c0f;
--border: #dfe3d8;
--fg: #161a14;
--muted: #5a6352;
--accent: #556b2f;
--accent-fg: #ffffff;
}
+18 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { hexContrast, hueGap, mixHex } from "./colorMath";
import { SCHEMES } from "./theme";
@@ -40,6 +40,23 @@ it("styles exactly the schemes the app offers", () => {
expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/);
});
// The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent
// fill passes on one scheme and fails on the next (white on the old Matrix lime was 1.5:1).
it("text on a solid accent fill uses --accent-fg", () => {
const root = new URL("../", import.meta.url);
const offenders: string[] = [];
for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) {
if (!f.endsWith(".tsx")) continue;
readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")
.split("\n")
.forEach((line, i) => {
if (/\bbg-accent(?![\w/-])/.test(line) && /\b(text|bg)-(white|black)\b/.test(line))
offenders.push(`${f}:${i + 1}`);
});
}
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`;
+1 -1
View File
@@ -10,7 +10,7 @@ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#22d3ee" },
{ id: "slate", name: "Slate", swatch: "#e879f9" },
{ id: "matrix", name: "Matrix", swatch: "#a3e635" },
{ id: "matrix", name: "Matrix", swatch: "#86a05a" },
];
export interface ThemePrefs {