commit 989eb7bbb734c7b391d33d2e17fc331b693e41a3 parent cf6353130de911f1302cb64ddee06ef499d63d9c Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Mon, 28 Sep 2026 21:24:34 -0400 common, export, editor, homepage: two grounds, Light and Dark; each site wears its own accent (release 14 slice T1) The ruling: Sepia is dropped in every app, the editor included; a reader who chose it gets Light; readers no longer pick an accent — each site shows its own, and stored accent choices are ignored. - THEME_BASES is System, Light, Dark; nextBase walks it; ResolvedBase is light | dark. The Sepia token block, its --base flag and every per-base entry (onSepia, BASE_GROUNDS, ACCENT_INK, the custom accent's fitted value) are gone. - A stored base of the retired ground (RETIRED_BASE, the one place its name is spelled) is light in the pre-paint script and in ThemeProvider, and is rewritten to light once; the old archive + light legacy pair maps to light. - The accent is the site's: ThemeScript and ThemeProvider never read ytdlp-tb:accent and never remove it (pinAccent is gone — it is the only behaviour). ThemeMenu is deleted; the export and editor headers keep ThemeToggle; the slide-out menu's ThemeRadios offers the base alone. - Unit tests over the script's matrix (the retired base, archive + light, garbage, a storage that will not take the write); e2e in each app for a stored retired base (a MutationObserver from an init script sees no other ground) and, on a site, for a stored accent (never applied, left in place). - Docs: operate.md, siteSchema's accent text (SITE.md), the site form's hint. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Diffstat:
56 files changed, 548 insertions(+), 764 deletions(-)
diff --git a/SITE.md b/SITE.md @@ -147,7 +147,7 @@ Default: absent ## `accent` -Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped. +Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's accent on every page; a reader does not pick one. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped. Default: absent diff --git a/common/components/BrandMark.test.ts b/common/components/BrandMark.test.ts @@ -21,7 +21,7 @@ test("the ring's corner is the ground's: MARK_GROUND_RX of MARK_VIEWBOX", () => assert.equal(pct, 21.875); assert.ok(BRAND_MARK_RING_CLASS.split(" ").includes(`rounded-[${pct}%]`)); // The ring is the dark variant only, 1px, spread with no blur or offset, - // in the palette's ring colour; light and sepia get no shadow class at all. + // in the palette's ring colour; light gets no shadow class at all. const shadows = BRAND_MARK_RING_CLASS.split(" ").filter((c) => c.includes("shadow")); assert.deepEqual(shadows, ["dark:shadow-[0_0_0_1px_var(--mark-ring)]"]); // The svg must not clip the ground a second time at that corner. diff --git a/common/components/BrandMark.tsx b/common/components/BrandMark.tsx @@ -11,7 +11,7 @@ export type BrandMarkPalette = Readonly<Record<MarkTone, string>>; // clears it. So on dark — `.dark` on <html>, tokens.css's `@custom-variant // dark` — the tile gets a 1px ring OUTSIDE it, in the palette's own dim // (`--mark-ring`, set from `palette.dim` below), so the ring reads as part of -// the mark. Light and sepia are untouched. +// the mark. Light is untouched. // - A box-shadow, so it takes no layout: the mark's size and the header's // alignment are the same on every base. // - `rounded-[21.875%]` is the ground's corner (MARK_GROUND_RX / MARK_VIEWBOX, diff --git a/common/components/ThemeMenu.tsx b/common/components/ThemeMenu.tsx @@ -1,86 +0,0 @@ -"use client"; - -import { Palette } from "lucide-react"; -import { useTheme } from "./ThemeProvider"; -import { - THEME_BASES, - accentOptions, - isThemeAccent, - isThemeBase, -} from "./themeConfig"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; -import { cn } from "../lib/utils"; - -// 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 { base, accent, siteAccent, setBase, setAccent } = useTheme(); - - return ( - <DropdownMenu> - <DropdownMenuTrigger - aria-label="Choose theme" - title="Theme" - className={cn( - "inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring", - className, - )} - > - <Palette className="h-4 w-4" aria-hidden="true" /> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="min-w-52"> - <DropdownMenuLabel>Base</DropdownMenuLabel> - <DropdownMenuRadioGroup - aria-label="Base" - value={base} - onValueChange={(v) => { - if (isThemeBase(v)) setBase(v); - }} - > - {THEME_BASES.map((b) => ( - <DropdownMenuRadioItem key={b.id} value={b.id}> - {b.label} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - <DropdownMenuSeparator /> - <DropdownMenuLabel>Accent</DropdownMenuLabel> - <DropdownMenuRadioGroup - aria-label="Accent" - value={accent} - onValueChange={(v) => { - if (isThemeAccent(v)) setAccent(v); - }} - > - {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> - </DropdownMenuContent> - </DropdownMenu> - ); -} diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx @@ -9,33 +9,34 @@ import { useMemo, useState, } from "react"; -import { BASE_GROUNDS, DEFAULT_ACCENT, isAccentId, type AccentId } from "../lib/brand"; +import { BASE_GROUNDS, DEFAULT_ACCENT } from "../lib/brand"; import { - ACCENT_KEY, BASE_KEY, LEGACY_MODE_KEY, LEGACY_THEME_KEY, + RETIRED_BASE, isThemeBase, migrateLegacy, nextBase, resolveBase, + storedBase, type ResolvedBase, type ThemeAccent, type ThemeBase, } from "./themeConfig"; -// Runtime theme controller: the reader's BASE (light | sepia | dark | system) -// and ACCENT (one of the seven, defaulting to the site's own). +// Runtime theme controller: the reader's BASE (light | dark | system). The +// ACCENT is the site's own (`siteAccent`), never the reader's: a stored pick +// from before is neither read nor removed. // // 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. +// attributes to what the server rendered — the className without `.dark` — 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 = { /** The reader's choice, "system" included. */ @@ -44,16 +45,12 @@ type ThemeContextValue = { resolvedBase: ResolvedBase; /** Whether the dark base is applied. */ isDark: boolean; - /** The accent applied: the reader's pick, else the site's. */ + /** The accent applied: the site's own ("custom" for a site with its own + * hex). */ 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. */ + /** system → light → 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); @@ -68,9 +65,10 @@ function systemPrefersDark(): boolean { } } -// 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 } { +// The stored base, after the same one-time migrations the pre-paint script +// runs (a no-op when the script already ran them): the legacy keys, and a +// stored RETIRED_BASE rewritten to "light". +function readStoredBase(): ThemeBase | null { try { const s = window.localStorage; let base = s.getItem(BASE_KEY); @@ -87,13 +85,10 @@ function readStored(): { base: ThemeBase | null; accent: AccentId | null } { 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, - }; + if (base === RETIRED_BASE) s.setItem(BASE_KEY, "light"); + return storedBase(base); } catch { - return { base: null, accent: null }; + return null; } } @@ -107,46 +102,38 @@ function applyToDom(resolved: ResolvedBase, accent: ThemeAccent) { } } -function store(key: string, value: string | null) { +function store(key: string, value: string) { try { - if (value === null) window.localStorage.removeItem(key); - else window.localStorage.setItem(key, value); + window.localStorage.setItem(key, value); } catch { /* storage disabled: the choice holds for this page only */ } } -// `pinAccent`: an app that offers no accent control (the homepage) keeps its -// own accent whatever the reader stored for the apps that do; the stored value -// is neither read nor removed. Pair with the same prop on ThemeScript. export function ThemeProvider({ defaultBase = "system", siteAccent = DEFAULT_ACCENT, - pinAccent = false, children, }: { defaultBase?: ThemeBase; siteAccent?: ThemeAccent; - pinAccent?: boolean; children: React.ReactNode; }) { // Initial state MUST equal what the server rendered (the defaults), so - // hydration matches; the persisted values are adopted just below. + // hydration matches; the persisted base is 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); + const accent = siteAccent; // 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(pinAccent ? siteAccent : (stored.accent ?? siteAccent)); + setBaseState(readStoredBase() ?? defaultBase); setSystemDark(systemPrefersDark()); setAdopted(true); - // Effectively once: the defaults are stable props from the layout. - }, [defaultBase, siteAccent, pinAccent]); + // Effectively once: the default is a stable prop from the layout. + }, [defaultBase]); // A LIVE OS preference: "system" follows a change made while the page is // open (the pre-paint script can only read it once). @@ -180,34 +167,16 @@ export function ThemeProvider({ 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); - }, - [siteAccent], - ); - const value = useMemo<ThemeContextValue>( () => ({ base, resolvedBase, isDark: resolvedBase === "dark", accent, - siteAccent, setBase, cycleBase, - setAccent, }), - [base, resolvedBase, accent, siteAccent, setBase, cycleBase, setAccent], + [base, resolvedBase, accent, setBase, cycleBase], ); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; diff --git a/common/components/ThemeRadios.tsx b/common/components/ThemeRadios.tsx @@ -1,24 +1,17 @@ "use client"; import { useTheme } from "./ThemeProvider"; -import { - THEME_BASES, - accentOptions, - isThemeAccent, - isThemeBase, -} from "./themeConfig"; +import { THEME_BASES, isThemeBase } from "./themeConfig"; -// The theme's two choices as NATIVE radio groups: "Base" (System / Light / -// Sepia / Dark) and "Accent" (the seven named accents, the site's own tagged -// "default"; a custom-hex site adds "Site colour" first) — the same lists -// ThemeMenu's dropdown offers. Used where a dropdown inside another layer would -// be a focus-trap fight: the export's slide-out menu. A pick applies at once (attributes on <html>; -// tokens.css does the rest) and nothing closes. +// The reader's base as a NATIVE radio group, "Base" (System / Light / Dark). +// Used where a dropdown inside another layer would be a focus-trap fight: the +// export's slide-out menu. A pick applies at once (attributes on <html>; +// tokens.css does the rest) and nothing closes. There is no accent group: each +// site shows its own. // -// Renders the two groups as siblings, each `groupClassName`, so a caller lays -// them out. The radiogroups are named "Base" and "Accent", and each radio by its -// label — an e2e contract. `namePrefix` keeps two instances on one page from -// sharing a radio group. Must render inside a <ThemeProvider/>. +// The radiogroup is named "Base", and each radio by its label — an e2e +// contract. `namePrefix` keeps two instances on one page from sharing a radio +// group. Must render inside a <ThemeProvider/>. export function ThemeRadios({ namePrefix, groupClassName, @@ -26,34 +19,20 @@ export function ThemeRadios({ namePrefix: string; groupClassName?: string; }) { - const { base, accent, siteAccent, setBase, setAccent } = useTheme(); + const { base, setBase } = useTheme(); return ( - <> - <div className={groupClassName}> - <GroupHeading>Base</GroupHeading> - <RadioList - name={`${namePrefix}-base`} - label="Base" - value={base} - options={THEME_BASES} - onPick={(v) => { - if (isThemeBase(v)) setBase(v); - }} - /> - </div> - <div className={groupClassName}> - <GroupHeading>Accent</GroupHeading> - <RadioList - name={`${namePrefix}-accent`} - label="Accent" - value={accent} - options={accentOptions(siteAccent)} - onPick={(v) => { - if (isThemeAccent(v)) setAccent(v); - }} - /> - </div> - </> + <div className={groupClassName}> + <GroupHeading>Base</GroupHeading> + <RadioList + name={`${namePrefix}-base`} + label="Base" + value={base} + options={THEME_BASES} + onPick={(v) => { + if (isThemeBase(v)) setBase(v); + }} + /> + </div> ); } @@ -75,13 +54,7 @@ function RadioList({ name: string; label: string; value: 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; - }>; + options: ReadonlyArray<{ id: string; label: string }>; onPick: (id: string) => void; }) { return ( @@ -99,19 +72,7 @@ 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/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx @@ -2,32 +2,23 @@ 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. -// 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). +// From localStorage it migrates the retired theme/mode keys once and a stored +// retired base to "light", then sets `data-base` (the resolved light | dark +// ground) and `.dark` on <html>, 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). // -// 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. -// -// `pinAccent` (the homepage, which offers no accent control): the stored -// accent is not read; see buildThemeScript. -export function ThemeScript({ - defaultBase = "system", - pinAccent = false, -}: { - defaultBase?: ThemeBase; - pinAccent?: boolean; -}) { +// The accent is not this script's business: the layout server-renders the +// site's own as `<html data-accent>`, and a reader's stored pick from before +// is not read. 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({ defaultBase = "system" }: { defaultBase?: ThemeBase }) { return ( <script - dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase, pinAccent }) }} + dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase }) }} suppressHydrationWarning /> ); diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx @@ -1,19 +1,19 @@ "use client"; -import { BookOpen, Monitor, Moon, Sun } from "lucide-react"; +import { Monitor, Moon, Sun } from "lucide-react"; import { useTheme } from "./ThemeProvider"; import { nextBase, type ThemeBase } from "./themeConfig"; import { cn } from "../lib/utils"; -// 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; +// The theme control: cycles the base, system → light → dark → system +// (themeConfig THEME_BASES). The icon shows the base in force, the label names +// the next one ("Switch to …" — the e2e specs find it by /switch to/i). There +// is no accent control: each site shows its own. Must be rendered inside a +// <ThemeProvider/>. +const ICON = { system: Monitor, light: Sun, dark: Moon } as const; const LABEL: Record<ThemeBase, string> = { system: "system", light: "light", - sepia: "sepia", dark: "dark", }; diff --git a/common/components/themeConfig.test.ts b/common/components/themeConfig.test.ts @@ -1,25 +1,25 @@ // 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. +// the pure helpers (migrateLegacy, storedBase, 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_IDS, BASE_GROUNDS } from "../lib/brand"; import { ACCENT_KEY, BASE_KEY, LEGACY_MODE_KEY, LEGACY_THEME_KEY, + RETIRED_BASE, THEME_BASES, buildThemeScript, - isThemeBase, - isThemeAccent, migrateLegacy, nextBase, resolveBase, + storedBase, type ThemeBase, } from "./themeConfig"; @@ -28,6 +28,8 @@ type Env = { prefersDark: boolean; serverAccent?: string; storageThrows?: boolean; + // Reads work, writes throw (a full or read-only storage). + writeThrows?: boolean; matchMediaThrows?: boolean; }; @@ -73,8 +75,8 @@ function run(defaultBase: ThemeBase, env: Env): Result { ? { 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), + setItem: (k: string, v: string) => (env.writeThrows ? fail() : void store.set(k, String(v))), + removeItem: (k: string) => (env.writeThrows ? fail() : void store.delete(k)), }; ctx.window = { get localStorage() { @@ -109,12 +111,13 @@ function run(defaultBase: ThemeBase, env: Env): Result { 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 BASES = [null, "light", RETIRED_BASE, "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"); + // The old "archive" paper theme was the retired third ground: light now. + assert.equal(migrateLegacy({ theme: "archive", mode: "light" }), "light"); assert.equal(migrateLegacy({ theme: "selenized", mode: "light" }), "light"); assert.equal(migrateLegacy({ theme: "archive", mode: "dark" }), "dark"); assert.equal(migrateLegacy({ theme: "selenized", mode: "dark" }), "dark"); @@ -140,18 +143,21 @@ test("the pre-paint script over the whole legacy × stored-base × default × OS const label = JSON.stringify({ theme, mode, base, defaultBase, prefersDark }); // 1. Migration: only when no base is stored; legacy keys always go. + // 2. The retired base is rewritten to light, in storage too. const migrated = base === null ? migrateLegacy({ theme, mode }) : null; - const storedAfter = base ?? migrated; + const storedAfter = base === RETIRED_BASE ? "light" : (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; + // 3–4. Validated base, resolved against the OS. + const effective: ThemeBase = storedBase(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`); + assert.ok(resolved === "light" || resolved === "dark", `${label}: a third ground`); + // 5. Browser chrome follows the resolved ground. assert.deepEqual(r.metas, [BASE_GROUNDS[resolved], BASE_GROUNDS[resolved]], `${label}: theme-color`); @@ -163,18 +169,37 @@ test("the pre-paint script over the whole legacy × stored-base × default × OS 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", () => { +test("a stored accent is ignored: the server's data-accent stays, and the key is left in place", () => { 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); + const label = JSON.stringify({ stored, serverAccent }); + assert.equal(r.attrs.get("data-accent"), serverAccent, label); + assert.ok(!r.log.includes("data-accent"), `${label}: the script wrote data-accent`); + assert.equal(r.store.get(ACCENT_KEY) ?? null, stored, `${label}: the stored accent moved`); + assert.equal(r.log.at(-1), "data-theme-ready"); + } + assert.ok(!buildThemeScript().includes(ACCENT_KEY)); +}); + +test("the retired base paints light even when storage will not take the rewrite", () => { + for (const defaultBase of DEFAULTS) + for (const prefersDark of [false, true]) { + const r = run(defaultBase, { storage: { [BASE_KEY]: RETIRED_BASE }, prefersDark, writeThrows: true }); + assert.equal(r.attrs.get("data-base"), "light"); + assert.equal(r.dark, false); assert.equal(r.log.at(-1), "data-theme-ready"); } }); +test("storedBase: light, dark and system, the retired one as light, anything else null", () => { + assert.equal(storedBase("light"), "light"); + assert.equal(storedBase("dark"), "dark"); + assert.equal(storedBase("system"), "system"); + assert.equal(storedBase(RETIRED_BASE), "light"); + for (const v of [null, "", "bogus", "Light", 1]) assert.equal(storedBase(v), null); +}); + test("storage that throws still paints the default base and sets the marker", () => { for (const defaultBase of DEFAULTS) for (const prefersDark of [false, true]) { @@ -199,32 +224,14 @@ test("an invalid defaultBase falls back to system", () => { assert.equal(r.attrs.get("data-base"), "dark"); }); -test("the toggle cycles system → light → sepia → dark → system, in picker order", () => { +test("the toggle cycles system → light → dark → system, in THEME_BASES order", () => { const seen: ThemeBase[] = []; let b: ThemeBase = "system"; - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 3; 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")); -}); - -// The homepage offers no accent control, so its script does not read the -// stored accent (pinAccent); every other app's script is what it was. -test("pinAccent: the stored accent is never read; without it the script is unchanged", () => { - const read = `getItem(${JSON.stringify(ACCENT_KEY)})`; - for (const defaultBase of ["system", "dark"] as const) { - assert.equal(buildThemeScript({ defaultBase, pinAccent: false }), buildThemeScript({ defaultBase })); - assert.ok(buildThemeScript({ defaultBase }).includes(read)); - assert.ok(!buildThemeScript({ defaultBase, pinAccent: true }).includes(read)); - } + assert.deepEqual(THEME_BASES.map((x) => x.label), ["System", "Light", "Dark"]); }); diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts @@ -1,33 +1,29 @@ // 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). +// (client), the toggle, 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 +// THE THEME (plans/brand-and-themes.md; two grounds since release 14, T1): +// • the reader's BASE — the light or the dark ground, or "system", which +// follows the OS between them. `html[data-base]` selects one of the two // 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. +// resolved base is dark, so Tailwind's `dark:` utilities keep working. A +// stored RETIRED_BASE (the third ground, retired in release 14) is read +// as, and rewritten to, "light". +// • the ACCENT — one of the seven named accents in lib/brand.ts, or a +// site's own hex. It is the SITE's (a server-rendered `html[data-accent]`), +// never the reader's: a reader's stored pick from before is ignored, and +// left in storage. // // PURE: no React, no DOM at import time. lib/brand.ts is pure too. -import { - ACCENTS, - ACCENT_IDS, - BASE_GROUNDS, - isAccentId, - type AccentId, -} from "../lib/brand"; +import { BASE_GROUNDS, type AccentId } from "../lib/brand"; -// The reader's base ("light" | "sepia" | "dark" | "system"). +// The reader's base ("light" | "dark" | "system"; RETIRED_BASE, from before +// release 14, is migrated to "light"). 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.) +// A reader's accent pick from before release 14. Nothing reads it and nothing +// deletes it: each app paints its own accent. 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), @@ -35,59 +31,41 @@ export const ACCENT_KEY = "ytdlp-tb:accent"; export const LEGACY_THEME_KEY = "ytdlp-tb:theme"; export const LEGACY_MODE_KEY = "ytdlp-tb:mode"; -// The three grounds a base resolves to, and the reader's choice (which adds +// The two grounds a base resolves to, and the reader's choice (which adds // "system"). -export type ResolvedBase = "light" | "sepia" | "dark"; +export type ResolvedBase = "light" | "dark"; export type ThemeBase = ResolvedBase | "system"; +// The third ground, retired in release 14: a stored base of this value is +// light. The one place its name is spelled. +export const RETIRED_BASE = "sepia"; + // 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. +// The toggle's cycle order, and every base a reader can store. 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" }, ]; 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); + return v === "system" || v === "light" || v === "dark"; } -// 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; +// What a stored base means: a base, RETIRED_BASE → "light", anything else null +// (the app's default applies). +export function storedBase(v: unknown): ThemeBase | null { + if (v === RETIRED_BASE) return "light"; + return isThemeBase(v) ? v : null; } -// ThemeToggle's cycle: system → light → sepia → dark → system. +// ThemeToggle's cycle, THEME_BASES in order: system → light → dark → system. export function nextBase(b: ThemeBase): ThemeBase { - return b === "system" ? "light" : b === "light" ? "sepia" : b === "sepia" ? "dark" : "system"; + const i = THEME_BASES.findIndex((t) => t.id === b); + return THEME_BASES[(i + 1) % THEME_BASES.length].id; } // A choice resolved against the OS preference. @@ -158,21 +136,21 @@ export const REQUIRED_TOKENS = [ // by the caller, whatever it returned. // // stored mode result -// light light — or sepia when the old theme was "archive" (paper) +// light light (the old "archive" paper theme too: it was the +// third ground until that was retired) // 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. +// accent is the site's. export function migrateLegacy({ - theme, mode, }: { theme: string | null; mode: string | null; }): ThemeBase | null { - if (mode === "light") return theme === "archive" ? "sepia" : "light"; + if (mode === "light") return "light"; if (mode === "dark") return "dark"; if (mode === "system") return "system"; return null; @@ -182,54 +160,48 @@ export function migrateLegacy({ // 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; +// 2. a stored RETIRED_BASE becomes "light", in storage too (once); +// 3. validate the stored base, falling back to `defaultBase`; +// 4. set `data-base` to the RESOLVED ground and toggle `.dark`; // 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. +// The accent is not read: `html[data-accent]` is the server's (the site's +// own), and a stored ACCENT_KEY stays where it is, unread. // 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. -// -// `pinAccent`: an app with no accent control of its own (the homepage) never -// reads the stored accent — step 4 is skipped, the server-rendered accent -// stays, and the reader's stored value is left where it is for the apps that -// offer the choice. Without it the script is exactly what it was. export function buildThemeScript({ defaultBase = "system", - pinAccent = false, -}: { defaultBase?: ThemeBase; pinAccent?: boolean } = {}): string { +}: { 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;" + + "var d=document.documentElement,b=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;" + + "var m=lm==='light'?'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)});` + "}" + - (pinAccent ? "" : `a=s.getItem(${q(ACCENT_KEY)});`) + + `if(b===${q(RETIRED_BASE)}){b='light';s.setItem(${q(BASE_KEY)},'light');}` + "}catch(e){}" + - `if(b!=='light'&&b!=='sepia'&&b!=='dark'&&b!=='system')b=${q(fallback)};` + + `if(b===${q(RETIRED_BASE)})b='light';` + + `if(b!=='light'&&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 @@ -69,16 +69,22 @@ function rule(selector: string): Rule { 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"> = { +const ON: Record<BaseGround, "onLight" | "onDark"> = { light: "onLight", - sepia: "onSepia", dark: "onDark", }; +test("two bases: tokens.css has a block for light and dark and none for any other", () => { + const blocks = RULES.map((r) => r.selector.match(/html\[data-base="([^"]+)"\]/g) ?? []) + .flat() + .map((sel) => sel.slice('html[data-base="'.length, -2)); + assert.deepEqual([...new Set(blocks)].sort(), ["dark", "light"]); + assert.deepEqual([...BASE_GROUND_IDS], ["light", "dark"]); +}); + 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]); @@ -92,7 +98,7 @@ test("every base declares every required token, color-scheme and every swatch", } }); -test("--base-light|sepia|dark are 1 on their own base and 0 on the others", () => { +test("--base-light|dark are 1 on their own base and 0 on the other", () => { // lib/siteColor.ts perBaseColor multiplies each base's channel by these, so // exactly one may be 1 — two would add two colours' channels together. for (const base of BASE_GROUND_IDS) { @@ -142,7 +148,7 @@ test("each base defaults --brand to Signal, keeps --primary neutral and rings in 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("--primary"), base === "dark" ? "#efe7d8" : "#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%/); @@ -271,5 +277,5 @@ test("status text reads at 4.5:1 on its own soft fill, on every base", () => { } } } - assert.equal(checks, 30); // 3 bases × 2 grounds × 5 pairs + assert.equal(checks, 20); // 2 bases × 2 grounds × 5 pairs }); diff --git a/common/components/ui/alert.tsx b/common/components/ui/alert.tsx @@ -5,7 +5,7 @@ import { cn } from "../../lib/utils" // A token-driven Alert primitive replacing the family's ad-hoc role="alert" / // role="status" notice divs. Each variant is a soft-filled, colored-border block -// that recolors correctly on every base (light, sepia, dark) via the semantic +// that recolors correctly on every base (light, dark) via the semantic // tokens. Callers keep control of the a11y role (default "alert"; pass // role="status" for non-urgent notices) so existing e2e querying by role stays // green. An optional leading icon is supported via the grid layout below. diff --git a/common/components/ui/badge.tsx b/common/components/ui/badge.tsx @@ -21,7 +21,7 @@ const badgeVariants = cva( // 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. + // ~4:1 on the light ground. success: "border-success/30 bg-success-soft text-success [a&]:hover:bg-success/15", warning: diff --git a/common/components/ui/sonner.tsx b/common/components/ui/sonner.tsx @@ -13,8 +13,8 @@ import { useTheme } from "../ThemeProvider" const Toaster = ({ ...props }: ToasterProps) => { // 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. + // tracks the base in force: "system" is already resolved (live) by the + // provider. const { isDark } = useTheme() return ( diff --git a/common/lib/accent.test.ts b/common/lib/accent.test.ts @@ -45,7 +45,6 @@ test("resolveAccent: a named accent reads its table row", () => { assert.deepEqual(resolveAccent(id), { id, light: ACCENTS[id].onLight, - sepia: ACCENTS[id].onSepia, dark: ACCENTS[id].onDark, }); } @@ -58,7 +57,7 @@ test("resolveAccent: absent or malformed is the default accent (Signal)", () => } }); -// Custom hexes chosen to need every kind of fit: too light for light/sepia, +// Custom hexes chosen to need every kind of fit: too light for light, // too dark for dark, already fine, and the extremes. const CUSTOMS = [ "#cc3366", "#ffff00", "#00ffff", "#ffffff", "#000000", "#808080", "#1e90ff", @@ -83,16 +82,16 @@ test("resolveAccent: a custom hex is fitted to 4.5:1 on every ground and with it }); test("resolveAccent: a custom hex that already passes is kept exactly; others move the right way", () => { - // #cc3366 is 4.57:1 on light (kept), 4.21 on sepia (darkened), 3.98 on dark - // (lightened). The themes slice's site-branding spec expects #cc3366 on light. + // #cc3366 is 4.57:1 on light (kept) and 3.98 on dark (lightened). The + // themes slice's site-branding spec expects #cc3366 on light. const r = resolveAccent("#CC3366"); assert.equal(r.light, "#cc3366"); - assert.notEqual(r.sepia, "#cc3366"); assert.notEqual(r.dark, "#cc3366"); const lum = (h: string) => contrastRatio(h, "#000000"); - assert.ok(lum(r.sepia) < lum("#cc3366"), "sepia is darker"); assert.ok(lum(r.dark) > lum("#cc3366"), "dark is lighter"); - // A dark-enough colour is kept on light and sepia, a light-enough on dark. + // The one that is too light for light is darkened there. + assert.ok(lum(resolveAccent("#ffff00").light) < lum("#ffff00"), "light is darker"); + // A dark-enough colour is kept on light, a light-enough on dark. assert.equal(resolveAccent("#101010").light, "#101010"); assert.equal(resolveAccent("#f0f0f0").dark, "#f0f0f0"); }); @@ -123,7 +122,6 @@ test("customAccentVars: only a custom hex carries inline vars", () => { const r = resolveAccent("#cc3366"); assert.deepEqual(customAccentVars("#cc3366"), { "--accent-custom-light": r.light, - "--accent-custom-sepia": r.sepia, "--accent-custom-dark": r.dark, }); }); diff --git a/common/lib/accent.ts b/common/lib/accent.ts @@ -51,7 +51,6 @@ export function parseAccentSetting(input: unknown): string | undefined { export type ResolvedAccent = { id: AccentId | "custom"; light: string; - sepia: string; dark: string; }; @@ -68,7 +67,7 @@ function meetsRule(hex: string, base: BaseGround): boolean { // Fit a custom hex to one base: unchanged when it already meets the rule // (brand.ts MIN_ACCENT_CONTRAST against the ground AND the ink), otherwise -// mixed toward black (light/sepia) or white (dark) in 1 % steps until it does. +// mixed toward black (light) or white (dark) in 1 % steps until it does. // Mixing keeps the hue; full black/white always passes, so this terminates. function fitAccent(hex: string, base: BaseGround): string { if (meetsRule(hex, base)) return hex; @@ -93,12 +92,11 @@ export function resolveAccent(input: unknown): ResolvedAccent { return { id: "custom", light: fitAccent(setting, "light"), - sepia: fitAccent(setting, "sepia"), dark: fitAccent(setting, "dark"), }; } const a = ACCENTS[setting && isAccentId(setting) ? setting : DEFAULT_ACCENT]; - return { id: a.id, light: a.onLight, sepia: a.onSepia, dark: a.onDark }; + return { id: a.id, light: a.onLight, dark: a.onDark }; } // The PUBLISHED accent: always a hex. An id becomes its on-dark value (the diff --git a/common/lib/brand.test.ts b/common/lib/brand.test.ts @@ -21,9 +21,8 @@ import { } from "./brand"; import { PROJECT_NAME, PROJECT_WORDMARK_LEAD } from "./project"; -const ON: Record<BaseGround, "onLight" | "onSepia" | "onDark"> = { +const ON: Record<BaseGround, "onLight" | "onDark"> = { light: "onLight", - sepia: "onSepia", dark: "onDark", }; @@ -31,7 +30,8 @@ test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () => assert.deepEqual(Object.keys(ACCENTS), [...ACCENT_IDS]); for (const id of ACCENT_IDS) { assert.equal(ACCENTS[id].id, id); - for (const k of ["onDark", "onLight", "onSepia"] as const) { + assert.deepEqual(Object.keys(ACCENTS[id]).sort(), ["id", "name", "onDark", "onLight"]); + for (const k of ["onDark", "onLight"] as const) { assert.match(ACCENTS[id][k], /^#[0-9a-f]{6}$/, `${id}.${k}`); } } @@ -39,13 +39,13 @@ test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () => // The whole table, literally, against plans/brand-and-themes.md "Accents": // a typo that still clears 4.5:1 would pass the contrast test below. assert.deepEqual(ACCENTS, { - signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73", onSepia: "#2b756e" }, - brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a", onSepia: "#8e6119" }, - vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f", onSepia: "#b3431f" }, - violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4", onSepia: "#6a4bc4" }, - sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a", onSepia: "#a83a6a" }, - blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8", onSepia: "#2d5fb8" }, - green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c", onSepia: "#3d772b" }, + signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73" }, + brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a" }, + vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f" }, + violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4" }, + sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a" }, + blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8" }, + green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c" }, }); assert.equal(isAccentId("brass"), true); assert.equal(isAccentId("Brass"), false); @@ -53,8 +53,8 @@ test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () => }); // THE CONTRAST RULE: every accent's value on every base reaches 4.5:1 against -// that base's ground AND against the ink set on it (white on light/sepia, the -// dark ground on dark). 7 accents × 3 bases × 2 = 42 checks. +// that base's ground AND against the ink set on it (white on light, the dark +// ground on dark). 7 accents × 2 bases × 2 = 28 checks. test("accents: every value reaches 4.5:1 on its ground and with its ink", () => { // The bar itself is pinned as a literal: comparing only against the // imported constant would let an edit to brand.ts lower rule and test @@ -71,9 +71,8 @@ test("accents: every value reaches 4.5:1 on its ground and with its ink", () => checks += 2; } } - assert.equal(checks, 42); + assert.equal(checks, 28); assert.equal(ACCENT_INK.light, "#ffffff"); - assert.equal(ACCENT_INK.sepia, "#ffffff"); assert.equal(ACCENT_INK.dark, BASE_GROUNDS.dark); }); @@ -94,7 +93,7 @@ test("contrastRatio: the sRGB curve, at the 4.5:1 boundary on white", () => { }); test("bases and icon palettes are the plan's", () => { - assert.deepEqual(BASE_GROUNDS, { light: "#f3f6f7", sepia: "#f4ecd8", dark: "#0c0a08" }); + assert.deepEqual(BASE_GROUNDS, { light: "#f3f6f7", dark: "#0c0a08" }); assert.deepEqual(ICON_PALETTES.archilyzer, { ground: "#151b20", dim: "#586977", lit: "#e7edf1" }); assert.deepEqual(childIconPalette(ACCENTS.brass.onDark), { ground: "#0c0a08", diff --git a/common/lib/brand.ts b/common/lib/brand.ts @@ -1,7 +1,7 @@ -// THE BRAND, AS DATA — the Found-line mark, the accent palette, the three base +// THE BRAND, AS DATA — the Found-line mark, the accent palette, the two base // grounds and the wordmark split. plans/brand-and-themes.md "The design" is the // source of every value here; where the design canvas and the plan differ, the -// plan wins (notably the contrast-corrected on-light / on-sepia accents). +// plan wins (notably the contrast-corrected on-light accents). // // PURE: zero imports, no I/O, no framework types, no zod. It is safe in server // components, `"use client"` trees (the editor's accent swatches), route @@ -31,17 +31,16 @@ export type Accent = { name: string; onDark: string; onLight: string; - onSepia: string; }; export const ACCENTS: Readonly<Record<AccentId, Accent>> = { - signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73", onSepia: "#2b756e" }, - brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a", onSepia: "#8e6119" }, - vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f", onSepia: "#b3431f" }, - violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4", onSepia: "#6a4bc4" }, - sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a", onSepia: "#a83a6a" }, - blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8", onSepia: "#2d5fb8" }, - green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c", onSepia: "#3d772b" }, + signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73" }, + brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a" }, + vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f" }, + violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4" }, + sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a" }, + blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8" }, + green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c" }, }; // An absent accent reads as this one — on a child site AND on the family's own @@ -58,19 +57,17 @@ export function isAccentId(v: unknown): v is AccentId { // against these, and the browser chrome colour is taken from them. export const BASE_GROUNDS = { light: "#f3f6f7", - sepia: "#f4ecd8", dark: "#0c0a08", } as const; export type BaseGround = keyof typeof BASE_GROUNDS; -export const BASE_GROUND_IDS = ["light", "sepia", "dark"] as const satisfies ReadonlyArray<BaseGround>; +export const BASE_GROUND_IDS = ["light", "dark"] as const satisfies ReadonlyArray<BaseGround>; // The text set ON an accent fill (a filled button, a badge): white on the light -// and sepia bases, the dark ground on the dark base. +// base, the dark ground on the dark base. export const ACCENT_INK: Readonly<Record<BaseGround, string>> = { light: "#ffffff", - sepia: "#ffffff", dark: BASE_GROUNDS.dark, }; diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts @@ -6,10 +6,10 @@ import { resolveAccent } from "./accent"; import { ACCENTS, ACCENT_IDS, BASE_GROUNDS, BASE_GROUND_IDS, contrastRatio, type BaseGround } from "./brand"; // What the browser paints for a perBaseColor value on `base`: tokens.css sets -// `--base-<base>` to 1 and the other two to 0 (`null`: no token sheet at all, +// `--base-<base>` to 1 and the other to 0 (`null`: no token sheet at all, // so every var() takes its fallback). Returns "#rrggbb". function paint(css: string, base: BaseGround | null): string { - const flags = css.replace(/var\(--base-(light|sepia|dark), ([01])\)/g, (_, b, fallback) => + const flags = css.replace(/var\(--base-(light|dark), ([01])\)/g, (_, b, fallback) => base === null ? fallback : b === base ? "1" : "0", ); const channels = [...flags.matchAll(/calc\(([^()]*)\)/g)].map((m) => @@ -38,7 +38,7 @@ test("siteColor: a named accent is its per-base swatch, whatever hex rides along test("siteColor: a custom hex is fitted to each base, as the site's own pages fit it", () => { // A pale hex: 1.43:1 on the light ground as published, so the old card - // painted it nearly invisible on light and sepia. + // painted it nearly invisible on light. const pale = "#f4c2d7"; const fitted = resolveAccent(pale); const css = siteColor({ accent: "#F4C2D7" }, seriesColor(0)); @@ -49,8 +49,8 @@ test("siteColor: a custom hex is fitted to each base, as the site's own pages fi } // Fitted, not as published, where the ground needs it; kept where it reads. assert.deepEqual( - { light: paint(css, "light"), sepia: paint(css, "sepia"), dark: paint(css, "dark") }, - { light: "#846974", sepia: "#7c636e", dark: pale }, + { light: paint(css, "light"), dark: paint(css, "dark") }, + { light: "#846974", dark: pale }, ); // A hex that already reads on a ground is kept there exactly (#cc3366 on // light), and the chart colour is never used for one. @@ -59,7 +59,7 @@ test("siteColor: a custom hex is fitted to each base, as the site's own pages fi }); test("perBaseColor: one value, each base's colour; the light one with no token sheet", () => { - const v = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" }; + const v = { light: "#010203", dark: "#ffeedd" }; const css = perBaseColor(v); for (const base of BASE_GROUND_IDS) assert.equal(paint(css, base), v[base]); assert.equal(paint(css, null), v.light); @@ -68,11 +68,11 @@ test("perBaseColor: one value, each base's colour; the light one with no token s }); test("perBaseColor: anything but a #rrggbb per base throws, never paints rgb(NaN …)", () => { - const ok = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" }; + const ok = { light: "#010203", dark: "#ffeedd" }; for (const bad of ["#fff", "red", "", "var(--brand)", "#12345g", "#1234567"]) { - assert.throws(() => perBaseColor({ ...ok, sepia: bad }), RangeError, bad); + assert.throws(() => perBaseColor({ ...ok, dark: bad }), RangeError, bad); } - assert.throws(() => perBaseColor({ light: "#010203" } as never), /dark|sepia/); + assert.throws(() => perBaseColor({ light: "#010203" } as never), /dark/); }); test("fittedHex: a published hex fitted per base; anything else is undefined", () => { diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts @@ -86,18 +86,18 @@ export function siteChartColors(sites: readonly { accentId?: string }[]): string return slot.map((k) => seriesColor(k)); } -// ONE CSS colour that is `values.light` on the light base, `values.sepia` on -// sepia and `values.dark` on dark — for a colour a component knows and the -// token sheet cannot (a custom hex is per site). tokens.css sets -// `--base-light|sepia|dark` to 1 on their own base and 0 on the others, so each -// channel is a calc() over the three and the browser resolves it to a plain -// rgb() for whichever base is in force, switching with it. It goes anywhere a -// colour goes (a background, a border, color-mix()); the fallbacks paint the -// light value on a page with no token sheet, as `:root` does. +// ONE CSS colour that is `values.light` on the light base and `values.dark` on +// dark — for a colour a component knows and the token sheet cannot (a custom +// hex is per site). tokens.css sets `--base-light|dark` to 1 on their own base +// and 0 on the other, so each channel is a calc() over the two and the browser +// resolves it to a plain rgb() for whichever base is in force, switching with +// it. It goes anywhere a colour goes (a background, a border, color-mix()); +// the fallbacks paint the light value on a page with no token sheet, as +// `:root` does. // // The value is CSS only: never parse it or compare it as a hex. Each value // must be a `#rrggbb` (resolveAccent's output); anything else THROWS rather -// than paint `rgb(NaN …)`, which a browser drops without a word. A fourth +// than paint `rgb(NaN …)`, which a browser drops without a word. A third // base needs its own flag in tokens.css (themeTokens.test.ts holds exactly one // 1 per base, over the same BASE_GROUND_IDS this walks). const RRGGBB = /^#[0-9a-f]{6}$/i; diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -113,7 +113,7 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = { cloudflareProject: "Cloudflare Pages project name this site deploys to (`wrangler pages deploy out --project-name <cloudflareProject>`). Trimmed; blank = none.", accent: - 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.', + 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s accent on every page; a reader does not pick one. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.', siteUrl: "Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.", relatedSites: diff --git a/common/styles/tokens.css b/common/styles/tokens.css @@ -5,20 +5,20 @@ @import "../../common/styles/tokens.css"; A reader theme is TWO independent choices (plans/brand-and-themes.md): - • `html[data-base="light|sepia|dark"]` selects the BASE — the ground, text, + • `html[data-base="light|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. + site server-renders its own, and a reader does not pick one. `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. + query). `: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. + script runs). The dark block is `html[data-base=…]` (0,1,1) and wins over + it; the accent rules come AFTER both 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, @@ -30,13 +30,13 @@ --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. - • `--base-light`, `--base-sepia`, `--base-dark` are 1 on their own base - and 0 on the others. They let a colour that differs per base but is - known only to a component be ONE CSS value: each channel a calc() over - the three (lib/siteColor.ts perBaseColor — a custom-hex site's card on - the homepage and the hub, fitted to each ground). + ACCENTS — themeTokens.test.ts keeps the two equal), so a component + can name an accent's colour on the ground in force. + • `--base-light` and `--base-dark` are 1 on their own base and 0 on the + other. They let a colour that differs per base but is known only to a + component be ONE CSS value: each channel a calc() over the two + (lib/siteColor.ts perBaseColor — a custom-hex site's card on the + homepage and the hub, fitted to each ground). ========================================================================== */ @import "tw-animate-css"; @@ -215,7 +215,6 @@ html[data-base="light"] { /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */ --base-light: 1; - --base-sepia: 0; --base-dark: 0; --brand: var(--swatch-signal); @@ -243,7 +242,7 @@ html[data-base="light"] { chart-1 is a blue, not Signal (ΔE 17.3 from it). chart-6 (release 11) is a rust, Vermilion's family: the sixth site's colour. The only hue family that clears every pair with chart-1..5 on - all three bases is the red-browns (OKLCH h ≈ 15–60); this one's worst + both bases is the red-browns (OKLCH h ≈ 15–60); this one's worst pair with them is CVD ΔE 12.5 deutan and normal 16.6, 8.1:1 on the chart surface and 7.4:1 on the ground. It sits near --state-gone (ΔE 8.4 normal), which is only ever labelled text, never a chart mark. */ @@ -260,91 +259,6 @@ html[data-base="light"] { } /* --------------------------------------------------------------------------- - 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). - --------------------------------------------------------------------------- */ -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: #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)); - - /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */ - --base-light: 0; - --base-sepia: 1; - --base-dark: 0; - - --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-6, the - rust, is light's: worst pair CVD 9.1 deutan, normal 16.6. */ - --chart-1: #3574d6; - --chart-2: #2a7d4f; - --chart-3: #5e3aa8; - --chart-4: #a8741a; - --chart-5: #bb4585; - --chart-6: #823c10; - --chart-surface: #faf4e6; - --chart-grid: rgba(51, 40, 26, 0.09); - --chart-axis: #6b5c43; - --chart-tooltip-bg: #fffaf0; -} - -/* --------------------------------------------------------------------------- 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. @@ -400,7 +314,6 @@ html[data-base="dark"] { /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */ --base-light: 0; - --base-sepia: 0; --base-dark: 1; --brand: var(--swatch-signal); @@ -464,7 +377,7 @@ html[data-accent="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` + `data-accent="custom"` plus the fitted `--accent-custom-light|dark` inline on <html>. */ html[data-accent="custom"] { --brand: var(--swatch-custom); diff --git a/common/views/laneState.ts b/common/views/laneState.ts @@ -45,8 +45,8 @@ export function deriveLaneState({ } // No new palette. These map onto the station tones the channel line already -// uses (see pipeline/tone.ts): a bespoke hue here would be wrong on all three -// bases (light, sepia, dark) at once. Deliberately NOT a second copy +// uses (see pipeline/tone.ts): a bespoke hue here would be wrong on both +// bases (light, dark) at once. Deliberately NOT a second copy // of those maps — flow/OverviewPanel already made one, and three would be a // guarantee they drift. export const LANE_DOT: Record<LaneState, string> = { diff --git a/common/views/pipeline/tone.ts b/common/views/pipeline/tone.ts @@ -1,7 +1,7 @@ import type { StageTone } from "./stageStatus"; // The line invents no colours. Every value below is one of the semantic tokens -// the repo already carries across the three bases, light, sepia and dark +// the repo already carries across the two bases, light and dark // (common/styles/tokens.css); a bespoke hue here would be wrong on every base // at once. diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -8,6 +8,7 @@ - **umtool's production build no longer reads the corpus folder.** Since umtool began finding the corpus from its checkout (the bullet above), `next build` treated the checkout's whole `transcripts/channels` as files to bundle. On a real archive it ran out of memory and was killed, so umtool could not be rebuilt. The build now ignores that folder and finishes in about 25 s at under 1 GB, the same as a checkout with no corpus. Nothing changes when umtool runs. - **A social icon pasted with only a width and height is accepted, and each social link can be shown in a header.** The social-link editors (Settings, a site's form) refused an SVG with no `viewBox`, so a vendor's logo file as downloaded, which often carries only its size, was refused. On save, a root with a numeric width and height (unitless or px) and no viewBox is now given `viewBox="0 0 W H"`; a percentage, `em`, or a missing or zero side is still refused. Each link has a **Show in header** checkbox, stored as `featured: true` only when checked, with the hint "With none checked, the header shows the last four.": a header shows at most four links, the checked ones when any is checked, else the last four (the homepage's header reads it). A file with neither is read and rendered as before. `SETTINGS.md` and `SITE.md` list `featured`. - **A social icon is checked by what it may contain, when it is saved new or edited and every time it is shown, and a refused one says why.** An icon must be one well-formed `<svg>` of shapes, groups, gradients, clips, masks, filters, text and simple animation, with SVG presentation attributes: no script, `style` block, `foreignObject`, link, embedded image, `title`/`desc` with anything but text (text-only ones are removed), or HTML element; no event handler, however it is written; a `style` attribute of presentation properties only; a reference only to something inside the icon, written plainly; and nothing that could load from elsewhere (a CSS escape or comment, `image-set(`, `image(`, `cross-fade(`, `element(`, `src(`, `paint(`, `@import`). Comments, a leading XML declaration and a plain DOCTYPE are removed. A refused save ends with the reason ("… has an invalid SVG: it has an event handler attribute.", "… it links to something outside the icon.") and never repeats the markup; for a drawing program's file it says to export it with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). **Upgrading:** an icon an older build stored is kept as it is when a save does not change it — a pause, a priority or a title still saves — but a page shows it as its label until its SVG is replaced; `archilyzer doctor`'s new "social icons" line names every stored icon that fails, by file and label, with the reason. +- **Two grounds, Light and Dark, and no accent picker in the header.** The editor's header keeps its theme toggle, which cycles System, Light and Dark; the theme menu (Base and Accent) is gone, and the editor wears its own accent, Signal. A stored choice of the retired third ground loads as Light and is rewritten once; a stored accent is not read and is left in storage. A site's accent is still set in its form; the form's hint no longer says a reader can pick another. ## [0.10.0] - 2026-09-28 - **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage. diff --git a/editor/app/globals.css b/editor/app/globals.css @@ -2,7 +2,7 @@ @import "../../common/styles/tokens.css"; @source "../../common/components"; -/* Design tokens, the `dark` variant, the three bases (light / sepia / dark) and +/* Design tokens, the `dark` variant, the two bases (light / 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 + diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -11,7 +11,6 @@ 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 { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; -import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; import { AppFrame } from "./components/AppFrame"; @@ -153,7 +152,6 @@ export default async function RootLayout({ </div> </div> <div className="flex items-center gap-1.5 shrink-0"> - <ThemeMenu /> <ThemeToggle /> </div> </div> diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -253,9 +253,9 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <fieldset className="flex flex-col gap-2 text-sm"> <legend className="font-medium">Brand accent</legend> <p className="text-xs text-muted-foreground"> - This site's default accent (its icon and first paint); a reader - can pick another. A custom colour is darkened or lightened on each - base (light, sepia, dark) until it reads at 4.5:1. + This site's accent (its icon and every page). A custom colour + is darkened or lightened on each base (light, dark) until it reads + at 4.5:1. </p> <div className="flex flex-wrap gap-x-4 gap-y-2"> {ACCENT_IDS.map((id) => ( diff --git a/editor/e2e/theme.spec.ts b/editor/e2e/theme.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "@playwright/test"; import { resetData } from "./helpers"; +import { RETIRED_BASE } from "../../common/components/themeConfig"; // Regression: refreshing the editor must honor the persisted theme. The // pre-paint <ThemeScript> sets `data-base` and `.dark` on <html>, but <html> is @@ -9,7 +10,8 @@ import { resetData } from "./helpers"; // 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. +// keys become a base (archive + light → light) and are deleted; a stored +// retired third ground becomes light. const BASE_KEY = "ytdlp-tb:base"; const LEGACY_THEME_KEY = "ytdlp-tb:theme"; const LEGACY_MODE_KEY = "ytdlp-tb:mode"; @@ -55,14 +57,46 @@ test("explicit light base loads light", async ({ page }) => { await expect(html).toHaveAttribute("data-base", "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(); - const html = page.locator("html"); - await expect(html).toHaveAttribute("data-base", "sepia"); - await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/); +// The retired third ground: a reader who chose it gets Light, before first +// paint, with no other ground on the way, and the stored value becomes +// "light" once. Every value `data-base` holds is recorded by a +// MutationObserver installed before the page's first script. +test.describe("a stored retired base, with the OS dark", () => { + test.use({ colorScheme: "dark" }); + + test("renders Light with no other ground on the way, and is rewritten", async ({ page }) => { + await page.addInitScript( + ([key, retired]) => { + try { + localStorage.setItem(key, retired); + } catch {} + const held: (string | null)[] = []; + (window as unknown as { __bases: (string | null)[] }).__bases = held; + new MutationObserver((records) => { + for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue); + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ["data-base"], + attributeOldValue: true, + }); + }, + [BASE_KEY, RETIRED_BASE], + ); + await page.goto("/", { waitUntil: "commit" }); + await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); + await page.waitForLoadState("load"); + await page.waitForTimeout(300); + const held = await page.evaluate(() => [ + ...(window as unknown as { __bases: (string | null)[] }).__bases, + document.documentElement.getAttribute("data-base"), + ]); + expect(held.filter((v) => v !== null && v !== "light"), JSON.stringify(held)).toEqual([]); + expect(held.at(-1)).toBe("light"); + const html = page.locator("html"); + await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/); + expect(await storage(page)).toEqual(["light", null, null]); + }); }); test.describe("system base with OS dark", () => { @@ -89,13 +123,13 @@ test("migration: a stored selenized + dark becomes the dark base; the old keys g expect(await storage(page)).toEqual(["dark", null, null]); }); -test("migration: a stored archive + light (the paper look) becomes sepia", async ({ +test("migration: a stored archive + light (the paper look) becomes light", 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).toHaveAttribute("data-base", "light"); await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/); - expect(await storage(page)).toEqual(["sepia", null, null]); + expect(await storage(page)).toEqual(["light", null, null]); }); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -4,6 +4,7 @@ - **The charts count every transcript, once the site is rebuilt.** A transcript that arrived after its video was first indexed was missing from the charts' transcript and cue counts and from "Transcribed over time", and a video with YouTube captions alone had no transcription date. Both are counted now, and a captioned video is dated by when its captions arrived. - **A social icon that fails the check is shown as its label, and every icon paints inside its box.** The footer inlines a social link's SVG only if it passes the same check a save runs (what an icon may contain is in `SITE.md`); otherwise the link shows its label as text, at most 10rem with an ellipsis. Each icon is clipped to its own box. Needs a rebuild and deploy of each site. - **A chart's stacked bars and stacked areas are separated by a 2 px gap in the chart card's colour.** A stacked bar's segments were drawn touching; a stacked area's bands each had a line in their own colour along the top. Both now have a 2 px gap in the card's colour between them, and in high-contrast mode the system's background colour. Charts of one series, line charts and side-by-side bars are unchanged. Needs a rebuild and deploy of each site. +- **Two grounds, Light and Dark, and each site in its own accent.** The third ground, the warm paper one, is gone: the header's toggle cycles System, Light and Dark, and the slide-out menu's Base list offers those three. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light (the old paper theme's `archive` + `light` too). The theme menu's accent picker is gone from the header and the slide-out menu: every page wears the site's own accent (`site.json` `accent`), and a reader's stored pick from before is not read and is left in storage. Needs a rebuild and deploy of each site. ## [0.10.0] - 2026-09-28 - **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site. diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -7,7 +7,6 @@ import { resolveRelatedSites, } from "yt-dlp-transcript-common/lib/site"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; -import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { currentSite } from "../lib/site"; @@ -20,7 +19,7 @@ import MobileMenu from "./MobileMenu"; // The export site's masthead: the Found-line mark + the split wordmark, calm // sans nav, and the cross-site "family" chrome (hub backlink + sibling -// switcher). The mark's lit line follows the reader's accent (lib/brand.ts +// switcher). The mark's lit line follows the site's accent (lib/brand.ts // headerMarkPalette); the hub wears the parent mark. The link's accessible name // is the header title exactly — the mark is decorative and the wordmark's two // spans join without a space. All cross-site data is resolved at build time @@ -110,7 +109,6 @@ export default function Header() { > Changelog </Link> - <ThemeMenu /> </div> {/* The one control that stays at every width: theme.spec resolves it by /switch to/i, and one tap to flip light/dark is worth a slot. */} diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx @@ -16,17 +16,16 @@ import type { SwitcherGroup } from "./SiblingSwitcher"; // The phone half of the masthead. Below `md` the header keeps only the brand, // the base toggle and this trigger; everything else the header offers — the -// nav, the sibling sites, the hub backlink, the base and accent pickers — +// nav, the sibling sites, the hub backlink, the base picker — // 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 base and accent lists are read from the client ThemeProvider and -// rendered as NATIVE radio groups ("Base", "Accent"; ThemeRadios), 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 list is read from the client ThemeProvider and rendered as a +// NATIVE radio group ("Base"; ThemeRadios), not as a dropdown inside this +// dialog — one popover layer inside another is a focus-trap fight. There is no +// accent group: each site shows its own. export default function MobileMenu({ links, sites, diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts @@ -15,7 +15,7 @@ // each in its own accent or none: the hex its /site.json published (a named // accent's on-dark value, or the site's own), FITTED to each base like any // custom hex (lib/siteColor.ts fittedHex, release 11 O2b) — so a pale one reads -// on light and sepia on its card, chip and result stripe. A value that is not +// on light on its card, chip and result stripe. A value that is not // a hex is dropped, never passed into a style. // // `listed` says the list is the hub's WHOLE list: `/hub-sites.json` has been diff --git a/export/app/globals.css b/export/app/globals.css @@ -2,7 +2,7 @@ @import "../../common/styles/tokens.css"; @source "../../common/components"; -/* Design tokens, the `dark` variant, the three bases (light / sepia / dark) and +/* Design tokens, the `dark` variant, the two bases (light / 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 diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -25,12 +25,13 @@ import "./globals.css"; // 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). +// Either way the reader cycles the base (ThemeToggle); the accent is the +// site's, and a reader's stored pick from before is ignored. // instanceMode() is server-only; this whole file is a server component. function themeDefaults(): { defaultBase: ThemeBase; accent: ThemeAccent; - // The inline `--accent-custom-light|sepia|dark` a custom-hex site needs + // The inline `--accent-custom-light|dark` a custom-hex site needs // (tokens.css `--swatch-custom` reads them); null for a named accent. accentVars: Record<string, string> | null; } { @@ -100,8 +101,8 @@ export default async function RootLayout({ }>) { // 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 + // custom hex adds its fitted per-base values inline. Nothing replaces it: a + // reader does not pick an accent. 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. diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts @@ -14,10 +14,9 @@ export function iconPalette(): IconPalette { : siteIconPalette(currentSite().accent); } -// 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. tokens.css defines `--brand-mark` on every page (a Signal default on +// The header mark's palette. On a site it is the icon's ink tile, its lit line +// the accent in force — `--brand-mark` is that accent's on-dark value (the +// tile is always ink), the same the favicon wears. 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 { diff --git a/export/e2e-hub/brand.spec.ts b/export/e2e-hub/brand.spec.ts @@ -52,10 +52,10 @@ test("the hub header is the parent mark + Archi|lyzer", async ({ page }) => { // The ring on dark (release 10, slice MR): on the dark base the tile has no // edge, so every mark's tile gets a 1px ring outside it in its palette's dim, // following the ground's corner (rx 112 of 512). A box-shadow, so the mark's -// box is the same on every base; light and sepia draw none. +// box is the same on every base; light draws none. const BASE_KEY = "ytdlp-tb:base"; -async function onBase(page: Page, base: "light" | "sepia" | "dark") { +async function onBase(page: Page, base: "light" | "dark") { await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]); await page.reload({ waitUntil: "commit" }); await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); @@ -79,7 +79,7 @@ async function markRing(mark: Locator) { }); } -test("on dark the hub's header and footer marks have a 1px ring in the slate's dim; on light and sepia none", async ({ +test("on dark the hub's header and footer marks have a 1px ring in the slate's dim; on light none", async ({ page, }) => { await page.route("**/hub-sites.json", (r) => fulfillJson(r, [])); @@ -93,7 +93,7 @@ test("on dark the hub's header and footer marks have a 1px ring in the slate's d expect(await markRing(header)).toEqual({ ring, radius: "21.875%", size: [28, 28] }); expect(await markRing(footer)).toEqual({ ring, radius: "21.875%", size: [16, 16] }); - for (const base of ["light", "sepia"] as const) { + for (const base of ["light"] as const) { await onBase(page, base); expect(await markRing(header), base).toEqual({ ring: [], radius: "21.875%", size: [28, 28] }); expect(await markRing(footer), base).toEqual({ ring: [], radius: "21.875%", size: [16, 16] }); diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts @@ -179,20 +179,16 @@ test.describe("hub official instances", () => { await expect(page.locator("html")).toHaveAttribute("data-base", "dark"); await expect.poll(colour).toBe(rgb(ACCENTS.brass.onDark)); - for (const base of ["light", "sepia"] as const) { - await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base); - await page.reload(); - await expect(page.locator("html")).toHaveAttribute("data-base", base); - await expect - .poll(colour) - .toBe(rgb(base === "light" ? ACCENTS.brass.onLight : ACCENTS.brass.onSepia)); - } + await page.evaluate(() => localStorage.setItem("ytdlp-tb:base", "light")); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-base", "light"); + await expect.poll(colour).toBe(rgb(ACCENTS.brass.onLight)); }); // Release 11 (O2b): an archive the VISITOR added wears the hex its // /site.json published, fitted to the base in force — on its card and on its // scope chip — like any custom hex. A pale one used to be painted as is: - // ~1.4:1 on the light and sepia grounds. + // ~1.4:1 on the light ground. test("an added archive's own hex is fitted to each base, on its card and its chip", async ({ page, }) => { @@ -240,13 +236,12 @@ test.describe("hub official instances", () => { const fitted = resolveAccent(PALE); // Fitted, not as published, where the ground needs it. expect(fitted.light).not.toBe(PALE); - expect(fitted.sepia).not.toBe(PALE); await page.goto("/"); await expect( page.getByRole("heading", { level: 2, name: "Archives You Added", exact: true }), ).toBeVisible(); - for (const base of ["dark", "light", "sepia"] as const) { + for (const base of ["dark", "light"] as const) { if (base !== "dark") { await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base); await page.reload(); diff --git a/export/e2e/brand.spec.ts b/export/e2e/brand.spec.ts @@ -9,7 +9,7 @@ import { installRoutes } from "./helpers"; // site's accent. The fixture site (e2e/fixtures/sites/testsite/site.json) is // headerTitle "Fixture Header", wordmarkLead "Fixture", accent #cc3366. -// The computed fill of each part of a BrandMark, plus what the reader's accent +// The computed fill of each part of a BrandMark, plus what the site's accent // resolves to — so the lit line can be checked against the token it follows // without pinning a colour the themes slice will change. async function markFills(mark: Locator) { @@ -59,7 +59,7 @@ test("the header link is the mark + the split wordmark, named by the header titl await expect(mark).toBeVisible(); }); -test("the header mark is the ink tile, its lit line following the reader's accent", async ({ +test("the header mark is the ink tile, its lit line following the site's accent", async ({ page, }) => { await installRoutes(page); @@ -75,10 +75,10 @@ test("the header mark is the ink tile, its lit line following the reader's accen // The ring on dark (release 10, slice MR): on the dark base a site's ink tile // IS the page, so every mark's tile gets a 1px ring outside it in its own // palette's dim, following the ground's corner (rx 112 of 512). A box-shadow, -// so the mark's box is the same on every base; light and sepia draw none. +// so the mark's box is the same on every base; light draws none. const BASE_KEY = "ytdlp-tb:base"; -async function onBase(page: Page, base: "light" | "sepia" | "dark") { +async function onBase(page: Page, base: "light" | "dark") { await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]); await page.reload({ waitUntil: "commit" }); await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); @@ -102,7 +102,7 @@ async function markRing(mark: Locator) { }); } -test("on dark the header and footer marks' tiles have a 1px ring in their dim; on light and sepia none", async ({ +test("on dark the header and footer marks' tiles have a 1px ring in their dim; on light none", async ({ page, }) => { await installRoutes(page); @@ -122,7 +122,7 @@ test("on dark the header and footer marks' tiles have a 1px ring in their dim; o size: [16, 16], }); - for (const base of ["light", "sepia"] as const) { + for (const base of ["light"] as const) { await onBase(page, base); expect(await markRing(header), base).toEqual({ ring: [], radius: "21.875%", size: [28, 28] }); expect(await markRing(footer), base).toEqual({ ring: [], radius: "21.875%", size: [16, 16] }); diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts @@ -90,10 +90,14 @@ 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 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(); + // The base as a plain radio list, because a popover inside a dialog is a + // focus-trap fight; there is no accent list. + const base = menu.getByRole("radiogroup", { name: "Base" }); + for (const name of ["System", "Light", "Dark"]) { + await expect(base.getByRole("radio", { name, exact: true })).toBeVisible(); + } + await expect(base.getByRole("radio")).toHaveCount(3); + await expect(menu.getByRole("radiogroup", { name: "Accent" })).toHaveCount(0); }); test("the filters sheet applies a filter and the results change", async ({ diff --git a/export/e2e/theme-accent.spec.ts b/export/e2e/theme-accent.spec.ts @@ -1,17 +1,17 @@ import { test, expect, type Page } from "@playwright/test"; -import { ACCENTS, MIN_ACCENT_CONTRAST, contrastRatio } from "../../common/lib/brand"; +import { MIN_ACCENT_CONTRAST, contrastRatio } from "../../common/lib/brand"; import { resolveAccent } from "../../common/lib/accent"; import { installRoutes } from "./helpers"; -// 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. +// A site shows its OWN accent, and a reader does not pick one. The fixture's +// site.json sets the custom hex #cc3366, so the layout renders +// data-accent="custom" with the hex fitted to each ground inline; `--brand` +// follows the base in force. A reader's stored pick from an earlier build is +// ignored — before paint and after hydration — and left where it is. const ACCENT_KEY = "ytdlp-tb:accent"; -const FIXTURE = resolveAccent("#cc3366"); // {id: "custom", light, sepia, dark} +const BASE_KEY = "ytdlp-tb:base"; +const FIXTURE = resolveAccent("#cc3366"); // {id: "custom", light, dark} async function state(page: Page) { return page.evaluate((key) => { @@ -27,136 +27,82 @@ async function state(page: Page) { }, 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(); +async function onBase(page: Page, base: "light" | "dark") { + await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]); + await page.reload({ waitUntil: "commit" }); + await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); + await page.waitForLoadState("load"); } -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 ({ +test("a site wears its own accent, fitted to each base, and offers no accent picker", async ({ page, }) => { await page.emulateMedia({ colorScheme: "light" }); await page.goto("/"); await expect.poll(() => state(page)).toMatchObject({ accent: "custom", + base: "light", stored: null, brand: FIXTURE.light, }); + await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); + await expect(page.getByRole("menuitemradio")).toHaveCount(0); - 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 onBase(page, "dark"); await expect.poll(() => state(page)).toMatchObject({ + accent: "custom", base: "dark", background: "#0c0a08", - brand: ACCENTS.violet.onDark, + brand: FIXTURE.dark, }); }); -test("picking the site's own colour again removes the stored accent", async ({ +// Every value `data-accent` ever holds is recorded by a MutationObserver +// installed before the page's first script, so a flash between two samples +// cannot pass. +test("a stored accent from before is ignored, with no flash, and left in place", 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 }); + await page.addInitScript((key) => { + try { + localStorage.setItem(key, "violet"); + } catch {} + const held: (string | null)[] = []; + (window as unknown as { __accents: (string | null)[] }).__accents = held; + new MutationObserver((records) => { + for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue); + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ["data-accent"], + attributeOldValue: true, + }); + }, ACCENT_KEY); + await page.goto("/", { waitUntil: "commit" }); + await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); + expect((await state(page)).accent).toBe("custom"); + await page.waitForLoadState("load"); + await page.waitForTimeout(300); + const held = await page.evaluate(() => [ + ...(window as unknown as { __accents: (string | null)[] }).__accents, + document.documentElement.getAttribute("data-accent"), + ]); + expect(held.filter((v) => v !== null && v !== "custom"), JSON.stringify(held)).toEqual([]); + expect(await state(page)).toMatchObject({ accent: "custom", stored: "violet", brand: FIXTURE.light }); }); // The search bar's "Press Enter or click Search to apply" wears the site's // accent (release 10). It was `text-warning`, the same yellow on every site. -// It follows the reader's accent and base like any `text-brand`, and stays -// readable on the page ground it sits on. -test("the unapplied-search hint wears the accent, readable on each base", async ({ +// It follows the base like any `text-brand`, and stays readable on the page +// ground it sits on. +test("the unapplied-search hint wears the site's accent, readable on each base", async ({ page, }) => { await installRoutes(page); await page.emulateMedia({ colorScheme: "light" }); await page.goto("/"); const hint = page.getByText("Press Enter or click Search to apply"); - // Typing is an unapplied edit (the input mounts after hydration). - await page.locator('input[data-testid^="leaf-query-"]').first().fill("alpha"); - await expect(hint).toBeVisible(); - await expect(hint).toHaveClass(/(^|\s)text-brand(\s|$)/); - await expect(hint).not.toHaveClass(/text-warning/); // The hint's colour and the ground under it, as "#rrggbb". const paint = () => @@ -173,19 +119,18 @@ test("the unapplied-search hint wears the accent, readable on each base", async }; }); const expectAccent = async (value: string) => { + // Typing is an unapplied edit (the input mounts after hydration). + await page.locator('input[data-testid^="leaf-query-"]').first().fill("alpha"); + await expect(hint).toBeVisible(); + await expect(hint).toHaveClass(/(^|\s)text-brand(\s|$)/); + await expect(hint).not.toHaveClass(/text-warning/); await expect.poll(async () => (await paint()).color).toBe(value); const { color, ground } = await paint(); expect(contrastRatio(color, ground)).toBeGreaterThanOrEqual(MIN_ACCENT_CONTRAST); }; - // The fixture site's own (custom) colour, fitted to the light ground… + // The fixture site's own (custom) colour, fitted to each ground. await expectAccent(FIXTURE.light); - // …a reader's pick… - await pick(page, "Brass"); - await expectAccent(ACCENTS.brass.onLight); - // …and each base's own value of it. - await pick(page, "Sepia"); - await expectAccent(ACCENTS.brass.onSepia); - await pick(page, "Dark"); - await expectAccent(ACCENTS.brass.onDark); + await onBase(page, "dark"); + await expectAccent(FIXTURE.dark); }); diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts @@ -1,8 +1,9 @@ import { test, expect, type Locator, type Page } from "@playwright/test"; +import { RETIRED_BASE } from "../../common/components/themeConfig"; // 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 +// system → light → 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. @@ -59,7 +60,7 @@ test("a site opens on the system base and follows the OS live", async ({ await expect.poll(async () => (await state(page)).base).toBe("light"); }); -test("the toggle cycles the four bases; each persists across a reload with no flash", async ({ +test("the toggle cycles the three bases; each persists across a reload with no flash", async ({ page, }) => { await page.emulateMedia({ colorScheme: "light" }); @@ -68,8 +69,7 @@ test("the toggle cycles the four bases; each persists across a reload with no fl await expect(toggle).toBeVisible(); 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: "light", base: "light", dark: false, background: "#f3f6f7", 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" }, @@ -110,6 +110,49 @@ test("the retired theme/mode keys migrate once, before paint", async ({ theme: localStorage.getItem("ytdlp-tb:theme"), mode: localStorage.getItem("ytdlp-tb:mode"), })); - // 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 }); + // archive + light was the paper look, the retired third ground: it becomes + // light, and the old keys go. + expect(s).toEqual({ base: "light", stored: "light", theme: null, mode: null }); +}); + +// The retired third ground: a reader who chose it gets Light, before first +// paint, with no other ground on the way, and the stored value becomes +// "light" once. Every value `data-base` holds is recorded by a +// MutationObserver installed before the page's first script. +test("a stored retired base renders Light, with no other ground on the way, and is rewritten", async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.addInitScript( + ([key, retired]) => { + try { + localStorage.setItem(key, retired); + } catch {} + const held: (string | null)[] = []; + (window as unknown as { __bases: (string | null)[] }).__bases = held; + new MutationObserver((records) => { + for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue); + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ["data-base"], + attributeOldValue: true, + }); + }, + [BASE_KEY, RETIRED_BASE], + ); + await page.goto("/", { waitUntil: "commit" }); + await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); + expect(await page.evaluate(() => document.documentElement.getAttribute("data-base"))).toBe("light"); + await page.waitForLoadState("load"); + await page.waitForTimeout(300); + const held = await page.evaluate(() => [ + ...(window as unknown as { __bases: (string | null)[] }).__bases, + document.documentElement.getAttribute("data-base"), + ]); + // None (a site's server markup has no base: it is the reader's system), then + // light — never the retired ground, never dark from the OS. + expect(held.filter((v) => v !== null && v !== "light"), JSON.stringify(held)).toEqual([]); + expect(held.at(-1)).toBe("light"); + expect(await state(page)).toMatchObject({ base: "light", dark: false, stored: "light" }); }); diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -3,9 +3,10 @@ ## [Unreleased] - **The social links are in the header, at every width, beside the theme toggle.** The operator's social icons (`homepage.json`'s, else `settings.json`'s) now sit in the header's bar as well as in the footer's Elsewhere column, followed by the theme toggle, all spaced alike. From 768 px wide the bar is wordmark, nav, icons, toggle; below 768 px it is wordmark, icons, toggle, and the nav has the rule below to itself, where its four links fit. The header shows at most four links: the ones marked **Show in header** when any is, else the last four; the footer shows them all. No link is hidden on a small screen: when the icons and the full wordmark do not fit side by side, the wordmark's text is dropped and its mark stays (with three links, the full wordmark shows from 348 px wide with a mouse and from 380 px on a touch screen; with four, from 384 and 424 px). Only on a screen narrower than that for the mark too, or at a much larger text size, do the icons scroll sideways in their own box, the last one in view first. Each is an icon named by its label, with no text beside it. -- **One theme toggle in place of the two theme buttons.** The header's theme menu (Base and Accent) and its base toggle are one toggle, the last of the header's icons, that cycles the ground (System, Light, Sepia, Dark) and is named for the next one. There is no accent to pick: the homepage's is its own, Signal, and an accent stored by an earlier build is ignored here, with no flash. +- **One theme toggle in place of the two theme buttons.** The header's theme menu (Base and Accent) and its base toggle are one toggle, the last of the header's icons, that cycles the ground (System, Light, Dark) and is named for the next one. There is no accent to pick: the homepage's is its own, Signal, and an accent stored by an earlier build is ignored here, with no flash. +- **Two grounds, Light and Dark.** The third ground, the warm paper one, is gone: the toggle cycles System, Light and Dark. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light. - **Changelog is in the footer only.** The header's nav is Docs, Source, Downloads and Stats; the footer's Sections list and the 404 page keep Changelog. -- **Each Official Instances card names its site with the site's wordmark.** The first part of the name is set heavy in the site's own accent and the rest light, as the site's own header sets it (Jer·alyzer, Hasan·alyzer, …), at the card title's size; the accent is fitted to the ground in force and reads above 4:1 on the card on Light, Sepia and Dark. A site with no configured lead, or a summary built before this, shows its title plain as before. `homepage-summary.json` gains an optional `wordmarkLead` per site (still version 5). +- **Each Official Instances card names its site with the site's wordmark.** The first part of the name is set heavy in the site's own accent and the rest light, as the site's own header sets it (Jer·alyzer, Hasan·alyzer, …), at the card title's size; the accent is fitted to the ground in force and reads above 4:1 on the card on Light and Dark. A site with no configured lead, or a summary built before this, shows its title plain as before. `homepage-summary.json` gains an optional `wordmarkLead` per site (still version 5). - **The growth chart's bands are separated by a 2 px gap in the ground's colour.** The gap runs along each band's upper edge where another band sits on it, the same 2 px at every width (it was a 1.25 px line in the page colour); where a band is too thin to give its share and keep a pixel of its own colour at the size the chart is drawn, the two bands touch instead, so no band disappears. In high-contrast mode the gap is the system's background colour. The gridlines are unchanged. The `/stats` charts' stacked areas and stacked bars are separated by the same 2 px gap in the chart panel's colour. - **Larger social links, with a focus ring.** Each icon, in the header and the footer, is a 36 px target around its 20 px glyph (44 px on a touch screen), in the muted text colour and the text colour on hover; the footer's were 20 px, in the faint colour, with no ring. Keyboard focus draws a 2 px ring in the accent, and in high-contrast mode the browser's own focus outline. An icon of two or more colours keeps its colours, and every icon paints inside its own box. A stored icon that fails the check a save runs is shown as its label (at most 10rem, with an ellipsis) instead. - **The e2e no longer reads the checkout's `settings.json` or `homepage.json`.** Its dev server reads `e2e/.e2e-settings.json` (`SETTINGS_FILE`), written by `e2e/fixture-social.ts`: three synthetic icons (a gradient with an outline, one colour, and a two-colour disc pasted with only its size), put through the same check a save runs; and `SITES_DIR` points at an empty directory. `e2e/social.spec.ts` covers the header and footer rows (at every width, with 1, 3 and 4 links, both pointers; the scroll fallback; hostile stored icons that must neither run nor fetch), `e2e/toggle.spec.ts` the theme toggle and the pinned accent, `e2e/svg-vectors.spec.ts` that every accepted icon stays inside its `<svg>` in a real parse, `e2e/growth-chart.spec.ts` the chart's lines, and `e2e/instance-wordmark.spec.ts` the cards' names; specs change the ground through one helper, `chooseTheme` (`e2e/helpers.ts`). @@ -14,13 +15,13 @@ - **The docs' *Building several sites at once* page says what Build all does.** It called the container pipeline opt-in, turned on in the settings. Build all sites builds every site in parallel in containers whenever a container engine is available, and one after another when none is; there is nothing to switch on. - **A single-colour social icon shows on every ground.** The footer's social icons are the operator's (`homepage.json`'s, else `settings.socialLinks`), normalized when they are saved (`normalizeSocialSvg`, release 11 slice O1). An icon drawn in one colour now takes the footer's colour throughout; before, a part that carried its own colour kept it, so X's official logo, which is white, was invisible on the Light ground. An icon of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted. "No fill", gradients, masks, clip paths and animation timing are never changed, and a clip path's own colour does not count, so a one-colour icon exported from Figma follows the footer too. It applies when the settings are next saved, then needs a rebuild and deploy of the homepage. - **In high-contrast mode the header mark's tile keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground and can be as dark as the slate tile, whose ring is only drawn on Dark. In that mode the tile gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners. Nothing changes outside that mode. -- **A sixth official instance has a chart colour of its own.** The growth chart, its legend and `/stats` had five validated colours, so a sixth site fell to a pink within a degree of the fifth's magenta. There is now a sixth, a rust (`--chart-6`: `#823c10` on Light and Sepia, `#a54a08` on Dark), which is Vermilion's hue family, so Jasolyzer's card and its layer will share a hue once it is published. It clears every pair with the other five on all three grounds for colour-blind readers (the dataviz validator, all pairs; worst CVD ΔE 9.1, normal 16.3). Any six sites now wear the six validated colours; `/stats`' sixth channel gets the rust too. -- **A custom-hex accent reads on every ground.** An Official Instances card whose site sets its own hex painted it exactly as set, so a pale one was all but invisible on Light and Sepia. It is now fitted to each ground the way the site's own pages fit it (4.5:1, `resolveAccent`). No live site uses one. +- **A sixth official instance has a chart colour of its own.** The growth chart, its legend and `/stats` had five validated colours, so a sixth site fell to a pink within a degree of the fifth's magenta. There is now a sixth, a rust (`--chart-6`: `#823c10` on Light, `#a54a08` on Dark), which is Vermilion's hue family, so Jasolyzer's card and its layer will share a hue once it is published. It clears every pair with the other five on each ground for colour-blind readers (the dataviz validator, all pairs; worst CVD ΔE 9.1, normal 16.3). Any six sites now wear the six validated colours; `/stats`' sixth channel gets the rust too. +- **A custom-hex accent reads on every ground.** An Official Instances card whose site sets its own hex painted it exactly as set, so a pale one was all but invisible on Light. It is now fitted to each ground the way the site's own pages fit it (4.5:1, `resolveAccent`). No live site uses one. - **The e2e no longer needs the operator's data.** It reads a synthetic summary built by the real summary builder (`e2e/fixture-summary.ts`, six sites, deterministic) instead of `public/homepage-summary.json`, so a fresh clone runs every spec instead of skipping eight. `e2e/fixture-accents.ts` is gone. -- **On the Dark ground the header mark's tile has a thin outline.** Its slate tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines, so the tile's edge shows against the dark page. Light and Sepia are unchanged, and so are the icons. +- **On the Dark ground the header mark's tile has a thin outline.** Its slate tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines, so the tile's edge shows against the dark page. Light is unchanged, and so are the icons. - **The lines inside the Archilyzer mark are easier to see.** The header mark's and the icons' three unlit lines now read at 3:1 against the slate tile instead of 2:1. - **The homepage opens on the Dark ground in Signal, even with JavaScript off, and a reader can pick another ground.** - The header's toggle cycles System → Light → Sepia → Dark; the accent is the homepage's own, + The header's toggle cycles System → Light → Dark; the accent is the homepage's own, Signal. 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, and the chart stacks its diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx @@ -15,7 +15,7 @@ import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; // siteColor() — a named accent as `var(--swatch-<id>)`, its value on the base // in force, from the summary's `accentId`; a custom hex fitted to each base as // the site's own pages fit it (release 11: a pale hex published as is was -// ~1.4:1 on light and sepia); a site with no accent its chart colour. The +// ~1.4:1 on light); a site with no accent its chart colour. The // hub's official cards wear the same (lib/hubSummary.ts officialInstances). // // A CARD STILL READS AS ITS LAYER'S LEGEND, BY HUE. The growth chart draws diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx @@ -40,16 +40,17 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; // (Jeralyzer Brass, Anilyzer Sakura, Bonnellyzer Blue, Hasanalyzer Violet, // Rekietalyzer Green; Jasolyzer Vermilion once it is published): // • adjacent — the stack — in today's order (Jeralyzer, Anilyzer, -// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on every base, worst CVD -// ΔE 13.1 / 11.1 / 11.0 and normal 17.7 / 15.3 / 16.7 (light / sepia / -// dark; today's mapping — release 10's slice MC, re-run in release 11); +// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on both bases, worst CVD +// ΔE 13.1 / 11.0 and normal 17.7 / 16.7 (light / dark; today's mapping — +// release 10's slice MC, re-run in release 11); // with Jasolyzer at any place in it: PASS, worst CVD -// 12.5 / 9.1 / 11.0, normal 16.6 / 15.3 / 16.3 (release 11); +// 12.5 / 11.0, normal 16.6 / 16.3 (release 11); // • all pairs, all six: the palette's own borderline in any order — CVD 6.2 -// / 6.1 / 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with -// the legend, the surface-gap edges and the table), normal ≥ 15.3. The -// sixth slot adds no pair under the target: its worst is CVD 9.1 (sepia, -// against green), normal 16.3 (dark, against amber). +// / 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with the +// legend, the surface-gap edges and the table), normal ≥ 15.3. The sixth +// slot adds no pair under the target (measured on three bases, before the +// third was retired): its worst normal pair is 16.3 (dark, against +// amber). // The accents' own values fail as a chart palette (Brass↔Vermilion ΔE 1.0 // deutan, Blue↔Violet 8.5 normal), which is why the chart wears their hue // families rather than the accents. diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -45,8 +45,8 @@ function NavList({ className }: { className?: string }) { // the operator's links (common/components/SocialLinks.tsx) and then the toggle // that cycles the base (common/components/ThemeToggle.tsx, `variant="bare"`), // dressed alike, their 36 px boxes touching, so every glyph is 16 px from the -// next. The homepage offers no accent control: its accent is pinned -// (layout.tsx). The header shows at most +// next. There is no accent control: the homepage wears its own (layout.tsx). +// The header shows at most // four links (headerSocialLinks); none is hidden by width. Measured with the // wordmark link at 148–152 px, the nav at 276 px and a key 36 px (44 px under a // coarse pointer): diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx @@ -72,10 +72,9 @@ export default function RootLayout({ <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)]"> {/* The project's own site opens on the dark base in Signal, the family's accent. A reader cycles the base with the header's toggle; - the accent is pinned to Signal (the header offers no accent - control), whatever this origin's storage holds. */} - <ThemeScript defaultBase="dark" pinAccent /> - <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT} pinAccent> + the accent is always Signal, whatever this origin's storage holds. */} + <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 @@ -82,9 +82,9 @@ editor. See [Deploy to Cloudflare](/docs/deploy-cloudflare/) for hosting, and Channels live in a single shared pool. A **site** is a selection of them with its 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 +named colours or a hex of your own, and every page of the site wears it; a +reader picks a light or dark ground (or the system's) with the header's theme +toggle. 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 diff --git a/homepage/e2e/brand.spec.ts b/homepage/e2e/brand.spec.ts @@ -40,10 +40,10 @@ test("the icon set is rendered from the parent mark", async ({ request }) => { // The ring on dark (release 10, slice MR): on the dark base the tile has no // edge, so every mark's tile gets a 1px ring outside it in its palette's dim, // following the ground's corner (rx 112 of 512). A box-shadow, so the mark's -// box is the same on every base; light and sepia draw none. +// box is the same on every base; light draws none. const BASE_KEY = "ytdlp-tb:base"; -async function onBase(page: Page, base: "light" | "sepia" | "dark") { +async function onBase(page: Page, base: "light" | "dark") { await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]); await page.reload({ waitUntil: "commit" }); await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); @@ -69,7 +69,7 @@ async function markRing(mark: Locator) { }); } -test("on dark the header mark has a 1px ring in the slate's dim; on light and sepia none", async ({ +test("on dark the header mark has a 1px ring in the slate's dim; on light none", async ({ page, }) => { await page.goto("/"); @@ -85,7 +85,7 @@ test("on dark the header mark has a 1px ring in the slate's dim; on light and se size: [28, 28], }); - for (const base of ["light", "sepia"] as const) { + for (const base of ["light"] as const) { await onBase(page, base); expect(await markRing(mark), base).toEqual({ ring: [], @@ -110,7 +110,7 @@ test.describe("under forced colours", () => { const mark = page .getByRole("link", { name: "Archilyzer home", exact: true }) .locator("svg[data-brand-mark]"); - for (const base of ["dark", "light", "sepia"] as const) { + for (const base of ["dark", "light"] as const) { await onBase(page, base); expect(await page.evaluate(() => matchMedia("(forced-colors: active)").matches)).toBe(true); const got = await mark.evaluate((svg) => { diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts @@ -34,7 +34,7 @@ export const FIXTURE_SUMMARY_NAME = ".e2e-summary.json"; // not charted; the week and day buckets end here. export const FIXTURE_NOW = new Date("2026-09-15T12:00:00Z"); -// A pale custom hex: published as is it would be a ghost on light and sepia. +// A pale custom hex: published as is it would be a ghost on light. export const FIXTURE_PALE_HEX = "#f4c2d7"; // The six sites, in summary order. `accent` is the site.json setting (an diff --git a/homepage/e2e/growth-chart.spec.ts b/homepage/e2e/growth-chart.spec.ts @@ -31,7 +31,7 @@ test("the bands are parted by a 2 px gap in the ground's colour, never the text page, }) => { await page.goto("/"); - for (const base of ["light", "sepia", "dark"] as const) { + for (const base of ["light", "dark"] as const) { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", base); diff --git a/homepage/e2e/helpers.ts b/homepage/e2e/helpers.ts @@ -5,8 +5,8 @@ import { THEME_BASES, nextBase, type ThemeBase } from "../../common/components/t // toggle (common/components/ThemeToggle.tsx, `variant="bare"`), which cycles // the base in nextBase's order, shows the base in force as `data-theme-base`, // and is named "Switch to {next}". Specs that only need a ground set -// `localStorage` instead. The homepage has no accent control (its accent is -// pinned), so there is no accent to choose here. +// `localStorage` instead. No app has an accent control (each shows its own), +// so there is no accent to choose here. export const themeToggle = (page: Page) => page.locator("header").getByRole("button", { name: /^switch to /i }); diff --git a/homepage/e2e/instance-colours.spec.ts b/homepage/e2e/instance-colours.spec.ts @@ -67,7 +67,7 @@ async function colours(page: Page) { return { stripes, legend }; } -async function useBase(page: Page, base: "dark" | "light" | "sepia") { +async function useBase(page: Page, base: "dark" | "light") { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", base); @@ -91,9 +91,9 @@ test("each instance card wears its site's accent on every base, in its chart lay expect([...chart].sort()).toEqual([1, 2, 3, 4, 5, 6].map((k) => `var(--chart-${k})`)); await page.goto("/"); - const on = { dark: "onDark", light: "onLight", sepia: "onSepia" } as const; + const on = { dark: "onDark", light: "onLight" } as const; const pale = resolveAccent(FIXTURE_PALE_HEX); - for (const base of ["dark", "light", "sepia"] as const) { + for (const base of ["dark", "light"] as const) { await useBase(page, base); const { stripes, legend } = await colours(page); expect(stripes).toHaveLength(n); @@ -114,7 +114,7 @@ test("each instance card wears its site's accent on every base, in its chart lay expect(await resolve(page, chart[0])).toBe(await resolve(page, "var(--chart-4)")); expect(hueGap(stripes[0], legend[0]), "brass vs its layer").toBeLessThan(25); - // Site 1: the pale custom hex, FITTED to this base — on light and sepia + // Site 1: the pale custom hex, FITTED to this base — on light // darkened to 4.5:1 (as published it is ~1.4:1 there), on dark as is. expect(stripes[1], "the pale hex, fitted").toBe(await resolve(page, pale[base])); if (base !== "dark") { diff --git a/homepage/e2e/instance-wordmark.spec.ts b/homepage/e2e/instance-wordmark.spec.ts @@ -74,7 +74,7 @@ test("each card's name: the wordmark where there is a lead, tinted in the site's await expect(section(page).getByRole("link", { name: s.siteTitle, exact: true })).toHaveCount(1); } const pale = resolveAccent(FIXTURE_PALE_HEX); - for (const base of ["light", "sepia", "dark"] as const) { + for (const base of ["light", "dark"] as const) { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", base); diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; -import { REQUIRED_TOKENS } from "../../common/components/themeConfig"; +import { REQUIRED_TOKENS, RETIRED_BASE } from "../../common/components/themeConfig"; import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand"; import { chooseTheme } from "./helpers"; @@ -24,7 +24,7 @@ async function htmlState(page: Page) { }, BASE_KEY); } -test("dark + Signal by default; a base chosen in Options persists with no FOUC", async ({ +test("dark + Signal by default; a base chosen with the toggle persists with no FOUC", async ({ page, }) => { // The OS says light: the default is still dark (it is the site's, not the @@ -99,7 +99,7 @@ test("every base declares a complete palette, and the grounds differ", async ({ await page.goto("/"); - const readOn = (base: "light" | "sepia" | "dark") => + const readOn = (base: "light" | "dark") => page.evaluate( ({ base, tokens }) => { const d = document.documentElement; @@ -113,7 +113,7 @@ test("every base declares a complete palette, and the grounds differ", async ({ { base, tokens: TOKENS }, ); - const bases = ["light", "sepia", "dark"] as const; + const bases = ["light", "dark"] as const; const palettes = {} as Record<(typeof bases)[number], Record<string, string>>; for (const b of bases) palettes[b] = await readOn(b); @@ -123,9 +123,49 @@ test("every base declares a complete palette, and the grounds differ", async ({ } expect(palettes[b]["--background"]).toBe(BASE_GROUNDS[b]); } - // …and the three must actually differ, or a base is a label on another's + // …and the two must actually differ, or a base is a label on the other'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); + expect(new Set(bases.map((b) => palettes[b]["--background"])).size).toBe(2); + expect(new Set(bases.map((b) => palettes[b]["--foreground"])).size).toBe(2); + expect(new Set(bases.map((b) => palettes[b]["--chart-1"])).size).toBe(2); +}); + +// The retired third ground: a reader who chose it gets Light, before first +// paint, with no other ground on the way, and the stored value becomes +// "light" once. Every value `data-base` holds is recorded by a +// MutationObserver installed before the page's first script. +test("a stored retired base renders Light, with no other ground on the way, and is rewritten", async ({ + page, +}) => { + await page.addInitScript( + ([key, retired]) => { + try { + localStorage.setItem(key, retired); + } catch {} + const held: (string | null)[] = []; + (window as unknown as { __bases: (string | null)[] }).__bases = held; + new MutationObserver((records) => { + for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue); + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ["data-base"], + attributeOldValue: true, + }); + }, + [BASE_KEY, RETIRED_BASE], + ); + await page.goto("/", { waitUntil: "commit" }); + await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); + expect(await page.evaluate(() => document.documentElement.getAttribute("data-base"))).toBe("light"); + await page.waitForLoadState("load"); + await page.waitForTimeout(300); + const held = await page.evaluate(() => [ + ...(window as unknown as { __bases: (string | null)[] }).__bases, + document.documentElement.getAttribute("data-base"), + ]); + // The server's dark (the markup, before the pre-paint script), then light. + expect(held.filter((v) => v !== "dark" && v !== "light"), JSON.stringify(held)).toEqual([]); + expect(held.slice(1).every((v) => v === "light"), JSON.stringify(held)).toBe(true); + expect(await htmlState(page)).toMatchObject({ base: "light", dark: false, stored: "light" }); }); diff --git a/homepage/e2e/toggle.spec.ts b/homepage/e2e/toggle.spec.ts @@ -4,8 +4,8 @@ import { baseLabel, themeCycle, themeToggle } from "./helpers"; // THE HOMEPAGE'S THEME CONTROL is one toggle that cycles the base // (ThemeToggle, `variant="bare"`), the last key of the header's group; there is -// no options dialog and no accent control, and the accent is pinned to the -// homepage's own. The expected cycle is derived from THEME_BASES / nextBase, so +// no options dialog and no accent control: the accent is the homepage's own. +// The expected cycle is derived from THEME_BASES / nextBase, so // removing a base there needs no change here. const htmlState = (page: Page) => @@ -101,8 +101,8 @@ test("the toggle's focus: the ring, and in forced colours the browser's own outl expect(forced.width).toBeGreaterThan(0); }); -// With no accent control on the homepage, a stored accent (set on this origin -// by an earlier build that had one) must not tint it: the pre-paint script +// With no accent control, a stored accent (set on this origin by an earlier +// build that had one) must not tint the page: the pre-paint script // and the provider both ignore it, and it stays in storage untouched. Every // value `data-accent` ever holds is recorded by a MutationObserver installed // before the page's first script, so a flash between two samples cannot pass.