Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Acommon/components/themeConfig.test.ts | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/themeConfig.ts | 183++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
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" }, +];