commit 4de4d9514a17e357c35384783cae2bc14121f953
parent f73008025ee9cb13a8f4fa4a2905611952bb6de9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 23:05:22 -0400
Merge brand/themes — brand slice S2: reader themes are a base (System / Light / Sepia / Dark) × an accent (the site's own by default, any of the seven by choice); the five theme families retire; Archivo + IBM Plex; legacy picks migrate pre-paint; the hub and homepage render dark on the server
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
41 files changed, 2105 insertions(+), 1307 deletions(-)
diff --git a/common/components/ThemeMenu.tsx b/common/components/ThemeMenu.tsx
@@ -3,10 +3,10 @@
import { Palette } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import {
- THEME_FAMILIES,
- THEME_MODES,
- type ThemeFamily,
- type ThemeMode,
+ THEME_BASES,
+ accentOptions,
+ isThemeAccent,
+ isThemeBase,
} from "./themeConfig";
import {
DropdownMenu,
@@ -19,13 +19,14 @@ import {
} from "./ui/dropdown-menu";
import { cn } from "../lib/utils";
-// The theme-family picker: a palette dropdown to choose the family (Base /
-// Archive / Selenized / Swiss) and the mode (light / dark / system). Sits beside
-// the quick-toggle ThemeToggle. Each family is a CSS token swap (color, plus a
-// per-family radius + type voice), so picking one only changes `data-theme` on
-// <html> (handled by setTheme). Must render inside a <ThemeProvider/>.
+// The theme picker: a palette dropdown with two radio groups — the BASE
+// (System / Light / Sepia / Dark) and the ACCENT (the seven named accents, the
+// site's own tagged "default"; a custom-hex site adds its colour first as
+// "Site colour"). Sits beside the quick base toggle (ThemeToggle). Picking only
+// changes attributes on <html>; tokens.css does the rest. The `menuitemradio`
+// names are an e2e contract. Must render inside a <ThemeProvider/>.
export function ThemeMenu({ className }: { className?: string }) {
- const { theme, mode, setTheme, setMode } = useTheme();
+ const { base, accent, siteAccent, setBase, setAccent } = useTheme();
return (
<DropdownMenu>
@@ -39,27 +40,43 @@ export function ThemeMenu({ className }: { className?: string }) {
>
<Palette className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="min-w-44">
- <DropdownMenuLabel>Theme</DropdownMenuLabel>
+ <DropdownMenuContent align="end" className="min-w-52">
+ <DropdownMenuLabel>Base</DropdownMenuLabel>
<DropdownMenuRadioGroup
- value={theme}
- onValueChange={(v) => setTheme(v as ThemeFamily)}
+ aria-label="Base"
+ value={base}
+ onValueChange={(v) => {
+ if (isThemeBase(v)) setBase(v);
+ }}
>
- {THEME_FAMILIES.map((f) => (
- <DropdownMenuRadioItem key={f.id} value={f.id}>
- {f.label}
+ {THEME_BASES.map((b) => (
+ <DropdownMenuRadioItem key={b.id} value={b.id}>
+ {b.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
- <DropdownMenuLabel>Mode</DropdownMenuLabel>
+ <DropdownMenuLabel>Accent</DropdownMenuLabel>
<DropdownMenuRadioGroup
- value={mode}
- onValueChange={(v) => setMode(v as ThemeMode)}
+ aria-label="Accent"
+ value={accent}
+ onValueChange={(v) => {
+ if (isThemeAccent(v)) setAccent(v);
+ }}
>
- {THEME_MODES.map((m) => (
- <DropdownMenuRadioItem key={m.id} value={m.id}>
- {m.label}
+ {accentOptions(siteAccent).map((o) => (
+ <DropdownMenuRadioItem key={o.id} value={o.id}>
+ <span
+ aria-hidden="true"
+ className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
+ style={{ background: o.swatch }}
+ />
+ <span>{o.label}</span>
+ {o.isSiteDefault && (
+ <span className="ml-auto pl-3 font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
+ default
+ </span>
+ )}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx
@@ -4,154 +4,208 @@ import {
createContext,
useCallback,
useContext,
- useLayoutEffect,
useEffect,
+ useLayoutEffect,
useMemo,
useState,
} from "react";
+import { BASE_GROUNDS, DEFAULT_ACCENT, isAccentId, type AccentId } from "../lib/brand";
import {
- THEME_KEY,
- MODE_KEY,
- type ThemeFamily,
- type ThemeMode,
+ ACCENT_KEY,
+ BASE_KEY,
+ LEGACY_MODE_KEY,
+ LEGACY_THEME_KEY,
+ isThemeBase,
+ migrateLegacy,
+ nextBase,
+ resolveBase,
+ type ResolvedBase,
+ type ThemeAccent,
+ type ThemeBase,
} from "./themeConfig";
-// Runtime theme controller. The pre-paint <ThemeScript/> colors the FIRST paint
-// by setting attributes on <html> from localStorage before hydration. But <html>
-// is server-rendered with a static className that omits `.dark`, so that script
-// mutation is lost across the hydration boundary and nothing restores it — the
-// page would stay light until a user toggle. So on mount this provider RE-ASSERTS
-// the *persisted, resolved* theme to the DOM (via useLayoutEffect, before paint).
-// That is idempotent — it recomputes the exact same value the script already used
-// from the same localStorage, so it never fights the script and never flashes.
-// After mount it writes to the DOM only on explicit user changes and live
-// OS-preference changes.
+// Runtime theme controller: the reader's BASE (light | sepia | dark | system)
+// and ACCENT (one of the seven, defaulting to the site's own).
+//
+// The pre-paint <ThemeScript/> colours the FIRST paint by setting attributes on
+// <html> from localStorage before hydration. Hydration can reset <html>'s
+// attributes to what the server rendered — the className without `.dark`, and
+// the SITE's `data-accent` over a reader's pick — so on mount this provider
+// RE-ASSERTS the persisted, resolved theme (data-base, .dark, data-accent,
+// theme-color) in a useLayoutEffect, before paint. That is idempotent: it
+// recomputes what the script computed from the same storage, so it never
+// fights the script and never flashes. After that it writes to the DOM only on
+// a reader's change and on a live OS-preference change.
type ThemeContextValue = {
- theme: ThemeFamily;
- mode: ThemeMode;
- /** Whether dark is currently applied (resolves "system"). */
+ /** The reader's choice, "system" included. */
+ base: ThemeBase;
+ /** The ground actually painted ("system" resolved against the OS). */
+ resolvedBase: ResolvedBase;
+ /** Whether the dark base is applied. */
isDark: boolean;
- setTheme: (t: ThemeFamily) => void;
- setMode: (m: ThemeMode) => void;
- /** Cycle light → dark → system → light. */
- cycleMode: () => void;
+ /** The accent applied: the reader's pick, else the site's. */
+ accent: ThemeAccent;
+ /** The site's own accent ("custom" for a site with its own hex). */
+ siteAccent: ThemeAccent;
+ setBase: (b: ThemeBase) => void;
+ /** system → light → sepia → dark → system. */
+ cycleBase: () => void;
+ /** Picking the site's own accent REMOVES the stored pick, so the reader
+ * follows the site's default from then on. */
+ setAccent: (a: ThemeAccent) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
+const DARK_QUERY = "(prefers-color-scheme: dark)";
+
function systemPrefersDark(): boolean {
- return (
- typeof window !== "undefined" &&
- window.matchMedia("(prefers-color-scheme: dark)").matches
- );
+ try {
+ return window.matchMedia(DARK_QUERY).matches;
+ } catch {
+ return false;
+ }
}
-function resolveDark(mode: ThemeMode): boolean {
- return mode === "system" ? systemPrefersDark() : mode === "dark";
+// What storage holds, after the same one-time legacy migration the pre-paint
+// script runs (a no-op when the script already ran it).
+function readStored(): { base: ThemeBase | null; accent: AccentId | null } {
+ try {
+ const s = window.localStorage;
+ let base = s.getItem(BASE_KEY);
+ const theme = s.getItem(LEGACY_THEME_KEY);
+ const mode = s.getItem(LEGACY_MODE_KEY);
+ if (theme !== null || mode !== null) {
+ if (base === null) {
+ const migrated = migrateLegacy({ theme, mode });
+ if (migrated) {
+ s.setItem(BASE_KEY, migrated);
+ base = migrated;
+ }
+ }
+ s.removeItem(LEGACY_THEME_KEY);
+ s.removeItem(LEGACY_MODE_KEY);
+ }
+ const accent = s.getItem(ACCENT_KEY);
+ return {
+ base: isThemeBase(base) ? base : null,
+ accent: isAccentId(accent) ? accent : null,
+ };
+ } catch {
+ return { base: null, accent: null };
+ }
}
-function applyToDom(theme: ThemeFamily, dark: boolean) {
+function applyToDom(resolved: ResolvedBase, accent: ThemeAccent) {
const d = document.documentElement;
- if (theme && theme !== "base") d.setAttribute("data-theme", theme);
- else d.removeAttribute("data-theme");
- d.classList.toggle("dark", dark);
+ d.setAttribute("data-base", resolved);
+ d.classList.toggle("dark", resolved === "dark");
+ d.setAttribute("data-accent", accent);
+ for (const m of document.querySelectorAll('meta[name="theme-color"]')) {
+ m.setAttribute("content", BASE_GROUNDS[resolved]);
+ }
+}
+
+function store(key: string, value: string | null) {
+ try {
+ if (value === null) window.localStorage.removeItem(key);
+ else window.localStorage.setItem(key, value);
+ } catch {
+ /* storage disabled: the choice holds for this page only */
+ }
}
export function ThemeProvider({
- defaultTheme = "base",
- defaultMode = "system",
+ defaultBase = "system",
+ siteAccent = DEFAULT_ACCENT,
children,
}: {
- defaultTheme?: ThemeFamily;
- defaultMode?: ThemeMode;
+ defaultBase?: ThemeBase;
+ siteAccent?: ThemeAccent;
children: React.ReactNode;
}) {
- // Initial state MUST equal what the server rendered (defaults), so hydration
- // matches; the persisted values are adopted in an effect just below.
- const [theme, setThemeState] = useState<ThemeFamily>(defaultTheme);
- const [mode, setModeState] = useState<ThemeMode>(defaultMode);
-
- // Adopt persisted values into React state AND re-assert them to the DOM before
- // paint, so the theme survives the hydration boundary (see block comment above).
- // useLayoutEffect runs synchronously before the browser paints the hydrated tree.
+ // Initial state MUST equal what the server rendered (the defaults), so
+ // hydration matches; the persisted values are adopted just below.
+ const [base, setBaseState] = useState<ThemeBase>(defaultBase);
+ const [accent, setAccentState] = useState<ThemeAccent>(siteAccent);
+ const [systemDark, setSystemDark] = useState(false);
+ const [adopted, setAdopted] = useState(false);
+
+ // Adopt the persisted choice before paint. The state updates re-render
+ // synchronously, and the effect below then re-asserts it to <html>.
useLayoutEffect(() => {
+ const stored = readStored();
+ setBaseState(stored.base ?? defaultBase);
+ setAccentState(stored.accent ?? siteAccent);
+ setSystemDark(systemPrefersDark());
+ setAdopted(true);
+ // Effectively once: the defaults are stable props from the layout.
+ }, [defaultBase, siteAccent]);
+
+ // A LIVE OS preference: "system" follows a change made while the page is
+ // open (the pre-paint script can only read it once).
+ useEffect(() => {
+ let mql: MediaQueryList;
try {
- let t = localStorage.getItem(THEME_KEY) as ThemeFamily | "terminal" | null;
- const m = localStorage.getItem(MODE_KEY) as ThemeMode | null;
- // The "terminal" family was renamed to "selenized"; migrate any stored value
- // (and rewrite it) so the picker highlights the right entry.
- if (t === "terminal") {
- t = "selenized";
- try {
- localStorage.setItem(THEME_KEY, t);
- } catch {
- /* ignore */
- }
- }
- if (t) setThemeState(t);
- if (m) setModeState(m);
- // Re-apply the resolved persisted values (falling back to the defaults the
- // server rendered) so <html> carries the correct `data-theme`/`.dark` again.
- const resolvedTheme = t ?? defaultTheme;
- const resolvedMode = m ?? defaultMode;
- applyToDom(resolvedTheme, resolveDark(resolvedMode));
+ mql = window.matchMedia(DARK_QUERY);
} catch {
- /* ignore */
+ return;
}
- // Effectively runs once: the defaults are stable props (passed by the
- // layout and never changed), so this re-adopts persisted state on mount and
- // does not fight later user changes made through setTheme/setMode.
- }, [defaultTheme, defaultMode]);
-
- // Track OS changes while in "system" mode (the only case the script can't
- // keep live after load).
- useEffect(() => {
- if (mode !== "system") return;
- const mql = window.matchMedia("(prefers-color-scheme: dark)");
- const onChange = () => applyToDom(theme, mql.matches);
+ const onChange = () => setSystemDark(mql.matches);
+ onChange();
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
- }, [mode, theme]);
-
- const setTheme = useCallback(
- (t: ThemeFamily) => {
- setThemeState(t);
- applyToDom(t, resolveDark(mode));
- try {
- localStorage.setItem(THEME_KEY, t);
- } catch {
- /* ignore */
- }
- },
- [mode],
- );
+ }, []);
+
+ const resolvedBase = resolveBase(base, systemDark);
- const setMode = useCallback(
- (m: ThemeMode) => {
- setModeState(m);
- applyToDom(theme, resolveDark(m));
- try {
- localStorage.setItem(MODE_KEY, m);
- } catch {
- /* ignore */
+ // Re-assert <html> — once adopted, and on every change after that. Nothing is
+ // written before adoption, so the server's defaults never overwrite what the
+ // pre-paint script set.
+ useLayoutEffect(() => {
+ if (adopted) applyToDom(resolvedBase, accent);
+ }, [adopted, resolvedBase, accent]);
+
+ const setBase = useCallback((b: ThemeBase) => {
+ if (!isThemeBase(b)) return;
+ setBaseState(b);
+ store(BASE_KEY, b);
+ }, []);
+
+ const cycleBase = useCallback(() => setBase(nextBase(base)), [base, setBase]);
+
+ const setAccent = useCallback(
+ (a: ThemeAccent) => {
+ if (a === siteAccent) {
+ setAccentState(a);
+ store(ACCENT_KEY, null);
+ return;
}
+ // "custom" only ever means THIS site's own colour (above); a named
+ // accent is the only thing a reader can store.
+ if (!isAccentId(a)) return;
+ setAccentState(a);
+ store(ACCENT_KEY, a);
},
- [theme],
+ [siteAccent],
);
- const cycleMode = useCallback(() => {
- setMode(mode === "light" ? "dark" : mode === "dark" ? "system" : "light");
- }, [mode, setMode]);
-
const value = useMemo<ThemeContextValue>(
- () => ({ theme, mode, isDark: resolveDark(mode), setTheme, setMode, cycleMode }),
- [theme, mode, setTheme, setMode, cycleMode],
+ () => ({
+ base,
+ resolvedBase,
+ isDark: resolvedBase === "dark",
+ accent,
+ siteAccent,
+ setBase,
+ cycleBase,
+ setAccent,
+ }),
+ [base, resolvedBase, accent, siteAccent, setBase, cycleBase, setAccent],
);
- return (
- <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
- );
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
diff --git a/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx
@@ -1,61 +1,29 @@
-import {
- THEME_KEY,
- MODE_KEY,
- ACCENT_KEY,
- type ThemeFamily,
- type ThemeMode,
-} from "./themeConfig";
+import { buildThemeScript, type ThemeBase } from "./themeConfig";
// Server component (NOT "use client"): renders an inline <script> that runs
// synchronously BEFORE first paint, so there is no flash of the wrong theme.
-// Reads localStorage and sets `data-theme` (family) + `.dark` (mode) + an
-// optional per-user `--brand` accent override on <html>. Works identically for
-// static-export sites and the server editor. Pair with `suppressHydrationWarning`
-// on <html> since this mutates the element before React hydrates.
+// From localStorage it migrates the retired theme/mode keys once, then sets
+// `data-base` (the resolved light | sepia | dark ground) and `.dark` on <html>,
+// `data-accent` when the reader stored an accent of their own, the browser
+// chrome colour, and — last — the `data-theme-ready` marker the e2e no-flash
+// tests wait for. The script's source and its order are
+// themeConfig.buildThemeScript (unit-tested in node:vm).
//
-// Accent precedence (highest first): user override (this script) > per-site
-// default (an inline <html style="--brand:…"> baked at build) > family token.
-// `--brand` is the family brand color (brass/blue); shadcn's neutral `--accent`
-// is a separate token and is never overridden here.
+// The site's OWN accent is not this script's business: the layout
+// server-renders it as `<html data-accent>`, and the script only replaces it
+// with a reader's valid stored pick. Works identically for static-export sites
+// and the server editor. Pair with `suppressHydrationWarning` on <html>, since
+// this mutates the element before React hydrates — and with ThemeProvider,
+// which re-asserts the same attributes after hydration.
export function ThemeScript({
- defaultTheme = "base",
- defaultMode = "system",
+ defaultBase = "system",
}: {
- defaultTheme?: ThemeFamily;
- defaultMode?: ThemeMode;
+ defaultBase?: ThemeBase;
}) {
- const js =
- "(function(){try{" +
- "var d=document.documentElement;" +
- "var t=localStorage.getItem(" +
- JSON.stringify(THEME_KEY) +
- ")||" +
- JSON.stringify(defaultTheme) +
- ";" +
- "if(t==='terminal'){t='selenized';}" + // renamed family; migrate old stored value
- "if(t&&t!=='base'){d.setAttribute('data-theme',t);}else{d.removeAttribute('data-theme');}" +
- "var m=localStorage.getItem(" +
- JSON.stringify(MODE_KEY) +
- ")||" +
- JSON.stringify(defaultMode) +
- ";" +
- "var dark=m==='dark'||(m!=='light'&&window.matchMedia('(prefers-color-scheme: dark)').matches);" +
- "d.classList.toggle('dark',dark);" +
- "var a=localStorage.getItem(" +
- JSON.stringify(ACCENT_KEY) +
- ");" +
- "if(a){d.style.setProperty('--brand',a);}" +
- // Last statement of the same synchronous try block, so the marker can only
- // be present once every attribute above is already set. e2e's "no FOUC"
- // tests reload with waitUntil:"commit", which resolves as soon as the
- // navigation commits — possibly before this head script has run at all —
- // and then read data-theme. They wait for this marker instead of racing.
- // It proves nothing weaker than before: a <head> script still runs before
- // any React, so "set before hydration" is exactly what is observed.
- "d.setAttribute('data-theme-ready','1');" +
- "}catch(e){}})();";
-
return (
- <script dangerouslySetInnerHTML={{ __html: js }} suppressHydrationWarning />
+ <script
+ dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase }) }}
+ suppressHydrationWarning
+ />
);
}
diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx
@@ -1,30 +1,33 @@
"use client";
-import { Sun, Moon, Monitor } from "lucide-react";
+import { BookOpen, Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider";
+import { nextBase, type ThemeBase } from "./themeConfig";
import { cn } from "../lib/utils";
-// Minimal mode toggle: cycles light → dark → system. A plain, dependency-light
-// control for Phase 0; it gets re-skinned onto the shadcn kit (and gains a
-// family/theme menu) in later phases. Must be rendered inside a <ThemeProvider/>.
-const ICON = { light: Sun, dark: Moon, system: Monitor } as const;
-const NEXT_LABEL = {
- light: "Switch to dark",
- dark: "Switch to system",
- system: "Switch to light",
-} as const;
+// The quick base toggle: cycles system → light → sepia → dark → system. The
+// icon shows the base in force, the label names the next one ("Switch to …" —
+// the e2e specs find it by /switch to/i). The accent lives in ThemeMenu. Must
+// be rendered inside a <ThemeProvider/>.
+const ICON = { system: Monitor, light: Sun, sepia: BookOpen, dark: Moon } as const;
+const LABEL: Record<ThemeBase, string> = {
+ system: "system",
+ light: "light",
+ sepia: "sepia",
+ dark: "dark",
+};
export function ThemeToggle({ className }: { className?: string }) {
- const { mode, cycleMode } = useTheme();
- const Icon = ICON[mode];
+ const { base, cycleBase } = useTheme();
+ const Icon = ICON[base];
return (
<button
type="button"
- onClick={cycleMode}
- aria-label={NEXT_LABEL[mode]}
- title={`Theme: ${mode}`}
- data-theme-mode={mode}
+ onClick={cycleBase}
+ aria-label={`Switch to ${LABEL[nextBase(base)]}`}
+ title={`Theme: ${LABEL[base]}`}
+ data-theme-base={base}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] hover:border-[var(--border-strong,var(--border))]",
className,
diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx
@@ -11,10 +11,9 @@ import { splitWordmark } from "../lib/brand";
// not "Jer alyzer"). Do not make this element `flex`: that would blockify the
// spans and the browser would put a space between them in the name.
//
-// Size, leading and truncation come from `className`. The face is Archivo
-// wherever it is loaded: `--font-grotesk` names it until the themes slice makes
-// Archivo the display face everywhere, after which the fallback is the same
-// face and the first term can go.
+// Size, leading and truncation come from `className`. The face is Archivo,
+// the display face on every base (common/styles/fonts.ts `--font-display`,
+// with the `wdth` axis the stretch needs).
export function Wordmark({
title,
lead,
@@ -30,7 +29,7 @@ export function Wordmark({
className={className}
data-wordmark=""
style={{
- fontFamily: "var(--font-grotesk, var(--font-display))",
+ fontFamily: "var(--font-display)",
fontStretch: "118%",
}}
>
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,37 +1,228 @@
-// 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 {
+ ACCENTS,
+ 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";
-
-export type ThemeMode = "light" | "dark" | "system";
-
-// 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" },
-];
+// 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 const THEME_MODES: { id: ThemeMode; label: string }[] = [
+// 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";
+
+// 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);
+}
+
+// The accent choices a picker offers on a site whose own accent is
+// `siteAccent`: the seven named accents in brand order, each with its swatch
+// (`--swatch-<id>` is that accent's value on the base in force, so the dot
+// shows what the pick will paint) and a flag on the site's own. A custom-hex
+// site adds its colour first, as "Site colour".
+export type AccentOption = {
+ id: ThemeAccent;
+ label: string;
+ swatch: string;
+ isSiteDefault: boolean;
+};
+
+export function accentOptions(siteAccent: ThemeAccent): AccentOption[] {
+ const named: AccentOption[] = ACCENT_IDS.map((id) => ({
+ id,
+ label: ACCENTS[id].name,
+ swatch: `var(--swatch-${id})`,
+ isSiteDefault: id === siteAccent,
+ }));
+ return siteAccent === "custom"
+ ? [{ id: "custom", label: "Site colour", swatch: "var(--swatch-custom)", isSiteDefault: true }, ...named]
+ : named;
+}
+
+// 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
+// than broken, and only on the base nobody checked. The unit test
+// (themeTokens.test.ts) parses tokens.css against this list; the homepage e2e
+// reads every one off the computed style of each base.
+export const REQUIRED_TOKENS = [
+ "--background",
+ "--foreground",
+ "--card",
+ "--card-foreground",
+ "--popover",
+ "--popover-foreground",
+ "--primary",
+ "--primary-foreground",
+ "--secondary",
+ "--secondary-foreground",
+ "--muted",
+ "--muted-foreground",
+ "--accent",
+ "--accent-foreground",
+ "--destructive",
+ "--destructive-foreground",
+ "--destructive-soft",
+ "--border",
+ "--border-strong",
+ "--input",
+ "--ring",
+ "--surface",
+ "--faint",
+ "--panel",
+ "--panel-2",
+ "--success",
+ "--success-foreground",
+ "--success-soft",
+ "--warning",
+ "--warning-foreground",
+ "--warning-soft",
+ "--info",
+ "--info-foreground",
+ "--info-soft",
+ "--brand",
+ "--brand-strong",
+ "--brand-soft",
+ "--brand-ink",
+ "--state-gone",
+ "--state-gone-soft",
+ "--chart-1",
+ "--chart-2",
+ "--chart-3",
+ "--chart-4",
+ "--chart-5",
+ "--chart-surface",
+ "--chart-grid",
+ "--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){}})();"
+ );
+}
+
diff --git a/common/components/themeTokens.test.ts b/common/components/themeTokens.test.ts
@@ -0,0 +1,245 @@
+// tokens.css completeness and parity. The token sheet is hand-written CSS and
+// lib/brand.ts is the palette as data; this test is what keeps the two equal,
+// and what catches a base that forgets a token (which fails SILENTLY in a
+// browser: it inherits the light block's value, since `:root` always matches).
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import {
+ ACCENTS,
+ ACCENT_IDS,
+ BASE_GROUND_IDS,
+ BASE_GROUNDS,
+ contrastRatio,
+ type BaseGround,
+} from "../lib/brand";
+import { customAccentVars } from "../lib/accent";
+import { REQUIRED_TOKENS } from "./themeConfig";
+
+const CSS = readFileSync(
+ fileURLToPath(new URL("../styles/tokens.css", import.meta.url)),
+ "utf8",
+);
+
+type Rule = { selector: string; decls: Map<string, string>; index: number };
+
+// Top-level rules only (the sheet has no nesting beyond `@theme inline`, which
+// is an at-rule and skipped). Comments are stripped first.
+function parseRules(css: string): Rule[] {
+ const src = css.replace(/\/\*[\s\S]*?\*\//g, "");
+ const rules: Rule[] = [];
+ let i = 0;
+ while (i < src.length) {
+ const open = src.indexOf("{", i);
+ if (open < 0) break;
+ const selector = src.slice(i, open).replace(/^[\s;]*(@import[^;]*;|@custom-variant[^;]*;)?/g, "").trim();
+ let depth = 1;
+ let j = open + 1;
+ while (j < src.length && depth > 0) {
+ if (src[j] === "{") depth++;
+ else if (src[j] === "}") depth--;
+ j++;
+ }
+ const body = src.slice(open + 1, j - 1);
+ const decls = new Map<string, string>();
+ if (!selector.startsWith("@")) {
+ for (const part of body.split(";")) {
+ const k = part.indexOf(":");
+ if (k < 0) continue;
+ const name = part.slice(0, k).trim();
+ if (name) decls.set(name, part.slice(k + 1).trim());
+ }
+ }
+ rules.push({ selector: selector.replace(/\s+/g, " "), decls, index: rules.length });
+ i = j;
+ }
+ return rules;
+}
+
+const RULES = parseRules(CSS);
+
+function rule(selector: string): Rule {
+ const r = RULES.find((x) => x.selector === selector);
+ assert.ok(r, `tokens.css has no rule for ${selector}`);
+ return r;
+}
+
+const BASE_SELECTOR: Record<BaseGround, string> = {
+ light: ':root, html[data-base="light"]',
+ sepia: 'html[data-base="sepia"]',
+ dark: 'html[data-base="dark"]',
+};
+
+const ON: Record<BaseGround, "onLight" | "onSepia" | "onDark"> = {
+ light: "onLight",
+ sepia: "onSepia",
+ dark: "onDark",
+};
+
+test("every base declares every required token, color-scheme and every swatch", () => {
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (const t of REQUIRED_TOKENS) {
+ assert.ok(r.decls.has(t), `${base} is missing ${t}`);
+ assert.notEqual(r.decls.get(t), "", `${base} declares ${t} empty`);
+ }
+ assert.equal(r.decls.get("color-scheme"), base === "dark" ? "dark" : "light");
+ for (const id of ACCENT_IDS) assert.ok(r.decls.has(`--swatch-${id}`), `${base} is missing --swatch-${id}`);
+ assert.ok(r.decls.has("--swatch-custom"), `${base} is missing --swatch-custom`);
+ }
+});
+
+test("REQUIRED_TOKENS is the 49 colour tokens, each once", () => {
+ assert.equal(REQUIRED_TOKENS.length, 49);
+ assert.equal(new Set(REQUIRED_TOKENS).size, REQUIRED_TOKENS.length);
+});
+
+test("--background is each base's ground (lib/brand.ts BASE_GROUNDS)", () => {
+ for (const base of BASE_GROUND_IDS) {
+ assert.equal(rule(BASE_SELECTOR[base]).decls.get("--background"), BASE_GROUNDS[base]);
+ }
+});
+
+test("each base's --swatch-<id> is that accent's value on that ground (ACCENTS)", () => {
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (const id of ACCENT_IDS) {
+ assert.equal(r.decls.get(`--swatch-${id}`), ACCENTS[id][ON[base]], `${base} --swatch-${id}`);
+ }
+ }
+});
+
+test("--swatch-custom reads the inline var customAccentVars writes for that base", () => {
+ const vars = customAccentVars("#cc3366");
+ assert.ok(vars);
+ for (const base of BASE_GROUND_IDS) {
+ const name = `--accent-custom-${base}`;
+ assert.ok(name in vars, `customAccentVars has no ${name}`);
+ assert.equal(
+ rule(BASE_SELECTOR[base]).decls.get("--swatch-custom"),
+ `var(${name}, var(--swatch-signal))`,
+ );
+ }
+});
+
+test("each base defaults --brand to Signal, keeps --primary neutral and rings in the accent", () => {
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ assert.equal(r.decls.get("--brand"), "var(--swatch-signal)");
+ assert.equal(r.decls.get("--ring"), "var(--brand)");
+ assert.doesNotMatch(r.decls.get("--primary") ?? "", /brand|swatch/);
+ assert.equal(r.decls.get("--primary"), base === "dark" ? "#efe7d8" : base === "sepia" ? "#33281a" : "#202a31");
+ assert.equal(r.decls.get("--brand-ink"), base === "dark" ? BASE_GROUNDS.dark : "#ffffff");
+ assert.match(r.decls.get("--brand-strong") ?? "", base === "dark" ? /var\(--brand\).*white/ : /var\(--brand\).*black/);
+ assert.match(r.decls.get("--brand-soft") ?? "", base === "dark" ? /var\(--brand\) 16%/ : /var\(--brand\) 12%/);
+ }
+});
+
+test("an accent rule per id sets --brand to its swatch and --brand-mark to its on-dark value", () => {
+ for (const id of ACCENT_IDS) {
+ const r = rule(`html[data-accent="${id}"]`);
+ assert.equal(r.decls.get("--brand"), `var(--swatch-${id})`);
+ assert.equal(r.decls.get("--brand-mark"), ACCENTS[id].onDark);
+ }
+ const custom = rule('html[data-accent="custom"]');
+ assert.equal(custom.decls.get("--brand"), "var(--swatch-custom)");
+ assert.match(custom.decls.get("--brand-mark") ?? "", /^var\(--accent-custom-dark\b/);
+});
+
+test("the accent rules come after every base block (same specificity, later wins)", () => {
+ const lastBase = Math.max(...BASE_GROUND_IDS.map((b) => rule(BASE_SELECTOR[b]).index));
+ for (const id of [...ACCENT_IDS, "custom"]) {
+ assert.ok(rule(`html[data-accent="${id}"]`).index > lastBase, `${id} precedes a base block`);
+ }
+});
+
+test(":root sets the one radius and a Signal --brand-mark default; .font-display runs wide", () => {
+ const root = rule(":root");
+ assert.equal(root.decls.get("--radius"), "0.375rem");
+ assert.equal(root.decls.get("--brand-mark"), ACCENTS.signal.onDark);
+ assert.equal(rule(".font-display").decls.get("font-stretch"), "112%");
+ // Hinted integer advances split words at DPR ≥ 2 on Linux ("Lig ht").
+ // Form controls too: the UA sheet resets their text-rendering to auto.
+ assert.equal(
+ rule("html, button, input, select, textarea").decls.get("text-rendering"),
+ "geometricPrecision",
+ );
+});
+
+test("no retired theme family is left in the sheet", () => {
+ assert.doesNotMatch(CSS, /data-theme/);
+ assert.doesNotMatch(CSS, /--font-(grotesk|plex|code)/);
+ assert.ok(!RULES.some((r) => r.selector === ".dark"), "a .dark token block is back");
+});
+
+test("charts stay off the accent and off the gone colour", () => {
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (let n = 1; n <= 5; n++) assert.match(r.decls.get(`--chart-${n}`) ?? "", /^#[0-9a-f]{6}$/);
+ // FACTS.md ~6442: chart-3 once WAS the gone hex; the palette itself was
+ // re-validated with the dataviz validator (plans/brand-and-themes.md, S2).
+ assert.notEqual(r.decls.get("--chart-3"), r.decls.get("--state-gone"));
+ assert.notEqual(r.decls.get("--chart-1"), ACCENTS.signal[ON[base]]);
+ assert.notEqual(r.decls.get("--chart-1"), r.decls.get("--swatch-signal"));
+ }
+});
+
+test("text tokens read at 4.5:1 on each base's ground and card", () => {
+ const TEXT = [
+ "--foreground",
+ "--muted-foreground",
+ "--destructive",
+ "--success",
+ "--warning",
+ "--info",
+ "--state-gone",
+ ];
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (const bg of ["--background", "--card"]) {
+ for (const t of TEXT) {
+ const ratio = contrastRatio(r.decls.get(t)!, r.decls.get(bg)!);
+ assert.ok(ratio >= 4.5, `${base}: ${t} on ${bg} is ${ratio.toFixed(2)}:1`);
+ }
+ }
+ const inkOn = contrastRatio(r.decls.get("--primary-foreground")!, r.decls.get("--primary")!);
+ assert.ok(inkOn >= 4.5, `${base}: primary-foreground on primary is ${inkOn.toFixed(2)}:1`);
+ }
+});
+
+// "Soft = a low-alpha tint of the hue for badge/alert fills; the base color is
+// tuned to read as TEXT on that soft fill" (tokens.css @theme). Sepia and dark
+// keep that promise on the ground and on a card. The light block's success and
+// warning (4.18 / 3.89 on their soft fills) predate the base × accent themes
+// and are not covered here.
+function compositeOver(rgba: string, ground: string): string {
+ const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(rgba);
+ assert.ok(m, `not an rgba(): ${rgba}`);
+ const a = Number(m[4]);
+ const g = parseInt(ground.slice(1), 16);
+ const G = [(g >> 16) & 255, (g >> 8) & 255, g & 255];
+ const out = [1, 2, 3].map((i, k) => Math.round(Number(m[i]) * a + G[k] * (1 - a)));
+ return `#${out.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
+}
+
+test("status text reads at 4.5:1 on its own soft fill (sepia and dark)", () => {
+ const PAIRS = [
+ ["--success", "--success-soft"],
+ ["--warning", "--warning-soft"],
+ ["--info", "--info-soft"],
+ ["--destructive", "--destructive-soft"],
+ ["--state-gone", "--state-gone-soft"],
+ ] as const;
+ for (const base of ["sepia", "dark"] as const) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (const bg of ["--background", "--card"]) {
+ for (const [text, soft] of PAIRS) {
+ const fill = compositeOver(r.decls.get(soft)!, r.decls.get(bg)!);
+ const ratio = contrastRatio(r.decls.get(text)!, fill);
+ assert.ok(ratio >= 4.5, `${base}: ${text} on ${soft} over ${bg} is ${ratio.toFixed(2)}:1`);
+ }
+ }
+ }
+});
diff --git a/common/components/ui/badge.tsx b/common/components/ui/badge.tsx
@@ -18,15 +18,17 @@ const badgeVariants = cva(
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
- // Soft-filled semantic tones — colorful but calm, legible across every
- // theme family in light and dark (token hues tuned as text-on-soft).
+ // Soft-filled semantic tones — colorful but calm, legible on every
+ // base (token hues tuned as text-on-soft). The accent reads on its own
+ // tint only as --brand-strong: plain --brand on --brand-soft falls to
+ // ~4:1 on the light and sepia grounds.
success:
"border-success/30 bg-success-soft text-success [a&]:hover:bg-success/15",
warning:
"border-warning/30 bg-warning-soft text-warning [a&]:hover:bg-warning/15",
info: "border-info/30 bg-info-soft text-info [a&]:hover:bg-info/15",
brand:
- "border-brand/30 bg-brand-soft text-brand [a&]:hover:bg-brand/15",
+ "border-brand/30 bg-brand-soft text-brand-strong [a&]:hover:bg-brand/15",
},
},
defaultVariants: {
diff --git a/common/components/ui/sonner.tsx b/common/components/ui/sonner.tsx
@@ -12,13 +12,14 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"
import { useTheme } from "../ThemeProvider"
const Toaster = ({ ...props }: ToasterProps) => {
- // Reuse the family theme controller (light | dark | system) rather than
- // next-themes, so toast chrome tracks the same mode as the rest of the app.
- const { mode } = useTheme()
+ // Reuse the family theme controller rather than next-themes, so toast chrome
+ // tracks the base in force: sepia is a light ground, and "system" is already
+ // resolved (live) by the provider.
+ const { isDark } = useTheme()
return (
<Sonner
- theme={mode as ToasterProps["theme"]}
+ theme={isDark ? "dark" : "light"}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
diff --git a/common/lib/accent.test.ts b/common/lib/accent.test.ts
@@ -6,7 +6,6 @@ import {
parseAccent,
parseAccentSetting,
resolveAccent,
- siteAccentVars,
} from "./accent";
import {
ACCENTS,
@@ -115,18 +114,3 @@ test("customAccentVars: only a custom hex carries inline vars", () => {
"--accent-custom-dark": r.dark,
});
});
-
-test("siteAccentVars (legacy, until S2): a hex as before, an id through its published hex", () => {
- assert.deepEqual(siteAccentVars("#cc3366"), {
- "--brand": "#cc3366",
- "--brand-strong": "#cc3366",
- "--brand-soft": "rgba(204, 51, 102, 0.16)",
- });
- assert.deepEqual(siteAccentVars("brass"), {
- "--brand": "#e3b15c",
- "--brand-strong": "#e3b15c",
- "--brand-soft": "rgba(227, 177, 92, 0.16)",
- });
- assert.equal(siteAccentVars(undefined), null);
- assert.equal(siteAccentVars("gold"), null);
-});
diff --git a/common/lib/accent.ts b/common/lib/accent.ts
@@ -111,8 +111,9 @@ export function accentHex(input: unknown): string | undefined {
}
// The inline `<html style>` a CUSTOM-hex site needs: its fitted value per base,
-// which the token sheet's `--swatch-custom` reads. Null for a named accent or
-// no accent — those are pure CSS (`data-accent`).
+// which the token sheet's `--swatch-custom` reads (export/app/layout.tsx puts it
+// on <html> beside `data-accent="custom"`). Null for a named accent or no
+// accent — those are pure CSS (`data-accent`).
export function customAccentVars(input: unknown): Record<string, string> | null {
const r = resolveAccent(input);
if (r.id !== "custom") return null;
@@ -120,28 +121,3 @@ export function customAccentVars(input: unknown): Record<string, string> | null
for (const base of BASE_GROUND_IDS) vars[`--accent-custom-${base}`] = r[base];
return vars;
}
-
-// LEGACY (deleted by the themes slice, S2): the pre-base×accent override — one
-// colour for both modes as an inline `--brand*` on <html>, applied by
-// export/app/layout.tsx. Takes an id OR a hex: an id paints with its published
-// hex (accentHex), so a site switched to a named accent keeps rendering until
-// the token sheet learns `data-accent`. Null when there is no accent.
-// --brand the color itself (used as the main accent)
-// --brand-strong same color (hover emphasis; a single override can't go both
-// lighter-on-dark AND darker-on-light, so keep it stable)
-// --brand-soft a translucent wash for soft backgrounds (mode-agnostic)
-export function siteAccentVars(
- accent: string | undefined,
-): Record<string, string> | null {
- const hex = accentHex(accent);
- if (!hex) return null;
- const n = parseInt(hex.slice(1), 16);
- const r = (n >> 16) & 255;
- const g = (n >> 8) & 255;
- const b = n & 255;
- return {
- "--brand": hex,
- "--brand-strong": hex,
- "--brand-soft": `rgba(${r}, ${g}, ${b}, 0.16)`,
- };
-}
diff --git a/common/styles/fonts.ts b/common/styles/fonts.ts
@@ -1,93 +1,47 @@
// Shared font source of truth for the whole family. One `next/font/google` call
-// site, exposing CSS-variable tokens consumed by tokens.css's @theme:
-// --font-display (serif: wordmark, headline numerals)
-// --font-sans (body / UI)
-// --font-mono (queries, timestamps, IDs — the "power layer")
+// site, exposing the CSS-variable tokens tokens.css's @theme consumes:
+// --font-display Archivo — the wordmark, headings, headline numerals
+// --font-sans IBM Plex Sans — body and UI
+// --font-mono IBM Plex Mono — queries, timestamps, counts, IDs
//
-// Each app's root layout imports `fontVars` and spreads it onto <html>. This
-// collapses the previous three divergent setups (export=Geist, editor=system,
-// homepage=Source) onto one Adobe Source super-family voice — the DEFAULT/base +
-// archive voice.
+// Three faces, one voice on every base (plans/brand-and-themes.md, "Type and
+// radius"). The theme families that each loaded a voice of their own (Source
+// Serif/Sans/Code, JetBrains Mono) retired with the base × accent themes.
//
-// THEME-FAMILY TYPE VOICES: some families reassign --font-display/sans/mono to a
-// different loaded face (see tokens.css, `html[data-theme="…"]` blocks) so a
-// theme feels distinct beyond color. Those faces are ALSO loaded here (their
-// `.variable` classes must be present on <html> for the @font-face to be active,
-// which is why every face is joined into `fontVars`), but only referenced when a
-// family opts in:
-// • swiss → Archivo (a neo-grotesque; International Typographic Style)
-// • selenized → JetBrains Mono display+mono over IBM Plex Sans body (terminal)
-// • archilyzer → Archivo at 125% width (expanded, poster-scale display) over
-// IBM Plex Sans body + IBM Plex Mono data (the instrument face)
+// Each app's root layout imports `fontVars` and puts it on <html>, so the
+// variables are defined from the root down.
//
// `next/font` is transformed by Next's SWC loader across the whole compiled
// module graph, and `transpilePackages: ["yt-dlp-transcript-common"]` puts this
// module in that graph for every app — so a shared call site works. If a build
// ever balks, the fallback is to duplicate these calls per layout using the same
// variable names; the CSS token contract is the real invariant.
-import {
- Source_Serif_4,
- Source_Sans_3,
- Source_Code_Pro,
- Archivo,
- JetBrains_Mono,
- IBM_Plex_Sans,
- IBM_Plex_Mono,
-} from "next/font/google";
+import { Archivo, IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
-// --- Base / archive voice (the default super-family) ------------------------
-export const fontDisplay = Source_Serif_4({
+// Archivo carries its WIDTH axis: tokens.css runs `.font-display` at
+// `font-stretch: 112%`, and the wordmark (common/components/Wordmark) wider
+// still. Variable weight, so the wordmark's 720 / 380 need no extra files.
+export const fontDisplay = Archivo({
variable: "--font-display",
subsets: ["latin"],
- style: ["normal", "italic"],
-});
-
-export const fontSans = Source_Sans_3({
- variable: "--font-sans",
- subsets: ["latin"],
-});
-
-export const fontMono = Source_Code_Pro({
- variable: "--font-mono",
- subsets: ["latin"],
-});
-
-// --- Per-family voices (opted into by tokens.css [data-theme] blocks) --------
-// Swiss: a neo-grotesque for both display and body — tight, objective, Helvetica-
-// adjacent. Used for --font-display + --font-sans under [data-theme="swiss"].
-// Archilyzer additionally drives Archivo's WIDTH axis, so `font-stretch: 125%`
-// yields a genuinely expanded cut for poster-scale display type — visibly a
-// different animal from the default-width Archivo that swiss uses. Declaring
-// the axis here (rather than loading Archivo a second time) costs swiss
-// nothing: wdth defaults to 100%, so its rendering is unchanged.
-export const fontGrotesk = Archivo({
- variable: "--font-grotesk",
- subsets: ["latin"],
axes: ["wdth"],
});
-// Selenized: a code face for display + mono (the "terminal" voice).
-export const fontCode = JetBrains_Mono({
- variable: "--font-code",
- subsets: ["latin"],
-});
-
-// Selenized + Archilyzer: a humanist sans body. Institutional, built for long
-// documentation — which is half of what the project site is.
-export const fontPlex = IBM_Plex_Sans({
- variable: "--font-plex",
+// A humanist sans built for long documentation — which is most of what the
+// archives and the project site are.
+export const fontSans = IBM_Plex_Sans({
+ variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
+ style: ["normal", "italic"],
});
-// Archilyzer: tabular figures for counts, timecodes and recording states. The
-// data layer is a first-class voice in the instrument face, not an accent.
-export const fontPlexMono = IBM_Plex_Mono({
- variable: "--font-plex-mono",
+// Tabular figures for counts, timecodes and recording states.
+export const fontMono = IBM_Plex_Mono({
+ variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500", "600"],
});
-// Space-joined `.variable` classes for <html className={...}>. EVERY face must be
-// here so its @font-face is registered even if only some families reference it.
-export const fontVars = `${fontDisplay.variable} ${fontSans.variable} ${fontMono.variable} ${fontGrotesk.variable} ${fontCode.variable} ${fontPlex.variable} ${fontPlexMono.variable}`;
+// Space-joined `.variable` classes for <html className={...}>.
+export const fontVars = `${fontDisplay.variable} ${fontSans.variable} ${fontMono.variable}`;
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -4,23 +4,34 @@
Imported by every app's globals.css right after `@import "tailwindcss"`:
@import "../../common/styles/tokens.css";
- Two orthogonal axes:
- • `html[data-theme="…"]` selects the THEME FAMILY (palette personality).
- • `html.dark` selects light/dark MODE within that family.
- The `@custom-variant dark` below makes Tailwind's `dark:` utilities follow the
- `.dark` class (not the OS media query), so a pre-paint script can set both axes
- from localStorage. Default (no data-theme) = the neutral "base" family, which
- reproduces the previous zinc look so editor/export keep visual parity.
-
- TOKEN NAMING (Phase 1 — shadcn kit landed):
+ A reader theme is TWO independent choices (plans/brand-and-themes.md):
+ • `html[data-base="light|sepia|dark"]` selects the BASE — the ground, text,
+ lines, status and chart colours. "System" is not a block: the pre-paint
+ ThemeScript resolves it to light or dark before first paint.
+ • `html[data-accent="<id>|custom"]` selects the ACCENT — `--brand`. Each
+ site server-renders its own; a reader may pick another.
+ `html.dark` is set iff the resolved base is dark. The `@custom-variant dark`
+ below makes Tailwind's `dark:` utilities follow that class (not the OS media
+ query), so sepia styles as a light ground.
+
+ `:root` always matches <html>, so the light block below is also the
+ fallback for a page no script has touched (no-JS, the editor before its
+ script runs). The sepia and dark blocks are `html[data-base=…]` (0,1,1) and
+ win over it; the accent rules come AFTER all three at the same specificity,
+ so a `data-accent` wins `--brand` on every base.
+
+ TOKEN NAMING:
• The shadcn "new-york" contract owns the unprefixed names: --primary,
--secondary, --accent (a NEUTRAL subtle-hover bg), --muted, --card,
--popover, --destructive, --border, --input, --ring, --radius, --chart-*.
- Each has a *-foreground partner where shadcn expects one.
- • The FAMILY BRAND color (brass on archive, blue on base) lives under the
- `--brand*` namespace (--brand, --brand-strong, --brand-soft, --brand-ink)
- so it never collides with shadcn's neutral `--accent`. A per-user/per-site
- accent override (ThemeScript / per-site SSR) sets `--brand`.
+ Each has a *-foreground partner where shadcn expects one. --primary is
+ NEUTRAL on every base (ink on the ground), never the accent.
+ • The ACCENT lives under `--brand*` (--brand, --brand-strong, --brand-soft,
+ --brand-ink, and --brand-mark, the header mark's on-dark value) so it
+ never collides with shadcn's neutral `--accent`.
+ • `--swatch-<id>` is each named accent's value ON THIS BASE (lib/brand.ts
+ ACCENTS — themeTokens.test.ts keeps the two equal), so a picker can
+ show the colour a choice will actually paint.
========================================================================== */
@import "tw-animate-css";
@@ -52,7 +63,7 @@
/* Semantic status — success / warning / info, each with -foreground + -soft.
Soft = a low-alpha tint of the hue for badge/alert fills (like --brand-soft);
- the base color is tuned to read as TEXT on that soft fill in each family. */
+ the base color is tuned to read as TEXT on that soft fill on each base. */
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-soft: var(--success-soft);
@@ -63,16 +74,15 @@
--color-info-foreground: var(--info-foreground);
--color-info-soft: var(--info-soft);
- /* Family brand (brass / blue) — power-layer accent, distinct from shadcn accent */
+ /* The accent (html[data-accent]) — distinct from shadcn's neutral --accent */
--color-brand: var(--brand);
--color-brand-strong: var(--brand-strong);
--color-brand-soft: var(--brand-soft);
--color-brand-ink: var(--brand-ink);
- /* Extra family surfaces/lines (previously only some families defined these and
- none were exposed as utilities). Now every family sets them, so components
- can use `bg-surface`, `border-border-strong`, `text-faint`, `text-brand-ink`
- etc. and stay theme-aware. */
+ /* Extra surfaces/lines. Every base sets them, so components can use
+ `bg-surface`, `border-border-strong`, `text-faint`, `text-brand-ink` etc.
+ and stay theme-aware. */
--color-surface: var(--surface);
--color-border-strong: var(--border-strong);
--color-faint: var(--faint);
@@ -97,232 +107,215 @@
}
/* ---------------------------------------------------------------------------
- Per-family NON-COLOR axes — corner radius + type voice. Colour is the main way
- families differ; these add a second dimension so Swiss/Selenized don't just
- feel like recoloured Archive. Written with `html[data-theme="…"]` (specificity
- 0,1,1) so the --font-* reassignments reliably beat next/font's `.variable`
- class, which sets --font-display/sans/mono on <html> at specificity 0,1,0.
- Archive keeps the default soft radius and the full Source super-family voice.
- Base keeps the soft radius but is all-sans (see below) to match the pre-theme
- export/editor look, which had no serif.
+ Type and radius — one voice on every base. The faces come from
+ common/styles/fonts.ts (next/font sets --font-display/-sans/-mono on <html>):
+ Archivo (display, with its `wdth` axis), IBM Plex Sans (body), IBM Plex Mono
+ (data). The display face runs slightly wide; the wordmark sets its own,
+ wider stretch.
--------------------------------------------------------------------------- */
-/* Base family (no data-theme): all-sans like the pre-theme site. `:not([data-theme])`
- (specificity 0,1,1) targets base only and beats next/font's `.variable` class
- (0,1,0) without leaking into archive, which declares no --font-* and would
- otherwise inherit this from :root. */
-html:not([data-theme]) {
- --font-display: var(--font-sans); /* Source Sans 3 instead of Source Serif 4 */
-}
-html[data-theme="selenized"] {
- --radius: 0.375rem; /* calm, modest rounding — a code editor's corners */
- --font-display: var(--font-code); /* JetBrains Mono wordmark/headings */
- --font-sans: var(--font-plex); /* IBM Plex Sans body */
- --font-mono: var(--font-code);
-}
-html[data-theme="swiss"] {
- --radius: 0px; /* hard 90° corners — International Typographic Style */
- --font-display: var(--font-grotesk); /* Archivo neo-grotesque */
- --font-sans: var(--font-grotesk);
+:root {
+ --radius: 0.375rem;
+ /* The header mark's lit line: the accent's ON-DARK value (the mark sits on an
+ ink tile on every base). Signal until a data-accent rule below says
+ otherwise — the editor renders no data-accent at all. */
+ --brand-mark: #5fa8a0;
}
-html[data-theme="archilyzer"] {
- --radius: 0.125rem; /* 2px — a faceplate's edge break, not a rounded card */
- --font-display: var(--font-grotesk); /* Archivo, widened below */
- --font-sans: var(--font-plex); /* IBM Plex Sans — documentation body */
- --font-mono: var(--font-plex-mono); /* IBM Plex Mono — counts, states, time */
+
+.font-display {
+ font-stretch: 112%;
}
-/* Archivo carries a `wdth` axis (declared in common/styles/fonts.ts). A modest
- widening is what separates this family's headline voice from the same
- neo-grotesque swiss uses at default width. Held at 110% rather than the full
- 125%: at poster sizes the widest cut reads as shouting, and the point of the
- instrument face is composure. Scoped to the display utility so body copy is
- unaffected. */
-html[data-theme="archilyzer"] .font-display {
- font-stretch: 110%;
+
+/* Fractional glyph advances. IBM Plex Sans ships TrueType hinting, and
+ Chromium on Linux (FreeType) applies it at a device pixel ratio of 2 and
+ up, rounding each advance to a whole CSS pixel: at 14px the `g` of "Light"
+ or "Signal" measured 9px against its 7.41px design width, which read as
+ "Lig ht" and "Sig nal". geometricPrecision turns hinting off and lays text
+ out at the design widths. It changes nothing where text is not hinted
+ (macOS, iOS, Android's subpixel layout). Form controls are named too: the
+ UA sheet resets their text-rendering to auto, so a button's "Results" would
+ still read "Res ults". */
+html,
+button,
+input,
+select,
+textarea {
+ text-rendering: geometricPrecision;
}
/* ---------------------------------------------------------------------------
- BASE family (default — no data-theme). Neutral zinc; blue brand accent.
- Reproduces the prior editor/export look so the kit lands at visual parity.
- Chart axis/grid/surface/tooltip tokens live here too (they previously existed
- only on the homepage), so editor/export charts get proper strokes.
+ LIGHT — bone ground, graphite ink. The former Archilyzer light face, with a
+ neutral primary and the accent from data-accent. Also the `:root` fallback.
--------------------------------------------------------------------------- */
-:root {
- --radius: 0.625rem;
+:root,
+html[data-base="light"] {
+ color-scheme: light;
- --background: #ffffff;
- --foreground: #171717;
+ --background: #f3f6f7;
+ --surface: #e8edef;
+ --foreground: #161c21;
--card: #ffffff;
- --card-foreground: #171717;
+ --card-foreground: #161c21;
--popover: #ffffff;
- --popover-foreground: #171717;
- --primary: #18181b;
- --primary-foreground: #fafafa;
- --secondary: #f4f4f5;
- --secondary-foreground: #18181b;
- --muted: #f4f4f5;
- --muted-foreground: #71717a;
- --accent: #f4f4f5;
- --accent-foreground: #18181b;
- --destructive: #dc2626;
- --destructive-foreground: #fafafa;
- --destructive-soft: rgba(220, 38, 38, 0.12);
- --border: #e4e4e7;
- --border-strong: #d4d4d8;
- --input: #e4e4e7;
- --ring: #a1a1aa;
- --surface: #f7f7f8;
- --faint: #a1a1aa;
-
- --success: #15803d;
+ --popover-foreground: #161c21;
+ --primary: #202a31;
+ --primary-foreground: #f3f6f7;
+ --secondary: #e2e8eb;
+ --secondary-foreground: #202a31;
+ --muted: #e8edef;
+ --muted-foreground: #55646e;
+ --accent: #dde5e8;
+ --accent-foreground: #161c21;
+ --destructive: #a8412d;
+ --destructive-foreground: #ffffff;
+ --destructive-soft: rgba(168, 65, 45, 0.12);
+ --border: #d2dade;
+ --border-strong: #aeb9c0;
+ --input: #c4ced4;
+ --ring: var(--brand);
+ --faint: #78868f;
+ --panel: rgba(255, 255, 255, 0.78);
+ --panel-2: rgba(232, 237, 239, 0.78);
+
+ --success: #1f7a4d;
--success-foreground: #ffffff;
- --success-soft: rgba(22, 163, 74, 0.12);
- --warning: #b45309;
+ --success-soft: rgba(31, 122, 77, 0.12);
+ --warning: #96650b;
--warning-foreground: #ffffff;
- --warning-soft: rgba(217, 119, 6, 0.14);
- --info: #1d4ed8;
+ --warning-soft: rgba(150, 101, 11, 0.14);
+ --info: #1c5f96;
--info-foreground: #ffffff;
- --info-soft: rgba(37, 99, 235, 0.12);
+ --info-soft: rgba(28, 95, 150, 0.12);
- --brand: #2563eb;
- --brand-strong: #1d4ed8;
- --brand-soft: rgba(37, 99, 235, 0.12);
+ /* Each named accent's on-light value (lib/brand.ts ACCENTS.onLight). */
+ --swatch-signal: #2e7b73;
+ --swatch-brass: #95661a;
+ --swatch-vermilion: #b3431f;
+ --swatch-violet: #6a4bc4;
+ --swatch-sakura: #a83a6a;
+ --swatch-blue: #2d5fb8;
+ --swatch-green: #3f7a2c;
+ /* A custom-hex site's value fitted to this ground (lib/accent.ts
+ customAccentVars, inline on <html>); Signal if it is missing. */
+ --swatch-custom: var(--accent-custom-light, var(--swatch-signal));
+
+ --brand: var(--swatch-signal);
+ --brand-strong: color-mix(in oklab, var(--brand) 78%, black);
+ --brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
--brand-ink: #ffffff;
- --chart-1: #2563eb;
- --chart-2: #16a34a;
- --chart-3: #ea580c;
- --chart-4: #9333ea;
- --chart-5: #db2777;
+ /* Recording state: "this no longer exists on its platform". Worn only by
+ recordings re-checked and found absent — "kept" is deliberately
+ achromatic, since still being there is the unremarkable case. */
+ --state-gone: #a8412d;
+ --state-gone-soft: rgba(168, 65, 45, 0.12);
+
+ /* Charts do not follow the accent: a fixed categorical order per base, in
+ one hue order everywhere (blue, green, violet, amber, magenta). Validated
+ with the dataviz skill's validate_palette.js on this base's
+ --chart-surface: adjacent pairs pass every check (lightness band, chroma,
+ CVD ΔE ≥ 8, normal-vision ΔE ≥ 15, ≥ 3:1). All pairs pass too, with the
+ non-adjacent green ↔ amber in the CVD 6–8 floor band (6.2): legal only
+ with a secondary encoding, a legend or direct labels. chart-3 is a
+ violet, well apart from --state-gone (ΔE 24.3 normal, 21.7 protan): it
+ once WAS the gone hex, so the third archive in a chart read as "gone".
+ chart-1 is a blue, not Signal (ΔE 17.3 from it). */
+ --chart-1: #3a7de0;
+ --chart-2: #2f8a57;
+ --chart-3: #5e3aa8;
+ --chart-4: #a8741a;
+ --chart-5: #c24a8a;
--chart-surface: #ffffff;
- --chart-grid: rgba(0, 0, 0, 0.08);
- --chart-axis: #71717a;
+ --chart-grid: rgba(22, 28, 33, 0.09);
+ --chart-axis: #55646e;
--chart-tooltip-bg: #ffffff;
-
- /* Recording state: "this no longer exists on its platform". Declared on the
- base family (and on .dark below) rather than in each family block, so every
- family inherits a usable value — a family that redeclares it (archilyzer)
- simply wins on the same element. Without this, switching the project site
- to another family via the ThemeMenu would leave state chips uncoloured. */
- --state-gone: #dc2626;
- --state-gone-soft: rgba(220, 38, 38, 0.12);
-}
-
-.dark {
- --background: #0a0a0a;
- --foreground: #ededed;
- --card: #18181b;
- --card-foreground: #ededed;
- --popover: #18181b;
- --popover-foreground: #ededed;
- --primary: #fafafa;
- --primary-foreground: #18181b;
- --secondary: #27272a;
- --secondary-foreground: #fafafa;
- --muted: #27272a;
- --muted-foreground: #a1a1aa;
- --accent: #27272a;
- --accent-foreground: #fafafa;
- --destructive: #f87171;
- --destructive-foreground: #fafafa;
- --destructive-soft: rgba(248, 113, 113, 0.16);
- --border: #27272a;
- --border-strong: #3f3f46;
- --input: rgba(255, 255, 255, 0.12);
- --ring: #71717a;
- --surface: #141416;
- --faint: #52525b;
-
- --success: #4ade80;
- --success-foreground: #052e16;
- --success-soft: rgba(74, 222, 128, 0.16);
- --warning: #fbbf24;
- --warning-foreground: #2a1a02;
- --warning-soft: rgba(251, 191, 36, 0.16);
- --info: #60a5fa;
- --info-foreground: #04122e;
- --info-soft: rgba(96, 165, 250, 0.16);
-
- --brand: #60a5fa;
- --brand-strong: #93c5fd;
- --brand-soft: rgba(96, 165, 250, 0.16);
- --brand-ink: #04122e;
-
- --chart-1: #60a5fa;
- --chart-2: #4ade80;
- --chart-3: #fb923c;
- --chart-4: #c084fc;
- --chart-5: #f472b6;
- --chart-surface: #0a0a0a;
- --chart-grid: rgba(255, 255, 255, 0.08);
- --chart-axis: #a1a1aa;
- --chart-tooltip-bg: #18181b;
-
- /* See :root — the dark default every family inherits unless it overrides. */
- --state-gone: #f87171;
- --state-gone-soft: rgba(248, 113, 113, 0.16);
}
/* ---------------------------------------------------------------------------
- ARCHIVE family — the public flagship "Reading Room". Brass brand accent.
- Ships paper (light) + ink (dark). The ink values are the homepage's existing
- warm-ink palette, hoisted here verbatim so the homepage is pixel-parity when
- it defaults to data-theme="archive" + dark.
+ SEPIA — warm paper for long reading. New with the base × accent themes; its
+ status, panel and chart colours derive from the retired archive "paper"
+ block, darkened for this darker ground (every text colour ≥ 4.5:1 on it).
--------------------------------------------------------------------------- */
-[data-theme="archive"] {
- /* Paper (light) — warm aged-paper ground, darkened brass for contrast. */
- --background: #faf6ec;
- --surface: #f4ecdb;
- --foreground: #211a0e;
- --card: #fbf7ef;
- --card-foreground: #211a0e;
- --popover: #fbf7ef;
- --popover-foreground: #211a0e;
- --primary: #95661a;
- --primary-foreground: #fff7e7;
- --secondary: #efe6d3;
- --secondary-foreground: #211a0e;
- --muted: #efe6d3;
- --muted-foreground: #6b6250;
- --accent: #efe6d3;
- --accent-foreground: #211a0e;
+html[data-base="sepia"] {
+ color-scheme: light;
+
+ --background: #f4ecd8;
+ --surface: #ece2ca;
+ --foreground: #33281a;
+ --card: #faf4e6;
+ --card-foreground: #33281a;
+ --popover: #faf4e6;
+ --popover-foreground: #33281a;
+ --primary: #33281a;
+ --primary-foreground: #f4ecd8;
+ --secondary: #e9dec3;
+ --secondary-foreground: #33281a;
+ --muted: #ece2ca;
+ --muted-foreground: #6b5c43;
+ --accent: #e4d7b8;
+ --accent-foreground: #33281a;
--destructive: #b3261e;
- --destructive-foreground: #fff7e7;
- --border: rgba(40, 30, 12, 0.12);
- --border-strong: rgba(40, 30, 12, 0.2);
- --input: rgba(40, 30, 12, 0.18);
- --ring: #95661a;
- --faint: #948b78;
- --panel: rgba(255, 252, 244, 0.7);
- --panel-2: rgba(244, 236, 219, 0.7);
-
- --success: #2e7d32;
- --success-foreground: #fff7e7;
- --success-soft: rgba(46, 125, 50, 0.14);
- --warning: #a85b00;
- --warning-foreground: #fff7e7;
- --warning-soft: rgba(168, 91, 0, 0.14);
- --info: #1f6feb;
- --info-foreground: #fff7e7;
- --info-soft: rgba(31, 111, 235, 0.12);
-
- --brand: #95661a;
- --brand-strong: #714d10;
- --brand-soft: rgba(149, 102, 26, 0.14);
- --brand-ink: #fff7e7;
-
- --chart-1: #2563eb;
- --chart-2: #0f9d6b;
- --chart-3: #c2410c;
- --chart-4: #7c3aed;
- --chart-5: #be185d;
- --chart-surface: #fbf7ef;
- --chart-grid: rgba(40, 30, 12, 0.08);
- --chart-axis: #8a7f66;
+ --destructive-foreground: #ffffff;
+ --destructive-soft: rgba(179, 38, 30, 0.12);
+ --border: #dccdaa;
+ --border-strong: #c4b187;
+ --input: #d3c29c;
+ --ring: var(--brand);
+ --faint: #857a64;
+ --panel: rgba(250, 244, 230, 0.72);
+ --panel-2: rgba(236, 226, 202, 0.72);
+
+ /* Each deep enough to read at 4.5:1 on its OWN soft fill over the ground
+ and the card, as the @theme note promises (themeTokens.test.ts). */
+ --success: #256829;
+ --success-foreground: #ffffff;
+ --success-soft: rgba(37, 104, 41, 0.14);
+ --warning: #8c4c00;
+ --warning-foreground: #ffffff;
+ --warning-soft: rgba(140, 76, 0, 0.14);
+ --info: #1858bc;
+ --info-foreground: #ffffff;
+ --info-soft: rgba(24, 88, 188, 0.12);
+
+ /* Each named accent's on-sepia value (lib/brand.ts ACCENTS.onSepia). */
+ --swatch-signal: #2b756e;
+ --swatch-brass: #8e6119;
+ --swatch-vermilion: #b3431f;
+ --swatch-violet: #6a4bc4;
+ --swatch-sakura: #a83a6a;
+ --swatch-blue: #2d5fb8;
+ --swatch-green: #3d772b;
+ --swatch-custom: var(--accent-custom-sepia, var(--swatch-signal));
+
+ --brand: var(--swatch-signal);
+ --brand-strong: color-mix(in oklab, var(--brand) 78%, black);
+ --brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
+ --brand-ink: #ffffff;
+
+ --state-gone: #a3392a;
+ --state-gone-soft: rgba(163, 57, 42, 0.12);
+
+ /* Same hue order as light, stepped for the paper surface. Adjacent pairs
+ pass every check; all pairs pass with green ↔ amber in the CVD floor
+ band (6.1). chart-3 vs --state-gone: ΔE 23.7 normal. */
+ --chart-1: #3574d6;
+ --chart-2: #2a7d4f;
+ --chart-3: #5e3aa8;
+ --chart-4: #a8741a;
+ --chart-5: #bb4585;
+ --chart-surface: #faf4e6;
+ --chart-grid: rgba(51, 40, 26, 0.09);
+ --chart-axis: #6b5c43;
--chart-tooltip-bg: #fffaf0;
}
-[data-theme="archive"].dark {
- /* Ink (dark) — the homepage's warm near-black "archive room". */
+/* ---------------------------------------------------------------------------
+ DARK — warm ink. The former archive "ink" face (the homepage's archive room),
+ with a neutral primary, and the --destructive-soft and --state-gone(-soft)
+ it never declared.
+ --------------------------------------------------------------------------- */
+html[data-base="dark"] {
+ color-scheme: dark;
+
--background: #0c0a08;
--surface: #141009;
--foreground: #efe7d8;
@@ -330,8 +323,8 @@ html[data-theme="archilyzer"] .font-display {
--card-foreground: #efe7d8;
--popover: #1b150d;
--popover-foreground: #efe7d8;
- --primary: #e3b15c;
- --primary-foreground: #1a1305;
+ --primary: #efe7d8;
+ --primary-foreground: #0c0a08;
--secondary: #241d12;
--secondary-foreground: #efe7d8;
--muted: #1c1710;
@@ -340,10 +333,11 @@ html[data-theme="archilyzer"] .font-display {
--accent-foreground: #efe7d8;
--destructive: #f0857a;
--destructive-foreground: #1a1305;
+ --destructive-soft: rgba(240, 133, 122, 0.16);
--border: rgba(233, 220, 197, 0.1);
--border-strong: rgba(233, 220, 197, 0.18);
--input: rgba(233, 220, 197, 0.14);
- --ring: #e3b15c;
+ --ring: var(--brand);
--faint: #6f6757;
--panel: rgba(28, 23, 15, 0.55);
--panel-2: rgba(40, 33, 21, 0.5);
@@ -358,16 +352,32 @@ html[data-theme="archilyzer"] .font-display {
--info-foreground: #04122e;
--info-soft: rgba(110, 168, 255, 0.16);
- --brand: #e3b15c;
- --brand-strong: #f3c977;
- --brand-soft: rgba(227, 177, 92, 0.14);
- --brand-ink: #1a1305;
-
- --chart-1: #6ea8ff;
- --chart-2: #54d6a0;
- --chart-3: #f2935b;
- --chart-4: #c08cf0;
- --chart-5: #f178b6;
+ /* Each named accent's on-dark value (lib/brand.ts ACCENTS.onDark). */
+ --swatch-signal: #5fa8a0;
+ --swatch-brass: #e3b15c;
+ --swatch-vermilion: #ec7a52;
+ --swatch-violet: #b49cf2;
+ --swatch-sakura: #ee8fb5;
+ --swatch-blue: #74a9f2;
+ --swatch-green: #7cc46a;
+ --swatch-custom: var(--accent-custom-dark, var(--swatch-signal));
+
+ --brand: var(--swatch-signal);
+ --brand-strong: color-mix(in oklab, var(--brand) 78%, white);
+ --brand-soft: color-mix(in srgb, var(--brand) 16%, transparent);
+ --brand-ink: #0c0a08;
+
+ --state-gone: #d9644f;
+ --state-gone-soft: rgba(217, 100, 79, 0.12);
+
+ /* Same hue order, stepped into the validator's dark lightness band.
+ Adjacent pairs pass every check; all pairs pass with green ↔ magenta in
+ the CVD floor band (6.9). chart-3 vs --state-gone: ΔE 22.9 normal. */
+ --chart-1: #3561c8;
+ --chart-2: #3fa577;
+ --chart-3: #9a7ee6;
+ --chart-4: #b98a2a;
+ --chart-5: #c24c8a;
--chart-surface: #16110a;
--chart-grid: rgba(233, 220, 197, 0.08);
--chart-axis: #8a8170;
@@ -375,374 +385,44 @@ html[data-theme="archilyzer"] .font-display {
}
/* ---------------------------------------------------------------------------
- SELENIZED family — the Solarized successor (Jan Warchoł's "selenized"). The
- signature warm-tan ground (#fbf3db) / deep teal-slate ground (#103c48) with a
- cyan brand and the calibrated Selenized accent set. A distinct, unmistakable
- color identity; the terminal/code voice (JetBrains Mono + IBM Plex Sans) is
- applied via the per-family type block near the top of this file.
+ ACCENTS — after every base block, at the same specificity, so they win
+ `--brand` whichever base is set. `--brand` reads this base's swatch, so an
+ accent is always its contrast-corrected value for the ground it is on;
+ --brand-strong / -soft / --ring follow it through var(). `--brand-mark` is
+ the on-dark value on every base (the mark sits on an ink tile).
--------------------------------------------------------------------------- */
-[data-theme="selenized"] {
- /* Selenized light — teal-slate ink on the signature warm tan. */
- --background: #fbf3db;
- --surface: #ece3cc;
- --foreground: #3a4d53;
- --card: #fefbf0;
- --card-foreground: #3a4d53;
- --popover: #fefbf0;
- --popover-foreground: #3a4d53;
- --primary: #009c8f;
- --primary-foreground: #fbf3db;
- --secondary: #ece3cc;
- --secondary-foreground: #3a4d53;
- --muted: #ece3cc;
- --muted-foreground: #63757a;
- --accent: #d5cdb6;
- --accent-foreground: #3a4d53;
- --destructive: #d2212d;
- --destructive-foreground: #fbf3db;
- --destructive-soft: rgba(210, 33, 45, 0.12);
- --border: rgba(58, 77, 83, 0.14);
- --border-strong: rgba(58, 77, 83, 0.28);
- --input: rgba(58, 77, 83, 0.18);
- --ring: #009c8f;
- --faint: #909995;
- --panel: rgba(254, 251, 240, 0.7);
- --panel-2: rgba(236, 227, 204, 0.7);
-
- --success: #489100;
- --success-foreground: #fbf3db;
- --success-soft: rgba(72, 145, 0, 0.14);
- --warning: #ad8900;
- --warning-foreground: #fbf3db;
- --warning-soft: rgba(173, 137, 0, 0.14);
- --info: #0072d4;
- --info-foreground: #fbf3db;
- --info-soft: rgba(0, 114, 212, 0.12);
-
- --brand: #009c8f;
- --brand-strong: #00887c;
- --brand-soft: rgba(0, 156, 143, 0.14);
- --brand-ink: #fbf3db;
-
- --chart-1: #009c8f;
- --chart-2: #489100;
- --chart-3: #0072d4;
- --chart-4: #8762c6;
- --chart-5: #c25d1e;
- --chart-surface: #fefbf0;
- --chart-grid: rgba(58, 77, 83, 0.1);
- --chart-axis: #63757a;
- --chart-tooltip-bg: #fefbf0;
+html[data-accent="signal"] {
+ --brand: var(--swatch-signal);
+ --brand-mark: #5fa8a0;
}
-
-[data-theme="selenized"].dark {
- /* Selenized dark — bright cyan/teal on the deep teal-slate ground. */
- --background: #103c48;
- --surface: #0e3640;
- --foreground: #adbcbc;
- --card: #184956;
- --card-foreground: #cad8d9;
- --popover: #1c5162;
- --popover-foreground: #cad8d9;
- --primary: #41c7b9;
- --primary-foreground: #103c48;
- --secondary: #2d5b69;
- --secondary-foreground: #cad8d9;
- --muted: #184956;
- --muted-foreground: #72898f;
- --accent: #2d5b69;
- --accent-foreground: #cad8d9;
- --destructive: #ff665c;
- --destructive-foreground: #103c48;
- --destructive-soft: rgba(250, 87, 80, 0.16);
- --border: rgba(202, 216, 217, 0.12);
- --border-strong: rgba(202, 216, 217, 0.24);
- --input: rgba(202, 216, 217, 0.16);
- --ring: #41c7b9;
- --faint: #5c7278;
- --panel: rgba(24, 73, 86, 0.6);
- --panel-2: rgba(45, 91, 105, 0.5);
-
- --success: #84c747;
- --success-foreground: #0e2f14;
- --success-soft: rgba(132, 199, 71, 0.16);
- --warning: #ebc13d;
- --warning-foreground: #103c48;
- --warning-soft: rgba(235, 193, 61, 0.16);
- --info: #58a3ff;
- --info-foreground: #041022;
- --info-soft: rgba(88, 163, 255, 0.16);
-
- --brand: #41c7b9;
- --brand-strong: #53d6c7;
- --brand-soft: rgba(65, 199, 185, 0.16);
- --brand-ink: #103c48;
-
- --chart-1: #41c7b9;
- --chart-2: #84c747;
- --chart-3: #58a3ff;
- --chart-4: #bd96fa;
- --chart-5: #fd9456;
- --chart-surface: #184956;
- --chart-grid: rgba(202, 216, 217, 0.1);
- --chart-axis: #72898f;
- --chart-tooltip-bg: #1c5162;
+html[data-accent="brass"] {
+ --brand: var(--swatch-brass);
+ --brand-mark: #e3b15c;
}
-
-/* ---------------------------------------------------------------------------
- SWISS family — International Typographic Style. Red + black + white, strong
- hairline rules. Light-first; dark = white-on-black. Pure token swap.
- --------------------------------------------------------------------------- */
-[data-theme="swiss"] {
- /* Light — white ground, black primary, international red brand. */
- --background: #ffffff;
- --surface: #f5f5f5;
- --foreground: #0a0a0a;
- --card: #ffffff;
- --card-foreground: #0a0a0a;
- --popover: #ffffff;
- --popover-foreground: #0a0a0a;
- --primary: #0a0a0a;
- --primary-foreground: #ffffff;
- --secondary: #f0f0f0;
- --secondary-foreground: #0a0a0a;
- --muted: #f0f0f0;
- --muted-foreground: #525252;
- --accent: #ececec;
- --accent-foreground: #0a0a0a;
- --destructive: #b00016;
- --destructive-foreground: #ffffff;
- --border: #d4d4d4;
- --border-strong: #0a0a0a;
- --input: #c8c8c8;
- --ring: #0a0a0a;
- --faint: #8a8a8a;
- --panel: rgba(255, 255, 255, 0.85);
- --panel-2: rgba(245, 245, 245, 0.85);
-
- --success: #1a7d3a;
- --success-foreground: #ffffff;
- --success-soft: rgba(26, 125, 58, 0.12);
- --warning: #9a6700;
- --warning-foreground: #ffffff;
- --warning-soft: rgba(154, 103, 0, 0.12);
- --info: #1d4ed8;
- --info-foreground: #ffffff;
- --info-soft: rgba(29, 78, 216, 0.1);
-
- --brand: #d4001a;
- --brand-strong: #b00016;
- --brand-soft: rgba(212, 0, 26, 0.1);
- --brand-ink: #ffffff;
-
- --chart-1: #d4001a;
- --chart-2: #0a0a0a;
- --chart-3: #6b7280;
- --chart-4: #1d4ed8;
- --chart-5: #b8a589;
- --chart-surface: #ffffff;
- --chart-grid: rgba(10, 10, 10, 0.1);
- --chart-axis: #525252;
- --chart-tooltip-bg: #ffffff;
+html[data-accent="vermilion"] {
+ --brand: var(--swatch-vermilion);
+ --brand-mark: #ec7a52;
}
-
-[data-theme="swiss"].dark {
- /* Dark — black ground, white primary, brighter red brand. */
- --background: #0a0a0a;
- --surface: #141414;
- --foreground: #fafafa;
- --card: #141414;
- --card-foreground: #fafafa;
- --popover: #161616;
- --popover-foreground: #fafafa;
- --primary: #fafafa;
- --primary-foreground: #0a0a0a;
- --secondary: #1f1f1f;
- --secondary-foreground: #fafafa;
- --muted: #1c1c1c;
- --muted-foreground: #a3a3a3;
- --accent: #222222;
- --accent-foreground: #fafafa;
- --destructive: #ff5a6a;
- --destructive-foreground: #0a0a0a;
- --border: rgba(250, 250, 250, 0.14);
- --border-strong: rgba(250, 250, 250, 0.4);
- --input: rgba(250, 250, 250, 0.18);
- --ring: #fafafa;
- --faint: #6b6b6b;
- --panel: rgba(20, 20, 20, 0.6);
- --panel-2: rgba(31, 31, 31, 0.5);
-
- --success: #46c46a;
- --success-foreground: #0a0a0a;
- --success-soft: rgba(70, 196, 106, 0.16);
- --warning: #d9a13a;
- --warning-foreground: #0a0a0a;
- --warning-soft: rgba(217, 161, 58, 0.16);
- --info: #60a5fa;
- --info-foreground: #0a0a0a;
- --info-soft: rgba(96, 165, 250, 0.16);
-
- --brand: #ff2b3e;
- --brand-strong: #ff5a6a;
- --brand-soft: rgba(255, 43, 62, 0.16);
- --brand-ink: #0a0a0a;
-
- --chart-1: #ff2b3e;
- --chart-2: #fafafa;
- --chart-3: #9ca3af;
- --chart-4: #60a5fa;
- --chart-5: #cbb89a;
- --chart-surface: #141414;
- --chart-grid: rgba(250, 250, 250, 0.1);
- --chart-axis: #a3a3a3;
- --chart-tooltip-bg: #161616;
+html[data-accent="violet"] {
+ --brand: var(--swatch-violet);
+ --brand-mark: #b49cf2;
}
-
-/* ---------------------------------------------------------------------------
- ARCHILYZER family — the instrument face. This is the TOOL's voice, and it is
- deliberately not the archives' voice: the warm brass "reading room" belongs
- to the things Archilyzer builds, and dressing the instrument as its own
- output flattens a hierarchy that should read instantly.
-
- THE ARGUMENT, IN TOKENS: chrome is achromatic — graphite and bone, no
- decorative brand hue anywhere. Colour appears in exactly two places and both
- carry information: a recording's STATE (--state-gone, the one hot hue, worn
- only by recordings that are gone), and the archives themselves (each site
- card wears its own accent from site.json). The tool has no colour; the
- recordings do.
-
- --brand is therefore a cool signal, near-unused in chrome. It exists because
- every family must set it — ThemeScript can override it per-user, shadcn
- components reach for it — not because this family wants an accent. CTAs are
- bone-on-graphite solid fills, not coloured buttons.
-
- The ground is a LIFTED cool slate, not near-black: painted equipment under
- light, rather than the void that every "dark mode with an acid accent"
- landing page reaches for.
- --------------------------------------------------------------------------- */
-[data-theme="archilyzer"] {
- /* Light — bone ground, graphite ink. Anodized rather than papery. */
- --background: #f3f6f7;
- --surface: #e8edef;
- --foreground: #161c21;
- --card: #ffffff;
- --card-foreground: #161c21;
- --popover: #ffffff;
- --popover-foreground: #161c21;
- --primary: #202a31;
- --primary-foreground: #f3f6f7;
- --secondary: #e2e8eb;
- --secondary-foreground: #202a31;
- --muted: #e8edef;
- --muted-foreground: #55646e;
- --accent: #dde5e8;
- --accent-foreground: #161c21;
- --destructive: #a8412d;
- --destructive-foreground: #ffffff;
- --destructive-soft: rgba(168, 65, 45, 0.12);
- --border: #d2dade;
- --border-strong: #aeb9c0;
- --input: #c4ced4;
- --ring: #2f7f77;
- --faint: #78868f;
- --panel: rgba(255, 255, 255, 0.78);
- --panel-2: rgba(232, 237, 239, 0.78);
-
- --success: #1f7a4d;
- --success-foreground: #ffffff;
- --success-soft: rgba(31, 122, 77, 0.12);
- --warning: #96650b;
- --warning-foreground: #ffffff;
- --warning-soft: rgba(150, 101, 11, 0.14);
- --info: #1c5f96;
- --info-foreground: #ffffff;
- --info-soft: rgba(28, 95, 150, 0.12);
-
- --brand: #2f7f77;
- --brand-strong: #226058;
- --brand-soft: rgba(47, 127, 119, 0.12);
- --brand-ink: #ffffff;
-
- /* Recording state. The ONLY hues the chrome permits, and they are readable as
- a claim: "gone" is worn exclusively by recordings re-checked and found
- absent from their platform. "kept" is deliberately achromatic — a recording
- still being there is the unremarkable case, and colouring it would imply a
- guarantee nobody re-verified. */
- --state-gone: #a8412d;
- --state-gone-soft: rgba(168, 65, 45, 0.12);
-
- --chart-1: #2f7f77;
- --chart-2: #1c5f96;
- /* --chart-3 is a series hue, not --state-gone: it used to be the same hex,
- so the third archive in a chart read as "gone". A green, lightness-stepped
- away from the gone red so it stays apart under deutan/protan simulation
- too (OKLab dE vs gone 25.9 normal / 11.2 deutan; vs chart-2 26.9, chart-4
- 30.3; 3.29:1 on --chart-surface). */
- --chart-3: #5a9e3a;
- --chart-4: #6a4f9c;
- --chart-5: #8a6a1f;
- --chart-surface: #ffffff;
- --chart-grid: rgba(22, 28, 33, 0.09);
- --chart-axis: #55646e;
- --chart-tooltip-bg: #ffffff;
+html[data-accent="sakura"] {
+ --brand: var(--swatch-sakura);
+ --brand-mark: #ee8fb5;
}
-
-[data-theme="archilyzer"].dark {
- /* Dark — the primary look. Lifted cool slate, bone text, hairline geometry. */
- --background: #151b20;
- --surface: #1c242b;
- --foreground: #e7edf1;
- --card: #1c242b;
- --card-foreground: #e7edf1;
- --popover: #202932;
- --popover-foreground: #e7edf1;
- --primary: #e7edf1;
- --primary-foreground: #151b20;
- --secondary: #242e37;
- --secondary-foreground: #e7edf1;
- --muted: #1c242b;
- --muted-foreground: #a4b3bd;
- --accent: #242e37;
- --accent-foreground: #e7edf1;
- --destructive: #c4553f;
- --destructive-foreground: #f7eae7;
- --destructive-soft: rgba(196, 85, 63, 0.18);
- --border: #2a343c;
- --border-strong: #3d4a54;
- --input: rgba(231, 237, 241, 0.16);
- --ring: #5fa8a0;
- --faint: #8496a2;
- --panel: rgba(28, 36, 43, 0.72);
- --panel-2: rgba(36, 46, 55, 0.6);
-
- --success: #5ab97f;
- --success-foreground: #0c1f14;
- --success-soft: rgba(90, 185, 127, 0.16);
- --warning: #d3a03f;
- --warning-foreground: #1d1605;
- --warning-soft: rgba(211, 160, 63, 0.16);
- --info: #6aa5d8;
- --info-foreground: #071723;
- --info-soft: rgba(106, 165, 216, 0.16);
-
- --brand: #5fa8a0;
- --brand-strong: #7cc2ba;
- --brand-soft: rgba(95, 168, 160, 0.16);
- --brand-ink: #0d1a19;
-
- --state-gone: #c4553f;
- --state-gone-soft: rgba(196, 85, 63, 0.16);
-
- --chart-1: #5fa8a0;
- --chart-2: #6aa5d8;
- /* Same green, dark step (dE vs gone 29.1 normal / 17.1 deutan; vs chart-2
- 20.8, chart-4 26.6; 8.03:1 on --chart-surface). */
- --chart-3: #86c86a;
- --chart-4: #a98ede;
- --chart-5: #d3a03f;
- --chart-surface: #1a2229;
- --chart-grid: rgba(231, 237, 241, 0.08);
- --chart-axis: #8496a2;
- --chart-tooltip-bg: #202932;
+html[data-accent="blue"] {
+ --brand: var(--swatch-blue);
+ --brand-mark: #74a9f2;
+}
+html[data-accent="green"] {
+ --brand: var(--swatch-green);
+ --brand-mark: #7cc46a;
+}
+/* A site whose site.json accent is its own hex: the layout renders
+ `data-accent="custom"` plus the fitted `--accent-custom-light|sepia|dark`
+ inline on <html>. */
+html[data-accent="custom"] {
+ --brand: var(--swatch-custom);
+ --brand-mark: var(--accent-custom-dark, #5fa8a0);
}
diff --git a/common/views/pipeline/tone.ts b/common/views/pipeline/tone.ts
@@ -1,9 +1,9 @@
import type { StageTone } from "./stageStatus";
// The line invents no colours. Every value below is one of the semantic tokens
-// the repo already carries across four theme families × light/dark
-// (common/styles/tokens.css); a bespoke hue here would be wrong in eight
-// palettes at once.
+// the repo already carries across the three bases, light, sepia and dark
+// (common/styles/tokens.css); a bespoke hue here would be wrong on every base
+// at once.
export const STATION_DOT: Record<StageTone, string> = {
// A station with nothing to say is an outline, not a filled dot: "○" in the
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Every page now has a ground and an accent to choose, and the five theme families are gone.** The theme menu (the palette button beside the quick toggle, in the editor's sidebar and in the header of every published site, the hub and the homepage) has two groups. **Base** is System, Light, Sepia or Dark; Sepia is new, a warm paper ground for long reading. **Accent** is Signal, Brass, Vermilion, Violet, Sakura, Blue or Green, with the site's own tagged *default*; a site with a custom hex offers it first as *Site colour*. The quick toggle cycles System → Light → Sepia → Dark. A published site opens on the reader's system setting, in the accent its site form sets. The hub and the homepage open on Dark, in Signal, even with JavaScript off, and the editor follows the system, in Signal. Each accent has a value for each ground that reads at 4.5:1, and a custom hex is darkened or lightened per ground to match. A reader's accent is remembered only while it differs from the site's: picking the site's own again forgets it, so the reader follows the site if its accent changes later. Base, Archive, Selenized, Swiss and Archilyzer are gone. A choice made before this update carries over once: light stays light (Archive light becomes Sepia), dark stays dark and system stays system; the family itself is dropped. Headings are Archivo, text is IBM Plex Sans and figures are IBM Plex Mono everywhere, with one corner radius. Chart colours are fixed per ground and never follow the accent; the third is a violet, well clear of the red that marks a recording as gone. The phone's browser bar takes the page's ground, not the accent. Needs a rebuild and deploy of every site, the hub and the homepage.
- **Every site, the hub, the homepage and the editor wear the new Found-line mark, and a site's header splits its wordmark.** The mark is four transcript lines on a rounded square, the second lit and carrying a play head. The favicon, app icons and touch icon are no longer committed files: each build draws them from the mark and writes `/icons/icon.svg`, `maskable.svg`, `icon-32.png`, `icon-192.png`, `icon-512.png`, `maskable-512.png`, `apple-touch-icon.png` and `/favicon.ico` (16, 32 and 48 px). A site's icons are an ink tile lit with its own accent (Signal when it sets none; a custom colour is lightened until it reads on the tile). The hub, the homepage and the editor use the parent mark, bone on slate. The site header shows the mark and the header title split at the site's **Wordmark lead**, the lead heavy and the rest light (Jer|alyzer); with no lead the whole title is heavy. The header mark's lit line will follow the reader's accent once the theme picker lands; the favicon keeps the site's. The footer's "Built with Archilyzer" has the small parent mark in front of it, outside the link. The homepage header shows the parent mark and "Archi|lyzer", no longer in spaced capitals. An installed site's title bar is the dark theme's ground (`#0c0a08`) and its splash the icon's tile, where it used to fall back to blue for a site with a named accent. The service workers' shell cache is renamed to `shell-v2`, so an installed app drops the old icons; readers' offline channel downloads are kept. The editor's sidebar shows the parent mark beside the admin title, and the editor now has a favicon. Needs a rebuild and deploy of every site, the hub and the homepage.
- **A site's accent is one of seven named colours or a custom one, and a site can split its wordmark.** The site form's **Brand accent** is now a row of swatches: Signal (the family default), Brass, Vermilion, Violet, Sakura, Blue and Green, plus **Custom**, whose colour goes in the **Custom hex** field (typing there picks Custom). `site.json` stores the accent's id (`"accent": "brass"`) or the hex. Picking Signal stores no key, because no key means Signal. A custom colour is darkened or lightened for each reading theme until it reads at 4.5:1; the seven named colours already do. A new **Wordmark lead** field names the heavy first part of the header wordmark, e.g. `Jer` for Jeralyzer. The form refuses a lead that is not how the header title starts, and `site.json` keeps `wordmarkLead` only when it is. What other hubs read does not change: `/site.json`, the hub's `hub-sites.json` and the homepage summary still carry a hex, and an id goes out as its colour on dark. `SITE.md` lists both keys. The header, icons and theme picker that use them come with the rest of the brand work; until then a site with a named accent is tinted with that colour, as a custom hex is today.
- **The hub's search shows each archive's state, lets you choose which archives to search, and no longer waits for the slowest.** Under the line "Searching N archives …" is a row of chips, one per archive on the hub (official and added). Each says whether that archive is loading, how many videos it has in the search once it is in, or that it failed, with a Retry beside it. Pressing a chip takes that archive out of the search: nothing more is fetched from it and its results disappear. The choice is kept in this browser, and an archive it has never seen is searched. The search now runs as soon as one archive has loaded and runs again as each further one arrives, where it used to wait for all of them. When an archive does not answer, a line above the results says so once the others have loaded ("4 of 5 archives answered. Hasanalyzer did not, so its videos are not in these results.") with a Retry, and the other archives' results show as usual. None of that archive's videos, posts or live chat are searched until a Retry succeeds; before, it dropped out silently or in part. Each result names its archive in text before the channel ("Jeralyzer · TheQuartering · 2026-09-25"). The hub now loads at most six summaries pages at a time from each archive, so a large archive does not hold up the small ones. The line under the archives now counts "videos", as the chips do. `/ask` on the hub is unchanged: it searches every archive and waits for all of them. Published sites are unchanged. Needs a rebuild and deploy of the hub.
diff --git a/editor/app/components/SidebarBadges.tsx b/editor/app/components/SidebarBadges.tsx
@@ -24,7 +24,7 @@ export function JobsBadge({ seed }: { seed: number }) {
<span
data-testid="badge-active"
aria-label={`${count} active job${count === 1 ? "" : "s"}`}
- className="ml-auto text-xs rounded-full border border-brand/30 bg-brand-soft text-brand px-2 py-0.5 leading-none"
+ className="ml-auto text-xs rounded-full border border-brand/30 bg-brand-soft text-brand-strong px-2 py-0.5 leading-none"
>
{count}
</span>
diff --git a/editor/app/globals.css b/editor/app/globals.css
@@ -2,10 +2,11 @@
@import "../../common/styles/tokens.css";
@source "../../common/components";
-/* Design tokens, the `dark` variant, and theme palettes live in
- common/styles/tokens.css. The editor runs the neutral "base" family as a
- compact, command-first cockpit (shell + dashboard + palette on the shared
- kit); per-page internals migrate incrementally. */
+/* Design tokens, the `dark` variant, the three bases (light / sepia / dark) and
+ the accents live in common/styles/tokens.css; the faces in
+ common/styles/fonts.ts. The editor follows the operator's system base with
+ the Signal accent, as a compact, command-first cockpit (shell + dashboard +
+ palette on the shared kit); per-page internals migrate incrementally. */
body {
background: var(--background);
diff --git a/editor/e2e/theme.spec.ts b/editor/e2e/theme.spec.ts
@@ -1,57 +1,101 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
import { resetData } from "./helpers";
-// Regression: refreshing the editor must honor the persisted theme mode. The
-// pre-paint <ThemeScript> sets `.dark` on <html>, but <html> is server-rendered
-// with a static className that omits `dark`; the mutation was lost across the
-// hydration boundary and nothing re-asserted it, so the page loaded light until
-// a manual toggle. ThemeProvider now re-applies the persisted theme on mount.
-const THEME_KEY = "ytdlp-tb:theme";
-const MODE_KEY = "ytdlp-tb:mode";
-
-async function seed(
- page: import("@playwright/test").Page,
- values: Record<string, string>,
-) {
+// Regression: refreshing the editor must honor the persisted theme. The
+// pre-paint <ThemeScript> sets `data-base` and `.dark` on <html>, but <html> is
+// server-rendered with a static className that omits `dark`; the mutation was
+// once lost across the hydration boundary and nothing re-asserted it, so the
+// page loaded light until a manual toggle. ThemeProvider re-applies the
+// persisted base on mount.
+//
+// And the one-time migration: the retired `ytdlp-tb:theme` / `ytdlp-tb:mode`
+// keys become a base (archive + light → sepia) and are deleted.
+const BASE_KEY = "ytdlp-tb:base";
+const LEGACY_THEME_KEY = "ytdlp-tb:theme";
+const LEGACY_MODE_KEY = "ytdlp-tb:mode";
+
+async function seed(page: Page, values: Record<string, string>) {
// Need an origin before localStorage is writable.
await page.goto("/");
- await page.evaluate((vals) => {
- for (const [k, v] of Object.entries(vals)) localStorage.setItem(k, v);
- }, values);
+ await page.evaluate(
+ ({ vals, keys }) => {
+ for (const k of keys) localStorage.removeItem(k);
+ for (const [k, v] of Object.entries(vals)) localStorage.setItem(k, v);
+ },
+ { vals: values, keys: [BASE_KEY, LEGACY_THEME_KEY, LEGACY_MODE_KEY] },
+ );
+}
+
+function storage(page: Page) {
+ return page.evaluate(
+ (keys) => keys.map((k) => localStorage.getItem(k)),
+ [BASE_KEY, LEGACY_THEME_KEY, LEGACY_MODE_KEY],
+ );
}
test.beforeEach(async () => {
await resetData("empty");
});
-test("explicit dark mode survives a reload without toggling", async ({
+test("explicit dark base survives a reload without toggling", async ({
page,
}) => {
- await seed(page, { [MODE_KEY]: "dark" });
+ await seed(page, { [BASE_KEY]: "dark" });
+ await page.reload();
+ const html = page.locator("html");
+ await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
+ await expect(html).toHaveAttribute("data-base", "dark");
+});
+
+test("explicit light base loads light", async ({ page }) => {
+ await seed(page, { [BASE_KEY]: "light" });
await page.reload();
- await expect(page.locator("html")).toHaveClass(/(^|\s)dark(\s|$)/);
+ const html = page.locator("html");
+ await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
+ await expect(html).toHaveAttribute("data-base", "light");
});
-test("explicit light mode loads light", async ({ page }) => {
- await seed(page, { [MODE_KEY]: "light" });
+test("sepia loads as a light ground (no .dark) and survives a reload", async ({
+ page,
+}) => {
+ await seed(page, { [BASE_KEY]: "sepia" });
await page.reload();
- await expect(page.locator("html")).not.toHaveClass(/(^|\s)dark(\s|$)/);
+ const html = page.locator("html");
+ await expect(html).toHaveAttribute("data-base", "sepia");
+ await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
});
-test.describe("system mode with OS dark", () => {
+test.describe("system base with OS dark", () => {
test.use({ colorScheme: "dark" });
- test("system mode resolves to dark on reload", async ({ page }) => {
- await seed(page, { [MODE_KEY]: "system" });
+ test("system resolves to dark on reload", async ({ page }) => {
+ await seed(page, { [BASE_KEY]: "system" });
await page.reload();
- await expect(page.locator("html")).toHaveClass(/(^|\s)dark(\s|$)/);
+ const html = page.locator("html");
+ await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
+ await expect(html).toHaveAttribute("data-base", "dark");
});
});
-test("non-base family + dark persist across reload", async ({ page }) => {
- await seed(page, { [MODE_KEY]: "dark", [THEME_KEY]: "selenized" });
+test("migration: a stored selenized + dark becomes the dark base; the old keys go", async ({
+ page,
+}) => {
+ await seed(page, { [LEGACY_MODE_KEY]: "dark", [LEGACY_THEME_KEY]: "selenized" });
await page.reload();
const html = page.locator("html");
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
- await expect(html).toHaveAttribute("data-theme", "selenized");
+ await expect(html).toHaveAttribute("data-base", "dark");
+ await expect(html).not.toHaveAttribute("data-theme", /.*/);
+ expect(await storage(page)).toEqual(["dark", null, null]);
+});
+
+test("migration: a stored archive + light (the paper look) becomes sepia", async ({
+ page,
+}) => {
+ await seed(page, { [LEGACY_MODE_KEY]: "light", [LEGACY_THEME_KEY]: "archive" });
+ await page.reload();
+ const html = page.locator("html");
+ await expect(html).toHaveAttribute("data-base", "sepia");
+ await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
+ expect(await storage(page)).toEqual(["sepia", null, null]);
});
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A reader picks a ground and an accent; the five theme families are gone.** The header's theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (Signal, Brass, Vermilion, Violet, Sakura, Blue, Green, with the site's own tagged *default*, and *Site colour* first on a site with a custom hex). The toggle beside it cycles System → Light → Sepia → Dark; on a phone both lists are in the menu sheet. A site opens on the reader's system setting in the accent from its `site.json`, rendered as `<html data-accent>` so it is right before any script runs; the hub opens on Dark in Signal, already in the server markup. A reader's accent is stored only while it differs from the site's. A stored theme from before carries over once (Archive light becomes Sepia) and the old keys are deleted. Type is Archivo, IBM Plex Sans and IBM Plex Mono; charts have fixed colours per ground; the browser bar follows the ground (a light/dark pair for a site, dark for the hub).
- **The Found-line mark, and a split wordmark.** The site header's rotated square is now the family mark — four transcript lines on an ink tile, the second lit with the site's accent and carrying a play head — and the header title splits at the site's `wordmarkLead` (heavy lead, light rest: Jer|alyzer). The hub wears the parent mark, bone on slate, and splits as Archi|lyzer. The footer credit has the small parent mark before "Built with"; the link's name is still exactly "Archilyzer". The icons are no longer committed: `app/icons/[file]/route.ts` and `app/favicon.ico/route.ts` render them from `common/lib/brand.ts` at build (static export writes `out/icons/*` and `out/favicon.ico`), lit with the site's accent. `<head>` links `icon.svg` first, then `icon-32.png`, 192, 512 and the touch icon. The manifest lists `icon.svg` (sizes "any"); its `theme_color` is the dark base's ground `#0c0a08` and its `background_color` the icon's tile. Both service workers rename only their shell cache (`shell-v2`) so installed apps drop the old icons; the data caches, and readers' offline downloads, are kept.
## [0.8.7] - 2026-08-12
diff --git a/export/app/ask/ContextPanel.tsx b/export/app/ask/ContextPanel.tsx
@@ -48,7 +48,7 @@ export function ContextPanel({
<summary className="flex cursor-pointer items-center gap-2 px-4 py-2.5 text-sm font-medium text-foreground">
Context
{overrideActive && (
- <span className="rounded bg-brand-soft px-1.5 py-0.5 font-mono text-[0.7rem] text-brand">
+ <span className="rounded bg-brand-soft px-1.5 py-0.5 font-mono text-[0.7rem] text-brand-strong">
edited
</span>
)}
diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx
@@ -13,24 +13,26 @@ import {
} from "yt-dlp-transcript-common/components/ui/sheet";
import { useTheme } from "yt-dlp-transcript-common/components/ThemeProvider";
import {
- THEME_FAMILIES,
- THEME_MODES,
- type ThemeFamily,
- type ThemeMode,
+ THEME_BASES,
+ accentOptions,
+ isThemeAccent,
+ isThemeBase,
} from "yt-dlp-transcript-common/components/themeConfig";
import type { SwitcherGroup } from "./SiblingSwitcher";
// The phone half of the masthead. Below `md` the header keeps only the brand,
-// the mode toggle and this trigger; everything else the header offers — the nav,
-// the sibling sites, the hub backlink, the theme family — moves in here, which
-// is also how the Hub backlink and the sites list become reachable on a phone at
-// all (they were `hidden sm:*` and a dropdown too small to hit).
+// the base toggle and this trigger; everything else the header offers — the
+// nav, the sibling sites, the hub backlink, the base and accent pickers —
+// moves in here, which is also how the Hub backlink and the sites list become
+// reachable on a phone at all (they were `hidden sm:*` and a dropdown too small
+// to hit).
//
// Header is a server component, so the link groups arrive as plain serialisable
-// props. The theme lists are read from the client ThemeProvider and rendered as
-// NATIVE radio groups, not by nesting ThemeMenu's DropdownMenu inside this
-// dialog — one popover layer inside another is a focus-trap fight, and the
-// dropdown's `menuitemradio`s are a spec hook that belongs to the md+ header.
+// props. The base and accent lists are read from the client ThemeProvider and
+// rendered as NATIVE radio groups ("Base", "Accent"), not by nesting
+// ThemeMenu's DropdownMenu inside this dialog — one popover layer inside
+// another is a focus-trap fight, and the dropdown's `menuitemradio`s are a spec
+// hook that belongs to the md+ header.
export default function MobileMenu({
links,
sites,
@@ -41,7 +43,7 @@ export default function MobileMenu({
hubUrl?: string;
}) {
const [open, setOpen] = useState(false);
- const { theme, mode, setTheme, setMode } = useTheme();
+ const { base, accent, siteAccent, setBase, setAccent } = useTheme();
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -119,24 +121,28 @@ export default function MobileMenu({
)}
<div className="mt-4 px-2">
- <MenuHeading>Theme</MenuHeading>
+ <MenuHeading>Base</MenuHeading>
<RadioList
- name="mobile-theme-family"
- label="Theme"
- value={theme}
- options={THEME_FAMILIES}
- onPick={(v) => setTheme(v as ThemeFamily)}
+ name="mobile-theme-base"
+ label="Base"
+ value={base}
+ options={THEME_BASES}
+ onPick={(v) => {
+ if (isThemeBase(v)) setBase(v);
+ }}
/>
</div>
<div className="mt-4 px-2">
- <MenuHeading>Mode</MenuHeading>
+ <MenuHeading>Accent</MenuHeading>
<RadioList
- name="mobile-theme-mode"
- label="Mode"
- value={mode}
- options={THEME_MODES}
- onPick={(v) => setMode(v as ThemeMode)}
+ name="mobile-theme-accent"
+ label="Accent"
+ value={accent}
+ options={accentOptions(siteAccent)}
+ onPick={(v) => {
+ if (isThemeAccent(v)) setAccent(v);
+ }}
/>
</div>
</SheetContent>
@@ -162,7 +168,13 @@ function RadioList({
name: string;
label: string;
value: string;
- options: { id: string; label: string }[];
+ options: ReadonlyArray<{
+ id: string;
+ label: string;
+ // An accent's dot (a CSS colour) and whether it is the site's own.
+ swatch?: string;
+ isSiteDefault?: boolean;
+ }>;
onPick: (id: string) => void;
}) {
return (
@@ -180,7 +192,19 @@ function RadioList({
onChange={() => onPick(o.id)}
className="size-4 accent-[var(--brand)]"
/>
+ {o.swatch && (
+ <span
+ aria-hidden="true"
+ className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
+ style={{ background: o.swatch }}
+ />
+ )}
{o.label}
+ {o.isSiteDefault && (
+ <span className="ml-auto font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
+ default
+ </span>
+ )}
</label>
))}
</div>
diff --git a/export/app/components/OfflineManager.tsx b/export/app/components/OfflineManager.tsx
@@ -181,7 +181,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) {
{st.download === "cached" && (
<span
aria-label={`${c.slug} available offline`}
- className="rounded-full border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs text-brand"
+ className="rounded-full border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs text-brand-strong"
>
Available offline
</span>
@@ -246,7 +246,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) {
onClick={() => download(c.slug)}
aria-label={`download ${c.slug} for offline`}
title={!online ? "Reconnect to download" : undefined}
- className="min-h-9 rounded-md border border-brand/30 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand transition-colors hover:bg-brand/15 disabled:opacity-50"
+ className="min-h-9 rounded-md border border-brand/30 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand-strong transition-colors hover:bg-brand/15 disabled:opacity-50"
>
{st.download === "downloading" ? "Downloading…" : "Download"}
</button>
diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx
@@ -5,7 +5,7 @@
// Written as direction — validating → success, or a plain reason it couldn't
// be read. Self-contained: owns its input + status and commits to the shared
// registry. The button is the family's CTA — ink on ground, not a coloured
-// fill (tokens.css, the archilyzer block).
+// fill: foreground on background, whatever the base (tokens.css).
import { useState } from "react";
import {
diff --git a/export/app/components/hub/HubOfflineManager.tsx b/export/app/components/hub/HubOfflineManager.tsx
@@ -130,7 +130,7 @@ export default function HubOfflineManager() {
<button
type="button"
onClick={() => download(c.key)}
- className="shrink-0 rounded border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs font-medium text-brand transition-colors hover:bg-brand/15"
+ className="shrink-0 rounded border border-brand/30 bg-brand-soft px-2 py-0.5 text-xs font-medium text-brand-strong transition-colors hover:bg-brand/15"
>
{st.status === "error" ? "Retry" : "Save"}
</button>
diff --git a/export/app/downloads/page.tsx b/export/app/downloads/page.tsx
@@ -120,7 +120,7 @@ function HoldingRow({ entry }: { entry: ArchiveManifestEntry }) {
<a
href={href}
download
- className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-md bg-brand-soft px-3.5 py-2 font-mono text-sm font-medium text-brand transition-colors hover:bg-brand hover:text-brand-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
+ className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-md bg-brand-soft px-3.5 py-2 font-mono text-sm font-medium text-brand-strong transition-colors hover:bg-brand hover:text-brand-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<ArrowDownToLine className="size-4" aria-hidden />
{humanBytes(entry.bytes)}
diff --git a/export/app/globals.css b/export/app/globals.css
@@ -2,10 +2,11 @@
@import "../../common/styles/tokens.css";
@source "../../common/components";
-/* Design tokens, the `dark` variant, and theme palettes live in
- common/styles/tokens.css. Export defaults to the neutral "base" family (the
- pre-theme zinc/white look, all-sans); the archive/selenized/swiss families
- remain available via the ThemeMenu picker. */
+/* Design tokens, the `dark` variant, the three bases (light / sepia / dark) and
+ the accents live in common/styles/tokens.css; the faces in
+ common/styles/fonts.ts. A site opens on the reader's system base in its own
+ accent (site.json, rendered as `html[data-accent]`); the hub opens dark in
+ Signal. The ThemeMenu picks any base and any accent. */
body {
background: var(--background);
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -3,8 +3,16 @@ import { fontVars } from "yt-dlp-transcript-common/styles/fonts";
import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript";
import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider";
import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
-import { parseAccent, siteAccentVars } from "yt-dlp-transcript-common/lib/accent";
+import {
+ customAccentVars,
+ resolveAccent,
+} from "yt-dlp-transcript-common/lib/accent";
+import { BASE_GROUNDS, DEFAULT_ACCENT } from "yt-dlp-transcript-common/lib/brand";
import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles";
+import type {
+ ThemeAccent,
+ ThemeBase,
+} from "yt-dlp-transcript-common/components/themeConfig";
import { currentSite } from "./lib/site";
import { instanceMode, shipsPwa } from "./lib/mode";
import Header from "./components/Header";
@@ -12,23 +20,29 @@ import Footer from "./components/Footer";
import { ServiceWorkerRegister } from "./components/ServiceWorkerRegister";
import "./globals.css";
-// Browser-chrome themeColor fallback when a site defines no per-site accent.
-// Tracks the base family's brand blue (tokens.css --brand) now that export
-// defaults to base.
-const FALLBACK_THEME_COLOR = "#2563eb";
-
-// The hub is the TOOL, not an archive: it wears the project's own instrument
-// face — the "archilyzer" family, dark by default — exactly as the homepage
-// does (homepage/app/layout.tsx), so the two read as one product. Every site
-// build keeps the base family and the visitor's system mode. The browser chrome
-// colour is the homepage's too (the family's dark ground, tokens.css).
+// The reader theme's starting point (plans/brand-and-themes.md). A site opens
+// on the reader's system base in its OWN accent (site.json `accent`: a named
+// id, or a custom hex fitted to each ground). The hub is the TOOL, not an
+// archive: it opens dark in Signal, the family's own accent, exactly as the
+// homepage does (homepage/app/layout.tsx), so the two read as one product.
+// Either way the reader can pick any base and any accent (ThemeMenu).
// instanceMode() is server-only; this whole file is a server component.
-const HUB_THEME = { defaultTheme: "archilyzer", defaultMode: "dark" } as const;
-const SITE_THEME = { defaultTheme: "base", defaultMode: "system" } as const;
-const HUB_THEME_COLOR = "#151b20";
-
-function themeDefaults() {
- return instanceMode() === "hub" ? HUB_THEME : SITE_THEME;
+function themeDefaults(): {
+ defaultBase: ThemeBase;
+ accent: ThemeAccent;
+ // The inline `--accent-custom-light|sepia|dark` a custom-hex site needs
+ // (tokens.css `--swatch-custom` reads them); null for a named accent.
+ accentVars: Record<string, string> | null;
+} {
+ if (instanceMode() === "hub") {
+ return { defaultBase: "dark", accent: DEFAULT_ACCENT, accentVars: null };
+ }
+ const setting = currentSite().accent;
+ return {
+ defaultBase: "system",
+ accent: resolveAccent(setting).id,
+ accentVars: customAccentVars(setting),
+ };
}
export function generateMetadata(): Metadata {
@@ -56,10 +70,16 @@ export function generateMetadata(): Metadata {
export function generateViewport(): Viewport {
return {
- // Browser chrome color tracks the site's brand accent (falls back to base brand).
+ // Browser chrome continues the page's GROUND, not its accent. A site
+ // follows the OS (a light/dark pair), the hub opens dark; the pre-paint
+ // ThemeScript then points every theme-color meta at the base in force.
themeColor:
- parseAccent(currentSite().accent) ??
- (instanceMode() === "hub" ? HUB_THEME_COLOR : FALLBACK_THEME_COLOR),
+ instanceMode() === "hub"
+ ? BASE_GROUNDS.dark
+ : [
+ { media: "(prefers-color-scheme: light)", color: BASE_GROUNDS.light },
+ { media: "(prefers-color-scheme: dark)", color: BASE_GROUNDS.dark },
+ ],
width: "device-width",
initialScale: 1,
// Let the layout run under the notch/home indicator; the safe-area insets
@@ -78,26 +98,34 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- // Per-site accent: bake the brand-color override onto <html> at prerender so
- // it's present on first paint (before any CSS/JS). Unset → family brass.
- const accentVars = siteAccentVars(currentSite().accent);
+ // The accent is baked onto <html> at prerender, so it is right on first
+ // paint before any script: `data-accent` selects the tokens.css rule, and a
+ // custom hex adds its fitted per-base values inline. A reader's stored pick
+ // replaces `data-accent` pre-paint (ThemeScript). The hub's dark base is
+ // server-rendered too (`.dark` + data-base), as on the homepage; a site's
+ // "system" base cannot be resolved on the server, so it renders none and
+ // the pre-paint script sets it.
const theme = themeDefaults();
+ const serverDark = theme.defaultBase === "dark";
return (
<html
lang="en"
suppressHydrationWarning
- className={`${fontVars} h-full antialiased`}
- style={accentVars ?? undefined}
+ className={
+ serverDark
+ ? `${fontVars} h-full antialiased dark`
+ : `${fontVars} h-full antialiased`
+ }
+ data-base={serverDark ? "dark" : undefined}
+ data-accent={theme.accent}
+ style={(theme.accentVars as React.CSSProperties | null) ?? undefined}
>
<body className="min-h-full flex flex-col bg-background text-foreground font-sans">
- <ThemeScript
- defaultTheme={theme.defaultTheme}
- defaultMode={theme.defaultMode}
- />
+ <ThemeScript defaultBase={theme.defaultBase} />
{shipsPwa() && <ServiceWorkerRegister />}
<ThemeProvider
- defaultTheme={theme.defaultTheme}
- defaultMode={theme.defaultMode}
+ defaultBase={theme.defaultBase}
+ siteAccent={theme.accent}
>
<QueryProvider>
<Header />
diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts
@@ -17,8 +17,9 @@ export function iconPalette(): IconPalette {
// The header mark's palette. On a site it is the icon's ink tile, but its lit
// line follows the READER's accent — `--brand-mark` is the picked accent's
// on-dark value (the tile is always ink) — while the favicon keeps the site's
-// default. Until the base × accent tokens define `--brand-mark`, it falls back
-// to `--brand`. The hub's mark is the parent mark and does not follow.
+// default. tokens.css defines `--brand-mark` on every page (a Signal default on
+// `:root`, then each `html[data-accent]` rule); the `--brand` fallback is only a
+// guard. The hub's mark is the parent mark and does not follow.
export function headerMarkPalette(): BrandMarkPalette {
return instanceMode() === "hub"
? ICON_PALETTES.archilyzer
diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts
@@ -90,9 +90,10 @@ test.describe("phone layout", () => {
const menu = page.getByRole("dialog");
await expect(menu.getByRole("link", { name: "Ask AI" })).toBeVisible();
await expect(menu.getByRole("link", { name: "Search" })).toBeVisible();
- // The theme picker is a dropdown in the wide header; here it is a plain
- // radio list, because a popover inside a dialog is a focus-trap fight.
- await expect(menu.getByRole("radio", { name: "Archive" })).toBeVisible();
+ // The theme picker is a dropdown in the wide header; here it is two plain
+ // radio lists (Base, Accent), because a popover inside a dialog is a
+ // focus-trap fight.
+ await expect(menu.getByRole("radio", { name: "Sepia" })).toBeVisible();
});
test("the filters sheet applies a filter and the results change", async ({
diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts
@@ -81,15 +81,31 @@ test("the Downloads eyebrow appears only when something is behind it", async ({
}
});
-test("per-site accent is baked onto <html> as a brand override", async ({
+test("per-site accent is baked onto <html> as data-accent", async ({
page,
}) => {
await installRoutes(page);
+ await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
- // site.json sets accent #cc3366; the layout emits it as an inline --brand
- // override on <html> at prerender (overriding the family brass).
- await expect(page.locator("html")).toHaveAttribute(
- "style",
- /--brand:\s*#cc3366/,
- );
+ // site.json sets the custom hex #cc3366: the layout renders
+ // data-accent="custom" plus the hex fitted to each base as inline
+ // --accent-custom-* vars at prerender, so it is right on first paint.
+ // The live attribute below is also written by ThemeProvider, so it cannot
+ // tell the server's markup apart; the raw HTML can.
+ const served = await (await page.request.get("/")).text();
+ expect(served).toMatch(/<html[^>]*\sdata-accent="custom"/);
+ expect(served).toMatch(/<html[^>]*--accent-custom-light:\s*#cc3366/);
+ const html = page.locator("html");
+ await expect(html).toHaveAttribute("data-accent", "custom");
+ await expect(html).toHaveAttribute("style", /--accent-custom-light:\s*#cc3366/);
+ // #cc3366 already reads at 4.5:1 on the light ground, so it is kept as is.
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ getComputedStyle(document.documentElement)
+ .getPropertyValue("--brand")
+ .trim(),
+ ),
+ )
+ .toBe("#cc3366");
});
diff --git a/export/e2e/theme-accent.spec.ts b/export/e2e/theme-accent.spec.ts
@@ -0,0 +1,141 @@
+import { test, expect, type Page } from "@playwright/test";
+import { ACCENTS } from "../../common/lib/brand";
+import { resolveAccent } from "../../common/lib/accent";
+
+// The ThemeMenu's two radio groups. A site opens in its OWN accent (the
+// fixture's site.json sets the custom hex #cc3366, so the layout renders
+// data-accent="custom" and the menu offers "Site colour" first, tagged
+// "default"). A reader's pick of a named accent persists and is applied before
+// paint; picking the site's own again REMOVES the stored key, so the reader
+// follows the site from then on. The base group sits in the same menu.
+
+const ACCENT_KEY = "ytdlp-tb:accent";
+const FIXTURE = resolveAccent("#cc3366"); // {id: "custom", light, sepia, dark}
+
+async function state(page: Page) {
+ return page.evaluate((key) => {
+ const d = document.documentElement;
+ const cs = getComputedStyle(d);
+ return {
+ accent: d.getAttribute("data-accent"),
+ base: d.getAttribute("data-base"),
+ stored: localStorage.getItem(key),
+ brand: cs.getPropertyValue("--brand").trim(),
+ background: cs.getPropertyValue("--background").trim(),
+ };
+ }, ACCENT_KEY);
+}
+
+// Open the menu, absorbing a pre-hydration lost click on the trigger.
+async function openMenu(page: Page) {
+ const trigger = page.getByRole("button", { name: "Choose theme" });
+ const menu = page.getByRole("menu");
+ await expect(async () => {
+ if (!(await menu.isVisible())) await trigger.click();
+ await expect(menu).toBeVisible({ timeout: 1_000 });
+ }).toPass({ timeout: 10_000 });
+ return menu;
+}
+
+// An item's accessible name is its label, plus "default" on the site's own
+// accent (the visible tag) — so match the label at the start.
+async function pick(page: Page, label: string) {
+ const menu = await openMenu(page);
+ await menu.getByRole("menuitemradio", { name: new RegExp(`^${label}\\b`) }).click();
+ await expect(menu).toBeHidden();
+}
+
+test("the menu offers four bases and the site's colour first, then the seven accents", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ const menu = await openMenu(page);
+
+ const base = menu.getByRole("group", { name: "Base" });
+ for (const name of ["System", "Light", "Sepia", "Dark"]) {
+ await expect(base.getByRole("menuitemradio", { name, exact: true })).toBeVisible();
+ }
+ await expect(base.getByRole("menuitemradio", { name: "System", exact: true })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+
+ const accent = menu.getByRole("group", { name: "Accent" });
+ const items = accent.getByRole("menuitemradio");
+ await expect(items).toHaveCount(8);
+ // The custom-hex site's own colour comes first, checked, with its tag.
+ await expect(items.first()).toHaveText(/Site colour\s*default/);
+ await expect(items.first()).toHaveAttribute("aria-checked", "true");
+ for (const a of Object.values(ACCENTS)) {
+ await expect(accent.getByRole("menuitemradio", { name: a.name, exact: true })).toBeVisible();
+ }
+});
+
+test("pick Violet: it persists before paint, and --brand is violet on each base", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "custom",
+ stored: null,
+ brand: FIXTURE.light,
+ });
+
+ await pick(page, "Violet");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "violet",
+ stored: "violet",
+ base: "light",
+ brand: ACCENTS.violet.onLight,
+ });
+
+ // The reload applies the pick on the first commit, before React hydrates —
+ // and hydration does not put the site's own accent back.
+ await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
+ expect((await state(page)).accent).toBe("violet");
+ await page.waitForLoadState("load");
+ await expect.poll(() => state(page)).toMatchObject({ accent: "violet", brand: ACCENTS.violet.onLight });
+
+ // Sepia, from the same menu: the ground changes and the accent keeps its
+ // on-sepia value.
+ await pick(page, "Sepia");
+ await expect.poll(() => state(page)).toMatchObject({
+ base: "sepia",
+ accent: "violet",
+ background: "#f4ecd8",
+ brand: ACCENTS.violet.onSepia,
+ });
+ expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:base"))).toBe("sepia");
+
+ await pick(page, "Dark");
+ await expect.poll(() => state(page)).toMatchObject({
+ base: "dark",
+ background: "#0c0a08",
+ brand: ACCENTS.violet.onDark,
+ });
+});
+
+test("picking the site's own colour again removes the stored accent", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ await pick(page, "Brass");
+ await expect.poll(() => state(page)).toMatchObject({ accent: "brass", stored: "brass" });
+
+ await pick(page, "Site colour");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "custom",
+ stored: null,
+ brand: FIXTURE.light,
+ });
+
+ // With nothing stored, the site's colour is fitted to each ground.
+ await pick(page, "Sepia");
+ await expect.poll(() => state(page)).toMatchObject({ base: "sepia", brand: FIXTURE.sepia });
+ await pick(page, "Dark");
+ await expect.poll(() => state(page)).toMatchObject({ base: "dark", brand: FIXTURE.dark });
+});
diff --git a/export/e2e/theme-family.spec.ts b/export/e2e/theme-family.spec.ts
@@ -1,51 +0,0 @@
-import { test, expect, type Page } from "@playwright/test";
-
-// Phase 5: the ThemeMenu family picker. Each family is a pure CSS token swap, so
-// picking one only sets data-theme on <html> (persisted, re-applied pre-paint).
-
-async function familyState(page: Page) {
- return page.evaluate(() => ({
- theme: document.documentElement.getAttribute("data-theme"),
- persisted: localStorage.getItem("ytdlp-tb:theme"),
- }));
-}
-
-async function pickFamily(page: Page, label: string) {
- await page.getByRole("button", { name: "Choose theme" }).click();
- await page.getByRole("menuitemradio", { name: label }).click();
-}
-
-test("theme-family picker switches + persists data-theme with no FOUC", async ({
- page,
-}) => {
- await page.goto("/");
- // Export defaults to the base family (no data-theme attribute).
- expect((await familyState(page)).theme).toBe(null);
- await expect(
- page.getByRole("button", { name: "Choose theme" }),
- ).toBeVisible();
-
- await pickFamily(page, "Selenized");
- let s = await familyState(page);
- expect(s.theme).toBe("selenized");
- expect(s.persisted).toBe("selenized");
-
- // Reload: the pre-paint script re-applies data-theme before React hydrates.
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(
- () => document.documentElement.dataset.themeReady === "1",
- );
- expect(
- await page.evaluate(() =>
- document.documentElement.getAttribute("data-theme"),
- ),
- ).toBe("selenized");
-
- await pickFamily(page, "Swiss");
- s = await familyState(page);
- expect(s.theme).toBe("swiss");
- expect(s.persisted).toBe("swiss");
-
- await pickFamily(page, "Archive");
- expect((await familyState(page)).theme).toBe("archive");
-});
diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts
@@ -1,71 +1,115 @@
-import { test, expect, type Page } from "@playwright/test";
+import { test, expect, type Locator, type Page } from "@playwright/test";
-// The export site defaults to the neutral "base" family (the pre-theme
-// zinc/white look) with a system-default mode. Base removes the data-theme
-// attribute (ThemeScript), so it reads as null; the mode toggle must apply,
-// persist across reloads, and be set before hydration.
+// A published site opens on the reader's SYSTEM base (tokens.css resolves it
+// to the light or dark block) and follows the OS live. The base toggle cycles
+// system → light → sepia → dark → system; every choice persists across a
+// reload and is applied by the pre-paint ThemeScript before hydration — the
+// reload waits on `data-theme-ready`, which the script sets last.
+
+const BASE_KEY = "ytdlp-tb:base";
async function state(page: Page) {
- return page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
- mode: localStorage.getItem("ytdlp-tb:mode"),
- }));
+ return page.evaluate((key) => {
+ const d = document.documentElement;
+ return {
+ base: d.getAttribute("data-base"),
+ dark: d.classList.contains("dark"),
+ stored: localStorage.getItem(key),
+ background: getComputedStyle(d).getPropertyValue("--background").trim(),
+ };
+ }, BASE_KEY);
}
-async function cycleTo(page: Page, target: "dark" | "light") {
- const toggle = page.getByRole("button", { name: /switch to/i });
- for (let i = 0; i < 3; i++) {
- if ((await state(page)).mode === target) break;
- await toggle.click();
- }
+// A click before hydration is lost (the button is server-rendered inert), so
+// click until the stored base is the one wanted. A click React did take is
+// committed synchronously (a discrete event), so the check right after it
+// never races a taken click into a second one.
+async function toggleTo(page: Page, toggle: Locator, want: string | null) {
+ await expect(async () => {
+ if ((await state(page)).stored !== want) await toggle.click();
+ expect((await state(page)).stored).toBe(want);
+ }).toPass({ timeout: 10_000 });
+}
+
+async function reloadOnCommit(page: Page) {
+ await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(
+ () => document.documentElement?.dataset.themeReady === "1",
+ );
}
-test("base family default; mode toggle applies + persists with no FOUC", async ({
+test("a site opens on the system base and follows the OS live", async ({
page,
}) => {
+ await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
- // Default base family => no data-theme attribute on <html>.
- expect((await state(page)).theme).toBe(null);
- await expect(page.getByRole("button", { name: /switch to/i })).toBeVisible();
+ await expect
+ .poll(() => state(page))
+ .toEqual({ base: "light", dark: false, stored: null, background: "#f3f6f7" });
+ await expect(page.getByRole("button", { name: "Switch to light" })).toBeVisible();
- // Explicit dark, then reload-persists before hydration.
- await cycleTo(page, "dark");
- let s = await state(page);
- expect(s.mode).toBe("dark");
- expect(s.dark).toBe(true);
- expect(s.theme).toBe(null);
+ // The OS flips while the page is open: "system" follows without a reload.
+ await page.emulateMedia({ colorScheme: "dark" });
+ await expect
+ .poll(() => state(page))
+ .toEqual({ base: "dark", dark: true, stored: null, background: "#0c0a08" });
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(
- () => document.documentElement.dataset.themeReady === "1",
- );
- let onCommit = await page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
- mode: localStorage.getItem("ytdlp-tb:mode"),
- }));
- expect(onCommit.mode).toBe("dark");
- expect(onCommit.dark).toBe(true);
- expect(onCommit.theme).toBe(null);
+ await page.emulateMedia({ colorScheme: "light" });
+ await expect.poll(async () => (await state(page)).base).toBe("light");
+});
- // Explicit light, then reload-persists.
- await cycleTo(page, "light");
- s = await state(page);
- expect(s.mode).toBe("light");
- expect(s.dark).toBe(false);
+test("the toggle cycles the four bases; each persists across a reload with no flash", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ const toggle = page.getByRole("button", { name: /switch to/i });
+ await expect(toggle).toBeVisible();
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(
- () => document.documentElement.dataset.themeReady === "1",
- );
- onCommit = await page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
+ const steps = [
+ { stored: "light", base: "light", dark: false, background: "#f3f6f7", next: "Switch to sepia" },
+ { stored: "sepia", base: "sepia", dark: false, background: "#f4ecd8", next: "Switch to dark" },
+ { stored: "dark", base: "dark", dark: true, background: "#0c0a08", next: "Switch to system" },
+ // Back to system: stored as "system", resolved against the (light) OS.
+ { stored: "system", base: "light", dark: false, background: "#f3f6f7", next: "Switch to light" },
+ ];
+
+ for (const step of steps) {
+ await toggleTo(page, toggle, step.stored);
+ const s = await state(page);
+ expect(s.base, `after ${step.stored}`).toBe(step.base);
+ expect(s.dark, `after ${step.stored}`).toBe(step.dark);
+ expect(s.background, `after ${step.stored}`).toBe(step.background);
+ await expect(page.getByRole("button", { name: step.next })).toBeVisible();
+
+ // The pre-paint script re-applies it on the very first commit, before
+ // React hydrates.
+ await reloadOnCommit(page);
+ const onCommit = await state(page);
+ expect(onCommit.stored, `reload after ${step.stored}`).toBe(step.stored);
+ expect(onCommit.base, `reload after ${step.stored}`).toBe(step.base);
+ expect(onCommit.dark, `reload after ${step.stored}`).toBe(step.dark);
+ }
+});
+
+test("the retired theme/mode keys migrate once, before paint", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "dark" });
+ await page.goto("/");
+ await page.evaluate(() => {
+ localStorage.removeItem("ytdlp-tb:base");
+ localStorage.setItem("ytdlp-tb:theme", "archive");
+ localStorage.setItem("ytdlp-tb:mode", "light");
+ });
+ await reloadOnCommit(page);
+ const s = await page.evaluate(() => ({
+ base: document.documentElement.getAttribute("data-base"),
+ stored: localStorage.getItem("ytdlp-tb:base"),
+ theme: localStorage.getItem("ytdlp-tb:theme"),
mode: localStorage.getItem("ytdlp-tb:mode"),
}));
- expect(onCommit.mode).toBe("light");
- expect(onCommit.dark).toBe(false);
- expect(onCommit.theme).toBe(null);
+ // archive + light was the paper look: it becomes sepia, and the old keys go.
+ expect(s).toEqual({ base: "sepia", stored: "sepia", theme: null, mode: null });
});
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -2,6 +2,13 @@
## [Unreleased]
+- **The homepage opens on the Dark ground in Signal, even with JavaScript off, and a reader can pick another ground or accent.**
+ The theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (the seven named
+ accents, Signal tagged *default*); the toggle cycles System → Light → Sepia → Dark. The old
+ Archilyzer theme family is gone, and so are the other four: the dark ground is now the warm ink
+ the archives used. Type is Archivo, IBM Plex Sans and IBM Plex Mono. The chart's third colour
+ is a violet, well clear of the "gone" red. A stored theme from before carries over once. The
+ docs' *Operate* page describes the accent and the reader's menu.
- **The Found-line mark.** The header's CSS triangle is now the family's parent mark (bone
on slate) and "ARCHILYZER" is the split wordmark "Archi|lyzer" — heavy lead, light
suffix, no longer tracked uppercase; the link is still named "Archilyzer home". The
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -28,13 +28,16 @@ import {
//
// COLOUR follows the instance, never its rank on this chart: seriesColor(i) with
// i = the site's position in `sites`, the same index the instance cards use.
-// The STACK order is different on purpose: the family palette's first two
-// slots (teal, blue) are too close to share an edge, so the layers are
-// interleaved (0, 2, 1, 4, 3) to keep every touching pair distinguishable.
+// The layers stack in that same order (0..4): the per-base --chart-1..5
+// palette (tokens.css: blue, green, violet, amber, magenta) is validated for
+// ADJACENT pairs in exactly this order, so every touching pair clears the
+// dataviz validator on every base. It used to be interleaved (0, 2, 1, 4, 3)
+// for the old family palette, whose first two slots were too close; with the
+// new order that interleave put green against magenta, in the CVD floor band.
const W = 1000;
const H = 300;
-const STACK_ORDER = [0, 2, 1, 4, 3];
+const STACK_ORDER = [0, 1, 2, 3, 4];
function stackOrder(n: number): number[] {
const head = STACK_ORDER.filter((i) => i < n);
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -3,10 +3,11 @@
@source "../../common/components";
/* This is the PROJECT's site — the instrument, not one of the archives it
- builds. It commits to the "archilyzer" family (graphite chrome, achromatic,
- colour reserved for recording state and for the archives' own accents).
- Palettes and the `dark` variant live in common/styles/tokens.css; only the
- faceplate geometry and the page-load reveal remain here.
+ builds. It opens on the dark base in Signal, the family's own accent, and
+ keeps its chrome achromatic: colour is reserved for recording state and for
+ the archives' own accents. The bases, the accents and the `dark` variant
+ live in common/styles/tokens.css; only the faceplate geometry and the
+ page-load reveal remain here.
The warm radial glow + film grain that used to live here is deliberately
GONE. It was archive-room dressing, correct back when this page was a shelf
diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { fontVars } from "yt-dlp-transcript-common/styles/fonts";
import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript";
import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider";
+import { BASE_GROUNDS, DEFAULT_ACCENT } from "yt-dlp-transcript-common/lib/brand";
import {
PROJECT_NAME,
PROJECT_TAGLINE,
@@ -12,11 +13,6 @@ import Header from "./components/Header";
import Footer from "./components/Footer";
import "./globals.css";
-// Browser-chrome color: the archilyzer family's dark ground (tokens.css). Not
-// an accent — this family has no decorative brand hue, and the phone's chrome
-// should continue the faceplate rather than announce a colour.
-const THEME_COLOR = "#151b20";
-
// PRODUCT identity, not the operator's. This used to read `currentHomepage()`,
// the editable config in sites/_homepage/homepage.json — correct when this was
// "our hub", wrong now that it is the software's own site. One visible
@@ -49,8 +45,10 @@ export const metadata: Metadata = {
twitter: { card: "summary" },
};
+// Browser chrome continues the page's ground (the dark base the site opens
+// on), not an accent; the pre-paint ThemeScript follows a reader's other base.
export function generateViewport(): Viewport {
- return { themeColor: THEME_COLOR };
+ return { themeColor: BASE_GROUNDS.dark };
}
export default function RootLayout({
@@ -59,14 +57,23 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
+ // Dark is rendered on the server too (`.dark` + data-base), so a reader
+ // with no JS, or before the pre-paint script, gets this site's own base
+ // rather than the light `:root` fallback. ThemeScript then applies a
+ // reader's stored base before paint, and ThemeProvider re-asserts it after
+ // hydration (which does not patch <html>'s attributes).
<html
lang="en"
suppressHydrationWarning
- className={`${fontVars} h-full antialiased`}
+ className={`${fontVars} h-full antialiased dark`}
+ data-base="dark"
+ data-accent={DEFAULT_ACCENT}
>
<body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--brand-soft)] selection:text-[var(--foreground)]">
- <ThemeScript defaultTheme="archilyzer" defaultMode="dark" />
- <ThemeProvider defaultTheme="archilyzer" defaultMode="dark">
+ {/* The project's own site opens on the dark base in Signal, the
+ family's accent; a reader can pick any other. */}
+ <ThemeScript defaultBase="dark" />
+ <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT}>
<Header />
<main className="flex-1 w-full">{children}</main>
<Footer />
diff --git a/homepage/content/docs/operate.md b/homepage/content/docs/operate.md
@@ -81,9 +81,11 @@ editor. See [Deploy to Cloudflare](/docs/deploy-cloudflare/) for hosting, and
## Sites, groups, and one corpus
Channels live in a single shared pool. A **site** is a selection of them with its
-own title, description, accent colour and domain. One corpus can therefore
-publish several public archives without any data being duplicated — and a channel
-can appear on more than one.
+own title, description, accent colour and domain. The accent is one of seven
+named colours or a hex of your own, and each site opens in it; a reader can
+still pick another accent, and a light, sepia or dark ground, from the site's
+theme menu. One corpus can therefore publish several public archives without any
+data being duplicated — and a channel can appear on more than one.
Within a site, channels can be arranged into named groups, which is what drives
the navigation on the published pages.
diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts
@@ -1,145 +1,136 @@
import { test, expect, type Page } from "@playwright/test";
+import { REQUIRED_TOKENS } from "../../common/components/themeConfig";
+import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand";
// The shared theme system (common/styles/tokens.css + ThemeScript +
-// ThemeProvider + ThemeToggle) as this site uses it. The project site commits to
-// the "archilyzer" family — the instrument face — and defaults to dark. The mode
-// toggle must persist across reloads and be applied before hydration (no flash
-// of the wrong theme).
-//
-// This used to assert the "archive" family: the site wore the same warm-brass
-// costume as the archives it builds, back when it was a shelf of them.
+// ThemeProvider + ThemeToggle/ThemeMenu) as the project's own site uses it: it
+// opens on the DARK base in Signal, the family's accent. A reader's base
+// persists across reloads and is applied before hydration (no flash of the
+// wrong theme).
+
+const BASE_KEY = "ytdlp-tb:base";
async function htmlState(page: Page) {
- return page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
- mode: localStorage.getItem("ytdlp-tb:mode"),
- }));
+ return page.evaluate((key) => {
+ const d = document.documentElement;
+ return {
+ base: d.getAttribute("data-base"),
+ dark: d.classList.contains("dark"),
+ accent: d.getAttribute("data-accent"),
+ stored: localStorage.getItem(key),
+ brand: getComputedStyle(d).getPropertyValue("--brand").trim(),
+ };
+ }, BASE_KEY);
}
-test("archilyzer dark default; mode toggle persists with no FOUC", async ({
+test("dark + Signal by default; the base toggle persists with no FOUC", async ({
page,
}) => {
+ // The OS says light: the default is still dark (it is the site's, not the
+ // system's).
+ await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
- // Default: the instrument family, dark.
- let s = await htmlState(page);
- expect(s.theme).toBe("archilyzer");
- expect(s.dark).toBe(true);
+ await expect.poll(() => htmlState(page)).toEqual({
+ base: "dark",
+ dark: true,
+ accent: "signal",
+ stored: null,
+ brand: ACCENTS.signal.onDark,
+ });
const toggle = page.getByRole("button", { name: /switch to/i });
await expect(toggle).toBeVisible();
- // Cycle to an explicit light mode (dark → system → light) — deterministic
- // regardless of the runner's OS color-scheme preference.
- for (let i = 0; i < 3; i++) {
- s = await htmlState(page);
- if (s.mode === "light") break;
- await toggle.click();
- }
- s = await htmlState(page);
- expect(s.mode).toBe("light");
+ // Cycle to an explicit light base (dark → system → light). A click before
+ // hydration is lost, so click until it is stored; a click React took commits
+ // synchronously, so the check never races it into a second one.
+ await expect(async () => {
+ if ((await htmlState(page)).stored !== "light") await toggle.click();
+ expect((await htmlState(page)).stored).toBe("light");
+ }).toPass({ timeout: 10_000 });
+ let s = await htmlState(page);
+ expect(s.base).toBe("light");
expect(s.dark).toBe(false);
- expect(s.theme).toBe("archilyzer");
+ expect(s.brand).toBe(ACCENTS.signal.onLight);
- // Reload: the pre-paint inline script must re-apply the persisted mode on the
- // very first commit, before React hydrates.
+ // Reload: the pre-paint inline script must re-apply the persisted base on
+ // the very first commit, before React hydrates.
await page.reload({ waitUntil: "commit" });
- const onCommit = await page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- mode: localStorage.getItem("ytdlp-tb:mode"),
- }));
- expect(onCommit.mode).toBe("light");
- expect(onCommit.dark).toBe(false);
+ await page.waitForFunction(
+ () => document.documentElement?.dataset.themeReady === "1",
+ );
+ s = await htmlState(page);
+ expect(s.stored).toBe("light");
+ expect(s.base).toBe("light");
+ expect(s.dark).toBe(false);
+
+ // <html> is server-rendered dark now; hydration must not put it back.
+ await page.waitForLoadState("load");
+ await expect.poll(() => htmlState(page)).toMatchObject({
+ base: "light",
+ dark: false,
+ stored: "light",
+ brand: ACCENTS.signal.onLight,
+ });
+});
+
+test.describe("with JavaScript off", () => {
+ test.use({ javaScriptEnabled: false });
+
+ test("the server renders the dark base, so no script is needed for it", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ const html = page.locator("html");
+ await expect(html).toHaveAttribute("data-base", "dark");
+ await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
+ await expect(html).toHaveAttribute("data-accent", "signal");
+ // The pre-paint script never ran: this is the server's markup.
+ await expect(html).not.toHaveAttribute("data-theme-ready", /.*/);
+ await expect(html).toHaveCSS("--background", BASE_GROUNDS.dark);
+ await expect(page.locator("body")).toHaveCSS("background-color", "rgb(12, 10, 8)");
+ });
});
-test("the family declares a complete palette in BOTH modes", async ({
+test("every base declares a complete palette, and the grounds differ", async ({
page,
}) => {
- // The failure this guards is silent: a family that omits a token inherits the
- // BASE family's value, so a half-declared palette looks merely "a bit off"
- // rather than broken — and only in the mode nobody checked. Light mode ships
- // here because the theme toggle does.
- const TOKENS = [
- "--background",
- "--foreground",
- "--card",
- "--card-foreground",
- "--popover",
- "--popover-foreground",
- "--primary",
- "--primary-foreground",
- "--secondary",
- "--secondary-foreground",
- "--muted",
- "--muted-foreground",
- "--accent",
- "--accent-foreground",
- "--destructive",
- "--destructive-foreground",
- "--destructive-soft",
- "--border",
- "--border-strong",
- "--input",
- "--ring",
- "--surface",
- "--faint",
- "--panel",
- "--panel-2",
- "--success",
- "--success-foreground",
- "--success-soft",
- "--warning",
- "--warning-foreground",
- "--warning-soft",
- "--info",
- "--info-foreground",
- "--info-soft",
- "--brand",
- "--brand-strong",
- "--brand-soft",
- "--brand-ink",
- "--state-gone",
- "--state-gone-soft",
- "--chart-1",
- "--chart-2",
- "--chart-3",
- "--chart-4",
- "--chart-5",
- "--chart-surface",
- "--chart-grid",
- "--chart-axis",
- "--chart-tooltip-bg",
- "--radius",
- ];
+ // 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 merely "a bit off" rather than broken — and only on the base nobody
+ // checked. themeTokens.test.ts checks the sheet; this checks the browser.
+ const TOKENS = [...REQUIRED_TOKENS, "--radius"];
await page.goto("/");
- const readAll = (names: string[]) =>
- page.evaluate((tokens) => {
- const cs = getComputedStyle(document.documentElement);
- return Object.fromEntries(
- tokens.map((t) => [t, cs.getPropertyValue(t).trim()]),
- );
- }, names);
-
- const setMode = (dark: boolean) =>
- page.evaluate((d) => {
- localStorage.setItem("ytdlp-tb:mode", d ? "dark" : "light");
- document.documentElement.classList.toggle("dark", d);
- }, dark);
-
- await setMode(true);
- const dark = await readAll(TOKENS);
- await setMode(false);
- const light = await readAll(TOKENS);
-
- for (const t of TOKENS) {
- expect(dark[t], `${t} must be set in dark mode`).not.toBe("");
- expect(light[t], `${t} must be set in light mode`).not.toBe("");
+ const readOn = (base: "light" | "sepia" | "dark") =>
+ page.evaluate(
+ ({ base, tokens }) => {
+ const d = document.documentElement;
+ d.setAttribute("data-base", base);
+ d.classList.toggle("dark", base === "dark");
+ const cs = getComputedStyle(d);
+ return Object.fromEntries(
+ tokens.map((t) => [t, cs.getPropertyValue(t).trim()]),
+ );
+ },
+ { base, tokens: TOKENS },
+ );
+
+ const bases = ["light", "sepia", "dark"] as const;
+ const palettes = {} as Record<(typeof bases)[number], Record<string, string>>;
+ for (const b of bases) palettes[b] = await readOn(b);
+
+ for (const b of bases) {
+ for (const t of TOKENS) {
+ expect(palettes[b][t], `${t} must be set on the ${b} base`).not.toBe("");
+ }
+ expect(palettes[b]["--background"]).toBe(BASE_GROUNDS[b]);
}
- // …and the two modes must actually differ, or "light mode" is a label on the
- // dark palette.
- expect(light["--background"]).not.toBe(dark["--background"]);
- expect(light["--foreground"]).not.toBe(dark["--foreground"]);
+ // …and the three must actually differ, or a base is a label on another's
+ // palette.
+ expect(new Set(bases.map((b) => palettes[b]["--background"])).size).toBe(3);
+ expect(new Set(bases.map((b) => palettes[b]["--foreground"])).size).toBe(3);
+ expect(new Set(bases.map((b) => palettes[b]["--chart-1"])).size).toBe(3);
});
diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md
@@ -791,6 +791,248 @@ conflict with S2, is left for the merge).
- the worktree's `sw.js` stayed a copy, and the primary's `sw.js` is unchanged (10,027 B,
20:47:37).
+### Slice S2, as shipped — base × accent (2026-09-25)
+
+Branch `brand/themes` off S0's tip `02295a94`. S2 replaces the five theme families with two independent
+reader choices: a **base** (Light / Sepia / Dark, plus System) and an **accent** (the seven named accents of
+`lib/brand.ts`, defaulting to the site's own). No S1 file was touched: not the header, footer, mark,
+wordmark, manifest, icons, service workers or the `icons` metadata, and not C2's hub search components.
+
+**`common/styles/tokens.css`.**
+- The `dark` custom variant stays. Every `[data-theme=…]` block, the `.dark{}` token block and the per-family
+ font/radius rules are gone.
+- `:root` carries `--radius: 0.375rem` and a default `--brand-mark` (Signal's on-dark `#5fa8a0`), so a page
+ with no `data-accent` (the editor) still has one. `.font-display { font-stretch: 112% }`.
+- Three base blocks: `:root, html[data-base="light"]`, `html[data-base="sepia"]`, `html[data-base="dark"]`.
+ Each declares:
+ - all **49** colour tokens of `homepage/e2e/theme.spec.ts`'s list (the plan said 48; lines 64–112 are
+ 49) and `color-scheme`;
+ - `--swatch-<id>` for the seven accents on that ground and
+ `--swatch-custom: var(--accent-custom-<base>, var(--swatch-signal))`;
+ - `--brand: var(--swatch-signal)`, a `color-mix` `--brand-strong` (78 % toward black on light/sepia,
+ toward white on dark), `--brand-soft` (12 % / 16 %), `--brand-ink` (`#fff` / `#0c0a08`);
+ - a neutral `--primary` (`#202a31` / `#33281a` / `#efe7d8` on a ground-coloured foreground) and
+ `--ring: var(--brand)`.
+- Then one `html[data-accent="<id>"]` rule per accent (`--brand: var(--swatch-<id>)`, `--brand-mark: <onDark>`)
+ and `html[data-accent="custom"]` (`--brand: var(--swatch-custom)`,
+ `--brand-mark: var(--accent-custom-dark, #5fa8a0)`), after the bases at the same specificity.
+- **Light** is the old archilyzer light block. **Dark** is the archive ink block plus
+ `--destructive-soft`, `--state-gone: #d9644f` and `--state-gone-soft`; the gone red sits inside the
+ validator's dark lightness band. **Sepia** is new: the plan's five values
+ (the canvas holds no more), with status, panel and chart values from the archive paper block, darkened
+ for the ground. `themeTokens.test.ts` holds every text token (foreground, muted, destructive, success,
+ warning, info, state-gone) at ≥ 4.5:1 on each ground and card.
+- **Charts** are fixed per base, one hue order everywhere (blue, green, violet, amber, magenta):
+
+ | Base | chart-1..5 | surface | gone |
+ |---|---|---|---|
+ | Light | `#3a7de0 #2f8a57 #5e3aa8 #a8741a #c24a8a` | `#ffffff` | `#a8412d` |
+ | Sepia | `#3574d6 #2a7d4f #5e3aa8 #a8741a #bb4585` | `#faf4e6` | `#a3392a` |
+ | Dark | `#3561c8 #3fa577 #9a7ee6 #b98a2a #c24c8a` | `#16110a` | `#d9644f` |
+
+ The dataviz skill's `validate_palette.js` ran on each base's surface:
+ - Adjacent pairs pass every check. Adjacent is the rule for stacks, bars and lines.
+ - All pairs also exit 0, but one non-adjacent pair per base sits in the CVD 6–8 **floor band** (WARN,
+ legal only with a legend or direct labels): green ↔ amber on light (6.2) and sepia (6.1), and green ↔
+ magenta on dark (6.9).
+ - chart-3 against `--state-gone` (all pairs) passes every check on every base: normal ΔE 24.3 / 23.7 /
+ 22.9, and protan or deutan ΔE ≥ 21.
+ - chart-1 is a blue, ΔE 17.3 / 16.8 / 23.3 from Signal.
+
+ Output: `$T/s2-dataviz.log`.
+
+**`common/styles/fonts.ts`.** Archivo (`wdth` axis, variable weight) as `--font-display`, IBM Plex Sans
+400–700 normal + italic as `--font-sans`, IBM Plex Mono as `--font-mono`. Source Serif/Sans/Code and
+JetBrains Mono are gone. The comments in all three `globals.css` files describe the bases and accents.
+
+**`common/components/themeConfig.ts`.**
+- `BASE_KEY` (`ytdlp-tb:base`), `ACCENT_KEY` (an id), `LEGACY_THEME_KEY` and `LEGACY_MODE_KEY`.
+- `ThemeBase`, `ResolvedBase`, `ThemeAccent` (an id or `"custom"`), `THEME_BASES`, `nextBase`, `resolveBase`
+ and `REQUIRED_TOKENS`.
+- `accentOptions(siteAccent)`, which both pickers read. A custom-hex site gets "Site colour" first.
+- The pure `migrateLegacy`, following the plan's table.
+- `buildThemeScript({defaultBase})`, which runs in the plan's order and sets `data-theme-ready` last. It
+ guards every storage touch, so a throwing `localStorage` still paints the default base and sets the
+ marker.
+- `themeConfig.test.ts` runs the string in `node:vm` over 7 legacy themes × 5 modes × 6 stored bases ×
+ 3 defaults × OS light/dark (1,260 cases). It checks:
+ - the stored base, and that both legacy keys are deleted;
+ - `data-base`, `.dark` and `theme-color`;
+ - that the marker is the last mutation.
+
+ It also covers the accent, a throwing storage and a throwing matchMedia.
+
+**`ThemeProvider`.**
+- Props `{defaultBase = "system", siteAccent = DEFAULT_ACCENT}`. The context is exactly the plan's:
+ `{base, resolvedBase, isDark, accent, siteAccent, setBase, cycleBase, setAccent}`.
+- A `useLayoutEffect` adopts the stored choice. It runs the same one-time migration, then re-asserts
+ `data-base`, `.dark`, `data-accent` and `theme-color`. Nothing is written before adoption.
+- `systemDark` follows a live `matchMedia` listener.
+- `setAccent(siteAccent)` removes the key. `"custom"` is never stored.
+
+**Consumers ported.** Everything else that read the old context (`theme`, `mode`, `setTheme`, `setMode`,
+`cycleMode`, `THEME_FAMILIES`, `THEME_MODES`) moved over:
+- `ThemeMenu`:
+ - `aria-label="Choose theme"`;
+ - a "Base" group of `menuitemradio`s: System, Light, Sepia, Dark;
+ - an "Accent" group: a swatch dot of `var(--swatch-<id>)`, the name, and a visible "default" tag.
+
+ The tag is part of the item's accessible name ("Brass default"), so the specs match by prefix.
+- `ThemeToggle`: "Switch to <next>", Monitor/Sun/BookOpen/Moon. `data-theme-mode` became
+ `data-theme-base`; nothing read it.
+- `export/app/components/MobileMenu.tsx`: native radio groups "Base" and "Accent".
+- `ui/sonner.tsx`: `theme={isDark ? "dark" : "light"}`.
+- The three layouts.
+
+A grep for the old names finds only S1's `manifest.ts` (`parseAccent`, S1 replaces it) and
+`editor/app/sites/actions.ts` (`parseAccent` of the custom hex, by design).
+
+**Layouts.**
+- The export site renders `<html data-accent={resolveAccent(site.accent).id}>`, plus `customAccentVars`
+ inline for a custom hex. It opens on the system base.
+- The hub opens dark in Signal. The homepage opens dark with `data-accent="signal"`.
+- The editor layout is unchanged: system base, Signal from `:root`.
+- `generateViewport`: a site gets the light/dark `BASE_GROUNDS` media pair; the hub and the homepage get
+ `BASE_GROUNDS.dark`.
+- `FALLBACK_THEME_COLOR`, `HUB_THEME_COLOR`, the homepage `THEME_COLOR` and the hex-only `parseAccent`
+ read at `export/app/layout.tsx:64` are gone.
+- `siteAccentVars` and its test are deleted.
+
+**Tests.**
+- The export `theme.spec` is rewritten. `theme-family.spec` is deleted and `theme-accent.spec` is new.
+- `site-branding` checks `data-accent="custom"`, the inline `--accent-custom-light` and a computed
+ `--brand` of `#cc3366` on light.
+- In `responsive`, the "Archive" radio is now "Sepia".
+- The homepage `theme.spec` and the editor `theme.spec`: see the commits.
+- `themeTokens.test.ts` (12 tests) parses `tokens.css`. It checks completeness, and that the swatches and
+ `--brand-mark` equal `ACCENTS`, the accent rules follow the bases, and `customAccentVars`' names match.
+
+**Copy.** `homepage/content/docs/operate.md` describes the accent and the reader's menu. The hub's
+AddArchive comment no longer points at the retired archilyzer block. `export/playwright.config.ts:29` only
+describes the committed icons, which is S1's, so it was left.
+
+| sha | what |
+|---|---|
+| `7ba8258b` | `tokens.css`: three bases × seven accents, fixed validated charts; `fonts.ts`: Archivo / Plex Sans / Plex Mono; `globals.css` comments; `themeTokens.test.ts`; `REQUIRED_TOKENS` |
+| `ef56f3cf` | `themeConfig.ts`: keys, types, `migrateLegacy`, `buildThemeScript`; the node:vm matrix test |
+| `639fd1ff` | ThemeScript / ThemeProvider / ThemeMenu / ThemeToggle / sonner / MobileMenu; the export and homepage layouts (data-accent, defaults, `BASE_GROUNDS` chrome); the retired exports removed |
+| `553733c1` | `siteAccentVars` and its test deleted |
+| `93b963a0` | export specs: `theme` rewritten, `theme-accent` new, `theme-family` deleted, `site-branding`, `responsive` |
+| `6d179081` | homepage `theme.spec`: dark + Signal, `REQUIRED_TOKENS` on all three bases |
+| `b0fefda7` | editor `theme.spec`: seeds `BASE_KEY`; migration cases |
+| `b82f8e02` | `operate.md` copy; AddArchive comment |
+| `996d763d` | `tokens.css` chart comments: all pairs pass with one CVD floor-band pair per base (comments only) |
+
+**Gates.**
+- tsc was clean before each code commit: six runs, with the three spec commits sharing one. A run on the
+ final tree also covers `b82f8e02`, which changed only markdown and a comment.
+- Unit and script tests:
+ - common **1,893/1,893**: S0's 1,874, minus the `siteAccentVars` test, plus 12 token and 8 themeConfig
+ tests;
+ - editor unit **78/78**;
+ - `test:scripts` **162 + 1 skip**;
+ - mcp **219/219**.
+- Builds, with the `export/public` links seeded, `sw.js` a plain copy, no dangling links, and
+ `homepage/public`'s four data entries copied from the primary:
+ - `pnpm --filter editor exec next build` ok (80 s);
+ - `pnpm --filter export exec next build` ok (50 s, the known Turbopack warning);
+ - `pnpm --filter homepage exec next build` ok (23 s).
+ - The built `<html>` carries `data-accent`, the site's two theme-color metas are
+ `#f3f6f7` / `#0c0a08`, and the homepage's is `#0c0a08`.
+- e2e:
+ - export in full: **199 passed, 0 failed**, 8.1 min, after 44 s in the queue behind `brand-mark`;
+ - `e2e:hub`: **12 passed**, 23 s;
+ - homepage in full: **24 passed**, 56 s, with no skips because the data was copied;
+ - editor `theme.spec.ts sites-crud.spec.ts branding.spec.ts`: **22 passed**, 1.1 min. No other editor
+ spec mentions theme, `data-theme` or the theme keys.
+- The primary's `export/public/sw.js` was untouched: 10,027 B, mtime 20:47:37, before and after.
+- The dataviz validator exited 0 on every run listed above. The only WARNs are the three all-pairs
+ floor-band pairs.
+- Numbers tools: none.
+- Visual check: a brass site (`SITE_ID=shotsite`, a copy of the fixture with `"accent":"brass"`) was
+ built to `export/out` and served on `localhost:3310`. Shots:
+ - each base × {brass, violet} at 390 and 1280;
+ - the menu open on light and on dark;
+ - the phone sheet on sepia and dark.
+
+ They are in `$T/s2-*.png`. Sepia reads as paper. The dark ink ground carries brass and violet cleanly.
+ Contrast, computed: sepia fg 12.2, muted 5.5 (5.9 on card), faint 3.6, brass-on-sepia 4.6, white on
+ brass 5.4; dark fg 16.1, muted 7.1, faint 3.5, brass 10.1, gone 5.5.
+
+**Found and left.**
+- **chart-4 (amber) and chart-5 (magenta) sit near the gone red.** Normal ΔE to `--state-gone` is 11.8–14.5
+ on the three bases, below the validator's floor of 15 for a pair. The gate the plan asked for is chart-3,
+ and it clears widely. Every warm hue flanks red, and gone never draws inside a chart. The homepage's five
+ official instances do reach slots 4 and 5. **The review accepted this:** gone is drawn only as labelled
+ text.
+- **No-JS readers get the light block.** The server renders no `data-base`, so a page no script has run on
+ shows the light base, as the old base family's light look did before. **Fixed in review for the hub and
+ the homepage**, which now render dark on the server; a site's "system" base still cannot be.
+- **theme-color after client navigation is not asserted.** Next may re-render its head metas on client
+ navigation; the provider re-asserts theme-color only on a theme change.
+- **Two changelogs gained an `[Unreleased]` section:** `export/CHANGELOG.md` and `homepage/CHANGELOG.md`
+ had none. The export one shows under /sites "Release notes" and on every site's /changelog until it is
+ cut.
+- **`export/out` holds the brass shot build**, not a gate build, and this worktree's `homepage/public`
+ holds copied data (gitignored).
+
+**Review fixes** (review verdict SHIP AFTER FIXES: `$T/s2-review.md`; no must-fix).
+
+| sha | fix |
+|---|---|
+| `4c78c16a` | Sepia status text now reads at 4.5:1 on its own soft fill over the ground and a card: warning `#8c4c00` (4.65 / 4.96), info `#1858bc` (4.74 / 5.07), and success `#256829` (4.73 / 5.05; the new test found it at 4.49). Dark gone-soft alpha drops to 0.12 (4.94 / 4.60; it was 4.37 over a card). `themeTokens.test` checks text-on-soft for five pairs on sepia and dark. Light's success and warning (4.18 / 3.89) predate S2 and are left. |
+| `712257e0` | The homepage growth chart stacks in palette order, `STACK_ORDER = [0,1,2,3,4]`. The old interleave put green against magenta in the CVD floor band (6.3 / 7.2 / 6.9). In stack order every touching pair passes on all three bases (worst CVD 13.1 / 13.5 / 15.5; `$T/s2-dataviz-stack.log`). |
+| `e80af0b8` | The homepage and the hub (export with `defaultBase === "dark"` only) server-render `class="… dark"` and `data-base="dark"`. A new homepage spec runs with `javaScriptEnabled: false` and checks `data-base`, `.dark`, `data-accent`, that there is no ready marker, `--background` `#0c0a08` and the body's background. The Light reader's reload is re-checked after load. |
+| `38b03890` | Accent text on its soft tint is `text-brand-strong` (≥ 6.48:1 on every base; plain `text-brand` went as low as 3.96). Six places use it: the Badge `brand` variant, the editor's SidebarBadges, export ContextPanel, OfflineManager ×2, HubOfflineManager and `downloads/page.tsx`. None of C2's files had one. |
+| `1c72eda1` | `site-branding` reads the raw HTML of `/` and finds `<html … data-accent="custom">` and the inline `--accent-custom-light`. `tone.ts`'s header names the three bases. |
+| `86a8dfa7`, `16e3d2c1` | Glyph gaps at 390 px. They persisted after `document.fonts.ready`, appear at DPR 2 and 3 with or without mobile emulation, and are absent at DPR 1. **The cause is font hinting**, not letter-spacing, font-stretch or justification. IBM Plex Sans ships TrueType hinting, and Chromium on Linux (FreeType) rounds each hinted advance to a whole CSS pixel: at 14px the `g` of "Light" measured 9px against a 7.41px design width. `text-rendering: geometricPrecision` fixes it, on `html` and on `button, input, select, textarea`, because the UA sheet resets form controls to `auto` ("Res ults"). `themeTokens.test` pins the rule. |
+| `e3e3fb26` | The no-flash specs' ready-marker wait is `document.documentElement?.…`. Right after a `commit` reload the new document can have no root, and one post-merge run failed on exactly that (`theme.spec.ts:96`). |
+
+- **Pre-merge gates:**
+ - tsc clean (two runs);
+ - common 1,894, editor unit 78;
+ - export build ok (24 s), homepage build ok (17 s); the homepage's `<html>` is
+ `antialiased dark" data-base="dark" data-accent="signal"`;
+ - e2e: export `theme`, `theme-accent` and `site-branding`, **13 passed** (37 s); homepage full,
+ **25 passed** (39 s); hub, **12 passed** (29 s).
+- **Merge of `main` (`575ae1d4`, S0 + S1 + release 9 C2/C3), `6456ac6c`.** Conflicts were the export
+ layout's import block (both sides kept), the three `[Unreleased]` changelogs (every bullet) and this file
+ (records in slice order). `accent.ts` merged cleanly. `33344d66` ports S1's leftovers:
+ - Wordmark's font is `var(--font-display)`; `--font-grotesk` is gone;
+ - the header mark's comment no longer says `--brand-mark` is undefined. Its
+ `var(--brand-mark, var(--brand))` picks up the token.
+ - Nothing else of S1's or C2's reads the old theme API. `manifest.ts` and the icon routes compile
+ against the new `accent.ts`.
+- **Post-merge gates:**
+ - tsc clean before each commit;
+ - common **1,907**, editor unit **79**, `test:scripts` **162 + 1 skip**, mcp **219**.
+ - Builds:
+ - editor ok (38 s);
+ - export site ok (23 s), and hub mode (`INSTANCE_MODE=hub`) ok (25 s);
+ - homepage ok (14 s).
+ - Each export/homepage build has the seven `out/icons` files with PNG magic and IHDR
+ 180/192/32/512/512, and `favicon.ico` starts `00 00 01 00 03 00`. The site icon is lit Signal
+ `#5fa8a0`; the hub and homepage icons are the parent mark `#151b20 / #3f4c56 / #e7edf1`. The hub
+ and homepage `<html>` carry `dark` and `data-base="dark"`; the site's carries neither.
+ - e2e:
+ - export full, **203 passed, 1 failed**, 7.0 min. The failure is the ready-marker race above; after
+ `e3e3fb26`, `theme`, `theme-accent` and `site-branding` `--repeat-each 3` gave **39 passed**
+ (1.4 min).
+ - `e2e:hub` **19 passed** (38 s).
+ - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`) **3 passed** (37 s). The composed entries in
+ `export/public` were swapped for copies first, then relinked.
+ - homepage full **27 passed** (39 s).
+ - editor `theme`, `branding` and `sites-crud` **23 passed** (53 s).
+ - The primary checkout's `export/public` was never written: `sw.js` is 10,027 B, mtime 20:47:37, and
+ the seven composed files kept their mtimes.
+ - `16e3d2c1` (the CSS rule extended to form controls) came after the e2e runs. It was gated by tsc,
+ common 1,907, the token test and a rebuild.
+- **Post-merge shots** (`$T/s2-merged-*.png`, served `out/` on localhost, all looked at):
+ - the site on light + brass, sepia + violet and dark + brass at 1280. S1's mark sits on its ink tile,
+ lit by `--brand-mark`: `#e3b15c` for brass, `#b49cf2` for violet. The wordmark is Archivo;
+ - the hub on its dark default: the parent mark, and `data-accent="signal"`;
+ - the 390 px phone menus on sepia and dark at DPR 2, after `fonts.ready`: no glyph gaps.
+
## Operator rollout (after merge)
1. Restart the live :3001 editor on the new `main`; it needs S0's form.