Archilyzer · Source

archilyzer

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

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:
Mcommon/components/themeConfig.ts | 211++-----------------------------------------------------------------------------
Acommon/lib/themeConfig.ts | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/layout.tsx | 5+++--
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 />