commit 6eafa357249f14f0a739c83fe1dc89b790fb4a50
parent 05e0cba726da093a9883b1e49a722a89b29ad99c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 09:07:20 -0400
common: the theme config moves to lib/ (the source publish reads the pre-paint script, and the publish layer may not import components/); components/themeConfig re-exports it; HOMEPAGE_DEFAULT_BASE is the one name for the homepage's dark default, which its layout passes to ThemeScript and ThemeProvider
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 224 insertions(+), 209 deletions(-)
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -1,207 +1,4 @@
-// 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 toggle, the unit tests and the homepage e2e (REQUIRED_TOKENS).
-// Keys are namespaced under the existing `ytdlp-tb:*` localStorage convention.
-//
-// 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. 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 { BASE_GROUNDS, type AccentId } from "../lib/brand";
-
-// The reader's base ("light" | "dark" | "system"; RETIRED_BASE, from before
-// release 14, is migrated to "light").
-export const BASE_KEY = "ytdlp-tb:base";
-// 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),
-// then deleted.
-export const LEGACY_THEME_KEY = "ytdlp-tb:theme";
-export const LEGACY_MODE_KEY = "ytdlp-tb:mode";
-
-// The two grounds a base resolves to, and the reader's choice (which adds
-// "system").
-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).
-export type ThemeAccent = AccentId | "custom";
-
-// 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: "dark", label: "Dark" },
-];
-
-export function isThemeBase(v: unknown): v is ThemeBase {
- return v === "system" || v === "light" || v === "dark";
-}
-
-// 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, THEME_BASES in order: system → light → dark → system.
-export function nextBase(b: ThemeBase): ThemeBase {
- 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.
-export function resolveBase(b: ThemeBase, systemDark: boolean): ResolvedBase {
- return b === "system" ? (systemDark ? "dark" : "light") : b;
-}
-
-// Every colour token a base block in tokens.css must declare. The failure this
-// guards is silent: a base that omits a token inherits the light block's value
-// (`:root` always matches), so a half-declared palette looks "a bit off" rather
-// than broken, and only on the base nobody checked. The unit test
-// (themeTokens.test.ts) parses tokens.css against this list; the homepage e2e
-// reads every one off the computed style of each base.
-export const REQUIRED_TOKENS = [
- "--background",
- "--foreground",
- "--card",
- "--card-foreground",
- "--popover",
- "--popover-foreground",
- "--primary",
- "--primary-foreground",
- "--secondary",
- "--secondary-foreground",
- "--muted",
- "--muted-foreground",
- "--accent",
- "--accent-foreground",
- "--destructive",
- "--destructive-foreground",
- "--destructive-soft",
- "--border",
- "--border-strong",
- "--input",
- "--ring",
- "--surface",
- "--faint",
- "--panel",
- "--panel-2",
- "--success",
- "--success-foreground",
- "--success-soft",
- "--warning",
- "--warning-foreground",
- "--warning-soft",
- "--info",
- "--info-foreground",
- "--info-soft",
- "--brand",
- "--brand-strong",
- "--brand-soft",
- "--brand-ink",
- "--state-gone",
- "--state-gone-soft",
- "--chart-1",
- "--chart-2",
- "--chart-3",
- "--chart-4",
- "--chart-5",
- "--chart-6",
- "--chart-surface",
- "--chart-grid",
- "--chart-axis",
- "--chart-tooltip-bg",
-] as const;
-
-// The retired keys → a base, once. After it runs, both legacy keys are deleted
-// by the caller, whatever it returned.
-//
-// stored mode result
-// light light (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.
-export function migrateLegacy({
- mode,
-}: {
- theme: string | null;
- mode: string | null;
-}): ThemeBase | null {
- if (mode === "light") return "light";
- if (mode === "dark") return "dark";
- if (mode === "system") return "system";
- return null;
-}
-
-// The pre-paint script, as the string ThemeScript.tsx inlines. It runs
-// synchronously before first paint, in this order:
-// 1. when no base is stored yet, migrate the legacy keys (migrateLegacy's
-// table); then delete both legacy keys;
-// 2. 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.
-export function buildThemeScript({
- defaultBase = "system",
-}: { defaultBase?: ThemeBase } = {}): string {
- const fallback: ThemeBase = isThemeBase(defaultBase) ? defaultBase : "system";
- const q = JSON.stringify;
- return (
- "(function(){try{" +
- "var d=document.documentElement,b=null,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'?'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)});` +
- "}" +
- `if(b===${q(RETIRED_BASE)}){b='light';s.setItem(${q(BASE_KEY)},'light');}` +
- "}catch(e){}" +
- `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');" +
- `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){}})();"
- );
-}
+// The theme's constants, types and pre-paint script live in lib/themeConfig.ts
+// (the source publish reads the script too, and the publish layer may not
+// import components/). Re-exported here for the UI and its tests.
+export * from "../lib/themeConfig";
diff --git a/common/lib/themeConfig.ts b/common/lib/themeConfig.ts
@@ -0,0 +1,217 @@
+// 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 toggle, the unit tests and the homepage e2e (REQUIRED_TOKENS) —
+// and by the source publish, which puts the homepage's pre-paint script on
+// every history page (publish/sourceHistory.ts). That last reader is why this
+// module lives in lib/: the publish layer may not import components/, which
+// re-exports it (components/themeConfig.ts) for the UI's importers.
+// Keys are namespaced under the existing `ytdlp-tb:*` localStorage convention.
+//
+// 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. 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 { BASE_GROUNDS, type AccentId } from "./brand";
+
+// The reader's base ("light" | "dark" | "system"; RETIRED_BASE, from before
+// release 14, is migrated to "light").
+export const BASE_KEY = "ytdlp-tb:base";
+// 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),
+// then deleted.
+export const LEGACY_THEME_KEY = "ytdlp-tb:theme";
+export const LEGACY_MODE_KEY = "ytdlp-tb:mode";
+
+// The two grounds a base resolves to, and the reader's choice (which adds
+// "system").
+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).
+export type ThemeAccent = AccentId | "custom";
+
+// 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: "dark", label: "Dark" },
+];
+
+// The project site's base for a reader who has stored none: it opens on the
+// dark ground (homepage/app/layout.tsx passes it to ThemeScript and
+// ThemeProvider). The source's history pages run the same pre-paint script
+// with it, so they open where the homepage does.
+export const HOMEPAGE_DEFAULT_BASE: ThemeBase = "dark";
+
+export function isThemeBase(v: unknown): v is ThemeBase {
+ return v === "system" || v === "light" || v === "dark";
+}
+
+// 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, THEME_BASES in order: system → light → dark → system.
+export function nextBase(b: ThemeBase): ThemeBase {
+ 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.
+export function resolveBase(b: ThemeBase, systemDark: boolean): ResolvedBase {
+ return b === "system" ? (systemDark ? "dark" : "light") : b;
+}
+
+// Every colour token a base block in tokens.css must declare. The failure this
+// guards is silent: a base that omits a token inherits the light block's value
+// (`:root` always matches), so a half-declared palette looks "a bit off" rather
+// than broken, and only on the base nobody checked. The unit test
+// (themeTokens.test.ts) parses tokens.css against this list; the homepage e2e
+// reads every one off the computed style of each base.
+export const REQUIRED_TOKENS = [
+ "--background",
+ "--foreground",
+ "--card",
+ "--card-foreground",
+ "--popover",
+ "--popover-foreground",
+ "--primary",
+ "--primary-foreground",
+ "--secondary",
+ "--secondary-foreground",
+ "--muted",
+ "--muted-foreground",
+ "--accent",
+ "--accent-foreground",
+ "--destructive",
+ "--destructive-foreground",
+ "--destructive-soft",
+ "--border",
+ "--border-strong",
+ "--input",
+ "--ring",
+ "--surface",
+ "--faint",
+ "--panel",
+ "--panel-2",
+ "--success",
+ "--success-foreground",
+ "--success-soft",
+ "--warning",
+ "--warning-foreground",
+ "--warning-soft",
+ "--info",
+ "--info-foreground",
+ "--info-soft",
+ "--brand",
+ "--brand-strong",
+ "--brand-soft",
+ "--brand-ink",
+ "--state-gone",
+ "--state-gone-soft",
+ "--chart-1",
+ "--chart-2",
+ "--chart-3",
+ "--chart-4",
+ "--chart-5",
+ "--chart-6",
+ "--chart-surface",
+ "--chart-grid",
+ "--chart-axis",
+ "--chart-tooltip-bg",
+] as const;
+
+// The retired keys → a base, once. After it runs, both legacy keys are deleted
+// by the caller, whatever it returned.
+//
+// stored mode result
+// light light (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.
+export function migrateLegacy({
+ mode,
+}: {
+ theme: string | null;
+ mode: string | null;
+}): ThemeBase | null {
+ if (mode === "light") return "light";
+ if (mode === "dark") return "dark";
+ if (mode === "system") return "system";
+ return null;
+}
+
+// The pre-paint script, as the string ThemeScript.tsx inlines. It runs
+// synchronously before first paint, in this order:
+// 1. when no base is stored yet, migrate the legacy keys (migrateLegacy's
+// table); then delete both legacy keys;
+// 2. 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.
+export function buildThemeScript({
+ defaultBase = "system",
+}: { defaultBase?: ThemeBase } = {}): string {
+ const fallback: ThemeBase = isThemeBase(defaultBase) ? defaultBase : "system";
+ const q = JSON.stringify;
+ return (
+ "(function(){try{" +
+ "var d=document.documentElement,b=null,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'?'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)});` +
+ "}" +
+ `if(b===${q(RETIRED_BASE)}){b='light';s.setItem(${q(BASE_KEY)},'light');}` +
+ "}catch(e){}" +
+ `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');" +
+ `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/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { fontVars } from "yt-dlp-transcript-common/styles/fonts";
import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript";
import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider";
+import { HOMEPAGE_DEFAULT_BASE } from "yt-dlp-transcript-common/lib/themeConfig";
import { BASE_GROUNDS, DEFAULT_ACCENT } from "yt-dlp-transcript-common/lib/brand";
import {
PROJECT_NAME,
@@ -73,8 +74,8 @@ export default function RootLayout({
{/* 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 always Signal, whatever this origin's storage holds. */}
- <ThemeScript defaultBase="dark" />
- <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT}>
+ <ThemeScript defaultBase={HOMEPAGE_DEFAULT_BASE} />
+ <ThemeProvider defaultBase={HOMEPAGE_DEFAULT_BASE} siteAccent={DEFAULT_ACCENT}>
<Header />
<main className="flex-1 w-full">{children}</main>
<Footer />