commit 9e468b83b533f2257032dd033ce2aebd45276a24
parent b4808e594369298f5de75897e12e610418748d27
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 21:39:50 -0400
common: themeConfig — base/accent keys, migrateLegacy, the pre-paint script source (brand S2)
`BASE_KEY` ("ytdlp-tb:base") and `ACCENT_KEY` (now an accent id);
`LEGACY_THEME_KEY` / `LEGACY_MODE_KEY` name the retired keys. `ThemeBase`
(light | sepia | dark | system), `ResolvedBase`, `ThemeAccent` (an id or
"custom"), `THEME_BASES` in picker order, `nextBase`, `resolveBase`.
`migrateLegacy({theme, mode})` is the plan's table: light → light (sepia when
the old theme was archive), dark → dark, system → system, absent → nothing.
`buildThemeScript({defaultBase})` is the pre-paint script as a string, in the
plan's order: migrate when no base is stored and delete both legacy keys;
validate the base; set data-base to the resolved ground and toggle .dark; set
data-accent only from a valid stored id; point theme-color at the ground;
data-theme-ready LAST. Each storage touch is guarded, so a throwing
localStorage still paints the default and sets the marker.
themeConfig.test.ts runs that string in node:vm over the whole matrix
(7 legacy themes × 5 modes × 6 stored bases × 3 defaults × OS light/dark),
checking the stored base, deleted legacy keys, data-base, .dark, theme-color
and that the marker is the last mutation; plus the accent and failure cases.
The retired family/mode exports stay at the bottom until the provider and the
pickers move over in the next commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 373 insertions(+), 29 deletions(-)
diff --git a/common/components/themeConfig.test.ts b/common/components/themeConfig.test.ts
@@ -0,0 +1,219 @@
+// The pre-paint theme script, run for real: buildThemeScript's string executed
+// in node:vm against a fake <html>, localStorage, matchMedia and theme-color
+// metas, over the whole legacy-migration matrix. The expected values come from
+// the pure helpers (migrateLegacy, resolveBase), so the inline script and the
+// runtime provider cannot drift apart.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import vm from "node:vm";
+import { ACCENT_IDS, BASE_GROUNDS, isAccentId } from "../lib/brand";
+import {
+ ACCENT_KEY,
+ BASE_KEY,
+ LEGACY_MODE_KEY,
+ LEGACY_THEME_KEY,
+ THEME_BASES,
+ buildThemeScript,
+ isThemeBase,
+ isThemeAccent,
+ migrateLegacy,
+ nextBase,
+ resolveBase,
+ type ThemeBase,
+} from "./themeConfig";
+
+type Env = {
+ storage: Record<string, string | null>;
+ prefersDark: boolean;
+ serverAccent?: string;
+ storageThrows?: boolean;
+ matchMediaThrows?: boolean;
+};
+
+type Result = {
+ attrs: Map<string, string>;
+ dark: boolean;
+ store: Map<string, string>;
+ metas: string[];
+ // Every DOM mutation, in order: the ready marker must be the last.
+ log: string[];
+};
+
+const scripts = new Map<string, vm.Script>();
+function compiled(defaultBase: ThemeBase): vm.Script {
+ let s = scripts.get(defaultBase);
+ if (!s) {
+ s = new vm.Script(buildThemeScript({ defaultBase }));
+ scripts.set(defaultBase, s);
+ }
+ return s;
+}
+
+const ctx = vm.createContext({});
+
+function run(defaultBase: ThemeBase, env: Env): Result {
+ const attrs = new Map<string, string>();
+ if (env.serverAccent) attrs.set("data-accent", env.serverAccent);
+ const classes = new Set<string>();
+ const log: string[] = [];
+ const store = new Map<string, string>();
+ for (const [k, v] of Object.entries(env.storage)) if (v !== null) store.set(k, v);
+ const metaEls = ["#123456", "#654321"].map((content) => ({
+ content,
+ setAttribute(k: string, v: string) {
+ if (k === "content") this.content = v;
+ log.push("meta");
+ },
+ }));
+ const fail = () => {
+ throw new Error("SecurityError: storage disabled");
+ };
+ const localStorage = env.storageThrows
+ ? { getItem: fail, setItem: fail, removeItem: fail }
+ : {
+ getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
+ setItem: (k: string, v: string) => void store.set(k, String(v)),
+ removeItem: (k: string) => void store.delete(k),
+ };
+ ctx.window = {
+ get localStorage() {
+ if (env.storageThrows) fail();
+ return localStorage;
+ },
+ matchMedia: (q: string) => {
+ if (env.matchMediaThrows) throw new Error("no matchMedia");
+ return { matches: q === "(prefers-color-scheme: dark)" && env.prefersDark };
+ },
+ };
+ ctx.document = {
+ documentElement: {
+ setAttribute: (k: string, v: string) => {
+ attrs.set(k, String(v));
+ log.push(k);
+ },
+ getAttribute: (k: string) => attrs.get(k) ?? null,
+ classList: {
+ toggle: (c: string, on: boolean) => {
+ if (on) classes.add(c);
+ else classes.delete(c);
+ log.push(`class:${c}`);
+ },
+ },
+ },
+ querySelectorAll: (sel: string) => (sel === 'meta[name="theme-color"]' ? metaEls : []),
+ };
+ compiled(defaultBase).runInContext(ctx);
+ return { attrs, dark: classes.has("dark"), store, metas: metaEls.map((m) => m.content), log };
+}
+
+const THEMES = [null, "base", "archive", "selenized", "swiss", "archilyzer", "terminal"];
+const MODES = [null, "light", "dark", "system", "bogus"];
+const BASES = [null, "light", "sepia", "dark", "system", "bogus"];
+const DEFAULTS: ThemeBase[] = ["system", "light", "dark"];
+
+test("migrateLegacy follows the plan's table", () => {
+ assert.equal(migrateLegacy({ theme: null, mode: "light" }), "light");
+ assert.equal(migrateLegacy({ theme: "archive", mode: "light" }), "sepia");
+ assert.equal(migrateLegacy({ theme: "selenized", mode: "light" }), "light");
+ assert.equal(migrateLegacy({ theme: "archive", mode: "dark" }), "dark");
+ assert.equal(migrateLegacy({ theme: "selenized", mode: "dark" }), "dark");
+ assert.equal(migrateLegacy({ theme: "archive", mode: "system" }), "system");
+ assert.equal(migrateLegacy({ theme: "archive", mode: null }), null);
+ assert.equal(migrateLegacy({ theme: null, mode: null }), null);
+ assert.equal(migrateLegacy({ theme: null, mode: "bogus" }), null);
+});
+
+test("the pre-paint script over the whole legacy × stored-base × default × OS matrix", () => {
+ let cases = 0;
+ for (const theme of THEMES)
+ for (const mode of MODES)
+ for (const base of BASES)
+ for (const defaultBase of DEFAULTS)
+ for (const prefersDark of [false, true]) {
+ const storage = {
+ [LEGACY_THEME_KEY]: theme,
+ [LEGACY_MODE_KEY]: mode,
+ [BASE_KEY]: base,
+ };
+ const r = run(defaultBase, { storage, prefersDark });
+ const label = JSON.stringify({ theme, mode, base, defaultBase, prefersDark });
+
+ // 1. Migration: only when no base is stored; legacy keys always go.
+ const migrated = base === null ? migrateLegacy({ theme, mode }) : null;
+ const storedAfter = base ?? migrated;
+ assert.equal(r.store.get(BASE_KEY) ?? null, storedAfter, `${label}: stored base`);
+ assert.ok(!r.store.has(LEGACY_THEME_KEY), `${label}: legacy theme key kept`);
+ assert.ok(!r.store.has(LEGACY_MODE_KEY), `${label}: legacy mode key kept`);
+
+ // 2–3. Validated base, resolved against the OS.
+ const effective: ThemeBase = isThemeBase(storedAfter) ? storedAfter : defaultBase;
+ const resolved = resolveBase(effective, prefersDark);
+ assert.equal(r.attrs.get("data-base"), resolved, `${label}: data-base`);
+ assert.equal(r.dark, resolved === "dark", `${label}: .dark`);
+
+ // 5. Browser chrome follows the resolved ground.
+ assert.deepEqual(r.metas, [BASE_GROUNDS[resolved], BASE_GROUNDS[resolved]], `${label}: theme-color`);
+
+ // 6. The marker is set, and set last.
+ assert.equal(r.attrs.get("data-theme-ready"), "1", `${label}: ready`);
+ assert.equal(r.log.at(-1), "data-theme-ready", `${label}: ready is not last`);
+ cases++;
+ }
+ assert.equal(cases, THEMES.length * MODES.length * BASES.length * DEFAULTS.length * 2);
+});
+
+test("data-accent comes only from a valid stored id; otherwise the server's stays", () => {
+ for (const stored of [null, ...ACCENT_IDS, "custom", "#cc3366", "bogus", ""])
+ for (const serverAccent of [undefined, "brass", "custom"]) {
+ const r = run("system", { storage: { [ACCENT_KEY]: stored }, prefersDark: false, serverAccent });
+ const expected = stored && isAccentId(stored) ? stored : serverAccent;
+ assert.equal(r.attrs.get("data-accent"), expected, JSON.stringify({ stored, serverAccent }));
+ // The script never writes the accent key.
+ assert.equal(r.store.get(ACCENT_KEY) ?? null, stored);
+ assert.equal(r.log.at(-1), "data-theme-ready");
+ }
+});
+
+test("storage that throws still paints the default base and sets the marker", () => {
+ for (const defaultBase of DEFAULTS)
+ for (const prefersDark of [false, true]) {
+ const r = run(defaultBase, { storage: {}, prefersDark, storageThrows: true, serverAccent: "violet" });
+ const resolved = resolveBase(defaultBase, prefersDark);
+ assert.equal(r.attrs.get("data-base"), resolved);
+ assert.equal(r.dark, resolved === "dark");
+ assert.equal(r.attrs.get("data-accent"), "violet");
+ assert.equal(r.log.at(-1), "data-theme-ready");
+ }
+});
+
+test("a matchMedia that throws resolves system to light and still sets the marker", () => {
+ const r = run("system", { storage: {}, prefersDark: true, matchMediaThrows: true });
+ assert.equal(r.attrs.get("data-base"), "light");
+ assert.equal(r.dark, false);
+ assert.equal(r.attrs.get("data-theme-ready"), "1");
+});
+
+test("an invalid defaultBase falls back to system", () => {
+ const r = run("bogus" as ThemeBase, { storage: {}, prefersDark: true });
+ assert.equal(r.attrs.get("data-base"), "dark");
+});
+
+test("the toggle cycles system → light → sepia → dark → system, in picker order", () => {
+ const seen: ThemeBase[] = [];
+ let b: ThemeBase = "system";
+ for (let i = 0; i < 4; i++) {
+ seen.push(b);
+ b = nextBase(b);
+ }
+ assert.equal(b, "system");
+ assert.deepEqual(seen, THEME_BASES.map((x) => x.id));
+ assert.deepEqual(THEME_BASES.map((x) => x.label), ["System", "Light", "Sepia", "Dark"]);
+});
+
+test("isThemeAccent takes the seven ids and custom only", () => {
+ for (const id of ACCENT_IDS) assert.ok(isThemeAccent(id));
+ assert.ok(isThemeAccent("custom"));
+ assert.ok(!isThemeAccent("#cc3366"));
+ assert.ok(!isThemeAccent("Signal"));
+});
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -1,41 +1,75 @@
-// Shared, dependency-free theme constants + types used by both the pre-paint
-// ThemeScript (server) and the runtime ThemeProvider (client). Keys are
-// namespaced under the existing `ytdlp-tb:*` localStorage convention.
+// Shared, dependency-light theme constants, types and the pre-paint script's
+// source. Used by the pre-paint ThemeScript (server), the runtime ThemeProvider
+// (client), the pickers, the unit tests and the homepage e2e (REQUIRED_TOKENS).
+// Keys are namespaced under the existing `ytdlp-tb:*` localStorage convention.
+//
+// A READER THEME IS TWO INDEPENDENT CHOICES (plans/brand-and-themes.md):
+// • a BASE — a light, sepia or dark ground, or "system", which follows the
+// OS between light and dark. `html[data-base]` selects one of the three
+// token blocks in common/styles/tokens.css; `.dark` is on <html> iff the
+// resolved base is dark, so Tailwind's `dark:` utilities keep working and
+// sepia styles as a light ground.
+// • an ACCENT — one of the seven named accents in lib/brand.ts. Each site
+// defaults to its own (a server-rendered `html[data-accent]`); a reader's
+// pick is stored only while it differs from the site's.
+//
+// PURE: no React, no DOM at import time. lib/brand.ts is pure too.
-export const THEME_KEY = "ytdlp-tb:theme";
-export const MODE_KEY = "ytdlp-tb:mode";
+import {
+ ACCENT_IDS,
+ BASE_GROUNDS,
+ isAccentId,
+ type AccentId,
+} from "../lib/brand";
+
+// The reader's base ("light" | "sepia" | "dark" | "system").
+export const BASE_KEY = "ytdlp-tb:base";
+// The reader's accent: an accent id, stored only while it differs from the
+// site's own. (Before the base × accent themes this key held a hex that
+// nothing in the UI wrote; a stored value that is not an accent id is ignored.)
export const ACCENT_KEY = "ytdlp-tb:accent";
+// The retired theme-family and light/dark-mode keys. Read once by the
+// migration (migrateLegacy, and the same table inside the pre-paint script),
+// then deleted.
+export const LEGACY_THEME_KEY = "ytdlp-tb:theme";
+export const LEGACY_MODE_KEY = "ytdlp-tb:mode";
-// Theme FAMILY (palette personality) — orthogonal to light/dark MODE.
-// "base" is the neutral default (no data-theme attribute). Each family is a pure
-// CSS token swap (a [data-theme="…"] block in tokens.css); no markup differs.
-export type ThemeFamily =
- | "base"
- | "archive"
- | "selenized"
- | "swiss"
- | "archilyzer";
+// The three grounds a base resolves to, and the reader's choice (which adds
+// "system").
+export type ResolvedBase = "light" | "sepia" | "dark";
+export type ThemeBase = ResolvedBase | "system";
-export type ThemeMode = "light" | "dark" | "system";
+// What `html[data-accent]` can carry: a named accent, or "custom" — a site
+// whose site.json accent is its own hex (the inline `--accent-custom-*` vars).
+// A reader can never STORE "custom": it only ever means "this site's colour".
+export type ThemeAccent = AccentId | "custom";
-// Selectable families. Order = display order in the ThemeMenu picker.
-export const THEME_FAMILIES: { id: ThemeFamily; label: string }[] = [
- { id: "base", label: "Base" },
- { id: "archive", label: "Archive" },
- { id: "selenized", label: "Selenized" },
- { id: "swiss", label: "Swiss" },
- // The instrument face — graphite chrome, no decorative brand hue. The project
- // site's default; selectable everywhere so an operator can dress an archive in
- // it, though it is deliberately the tool's voice, not an archive's.
- { id: "archilyzer", label: "Archilyzer" },
-];
-
-export const THEME_MODES: { id: ThemeMode; label: string }[] = [
+// Picker order.
+export const THEME_BASES: ReadonlyArray<{ id: ThemeBase; label: string }> = [
+ { id: "system", label: "System" },
{ id: "light", label: "Light" },
+ { id: "sepia", label: "Sepia" },
{ id: "dark", label: "Dark" },
- { id: "system", label: "System" },
];
+export function isThemeBase(v: unknown): v is ThemeBase {
+ return v === "system" || v === "light" || v === "sepia" || v === "dark";
+}
+
+export function isThemeAccent(v: unknown): v is ThemeAccent {
+ return v === "custom" || isAccentId(v);
+}
+
+// ThemeToggle's cycle: system → light → sepia → dark → system.
+export function nextBase(b: ThemeBase): ThemeBase {
+ return b === "system" ? "light" : b === "light" ? "sepia" : b === "sepia" ? "dark" : "system";
+}
+
+// A choice resolved against the OS preference.
+export function resolveBase(b: ThemeBase, systemDark: boolean): ResolvedBase {
+ return b === "system" ? (systemDark ? "dark" : "light") : b;
+}
+
// Every colour token a base block in tokens.css must declare. The failure this
// guards is silent: a base that omits a token inherits the light block's value
// (`:root` always matches), so a half-declared palette looks "a bit off" rather
@@ -93,3 +127,94 @@ export const REQUIRED_TOKENS = [
"--chart-axis",
"--chart-tooltip-bg",
] as const;
+
+// The retired keys → a base, once. After it runs, both legacy keys are deleted
+// by the caller, whatever it returned.
+//
+// stored mode result
+// light light — or sepia when the old theme was "archive" (paper)
+// dark dark
+// system system
+// absent/other null: nothing is stored and the app's default base applies
+//
+// The old theme FAMILY is otherwise dropped: every family retired, and the
+// accent is the site's again.
+export function migrateLegacy({
+ theme,
+ mode,
+}: {
+ theme: string | null;
+ mode: string | null;
+}): ThemeBase | null {
+ if (mode === "light") return theme === "archive" ? "sepia" : "light";
+ if (mode === "dark") return "dark";
+ if (mode === "system") return "system";
+ return null;
+}
+
+// The pre-paint script, as the string ThemeScript.tsx inlines. It runs
+// synchronously before first paint, in this order:
+// 1. when no base is stored yet, migrate the legacy keys (migrateLegacy's
+// table); then delete both legacy keys;
+// 2. validate the stored base, falling back to `defaultBase`;
+// 3. set `data-base` to the RESOLVED ground and toggle `.dark`;
+// 4. set `data-accent` only from a valid stored accent id — otherwise the
+// server-rendered default (the site's own accent) stays;
+// 5. point every `meta[name=theme-color]` at the resolved ground;
+// 6. LAST: `data-theme-ready="1"`, the e2e no-flash marker, so it is present
+// only once every attribute above is set. e2e's reloads resolve on
+// navigation commit, possibly before this head script has run, and wait
+// for the marker instead of racing.
+// Storage may throw (privacy modes): each storage touch is guarded, so a
+// failure still paints the default base and still sets the marker.
+// themeConfig.test.ts runs this string in node:vm over the whole matrix.
+export function buildThemeScript({
+ defaultBase = "system",
+}: { defaultBase?: ThemeBase } = {}): string {
+ const fallback: ThemeBase = isThemeBase(defaultBase) ? defaultBase : "system";
+ const q = JSON.stringify;
+ return (
+ "(function(){try{" +
+ "var d=document.documentElement,b=null,a=null,s;" +
+ "try{s=window.localStorage;" +
+ `b=s.getItem(${q(BASE_KEY)});` +
+ `var lt=s.getItem(${q(LEGACY_THEME_KEY)}),lm=s.getItem(${q(LEGACY_MODE_KEY)});` +
+ "if(lt!==null||lm!==null){" +
+ "if(b===null){" +
+ "var m=lm==='light'?(lt==='archive'?'sepia':'light'):lm==='dark'?'dark':lm==='system'?'system':null;" +
+ `if(m){s.setItem(${q(BASE_KEY)},m);b=m;}` +
+ "}" +
+ `s.removeItem(${q(LEGACY_THEME_KEY)});s.removeItem(${q(LEGACY_MODE_KEY)});` +
+ "}" +
+ `a=s.getItem(${q(ACCENT_KEY)});` +
+ "}catch(e){}" +
+ `if(b!=='light'&&b!=='sepia'&&b!=='dark'&&b!=='system')b=${q(fallback)};` +
+ "var r=b;" +
+ "if(b==='system'){r='light';try{if(window.matchMedia('(prefers-color-scheme: dark)').matches)r='dark';}catch(e){}}" +
+ "d.setAttribute('data-base',r);" +
+ "d.classList.toggle('dark',r==='dark');" +
+ `if(a&&${q(ACCENT_IDS)}.indexOf(a)>=0)d.setAttribute('data-accent',a);` +
+ `var g=${q(BASE_GROUNDS)}[r];` +
+ "try{var ms=document.querySelectorAll('meta[name=\"theme-color\"]');for(var i=0;i<ms.length;i++)ms[i].setAttribute('content',g);}catch(e){}" +
+ "d.setAttribute('data-theme-ready','1');" +
+ "}catch(e){}})();"
+ );
+}
+
+// ── Retired (removed once ThemeProvider/ThemeMenu/MobileMenu move over) ─────
+export const THEME_KEY = LEGACY_THEME_KEY;
+export const MODE_KEY = LEGACY_MODE_KEY;
+export type ThemeFamily = "base" | "archive" | "selenized" | "swiss" | "archilyzer";
+export type ThemeMode = "light" | "dark" | "system";
+export const THEME_FAMILIES: { id: ThemeFamily; label: string }[] = [
+ { id: "base", label: "Base" },
+ { id: "archive", label: "Archive" },
+ { id: "selenized", label: "Selenized" },
+ { id: "swiss", label: "Swiss" },
+ { id: "archilyzer", label: "Archilyzer" },
+];
+export const THEME_MODES: { id: ThemeMode; label: string }[] = [
+ { id: "light", label: "Light" },
+ { id: "dark", label: "Dark" },
+ { id: "system", label: "System" },
+];