// 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 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