Archilyzer · Source

archilyzer

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

commit 5f43d5211feee7120ba2f253f64675939259d299
parent 7614b292e5e274558b528443581e94c8addf8033
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 30 Jun 2026 00:09:07 -0400

redesign Phase 0: shared theme foundation (tokens, fonts, no-FOUC theming)

First phase of the family-wide UI redesign. Establishes one shared design
foundation across all three apps (editor, export, homepage) at visual parity —
same look as before, but now driven by a shared token system, a unified font
voice, and a real (class-based) light/dark/theme toggle instead of OS-only dark.

Shared foundation (new, in common/):
- styles/tokens.css — single source of truth for design tokens. Defines
  `@custom-variant dark`, the `@theme inline` color+font mappings, and themes as
  selector blocks on two orthogonal axes: `html[data-theme=…]` (family) and
  `html.dark` (mode). Ships the neutral "base" family (reproduces the prior
  zinc look for editor/export) plus the "archive" family in paper (light) and
  ink (dark). Chart axis/grid/surface/tooltip tokens — previously only on the
  homepage — are hoisted here for every theme so editor/export charts get proper
  strokes too.
- styles/fonts.ts — one next/font/google call site exporting `fontVars` and the
  canonical `--font-display|sans|mono` tokens (Adobe Source super-family),
  collapsing the prior three setups (export=Geist, editor=system, homepage=Source).
- components/themeConfig.ts — shared keys (`ytdlp-tb:theme|mode|accent`) + types.
- components/ThemeScript.tsx — server component emitting a pre-paint inline script
  that sets data-theme/.dark/--accent from localStorage before first paint (no
  FOUC), identical for static export and the server editor.
- components/ThemeProvider.tsx + useTheme — runtime controller. The inline script
  owns first paint; the provider never writes the DOM on mount (only on explicit
  user changes and live OS-preference changes), so a persisted mode that differs
  from a layout's default no longer flickers on reload.
- components/ThemeToggle.tsx — minimal light→dark→system cycle (lucide icons);
  re-skinned onto the shadcn kit in a later phase.

App wiring (all three):
- globals.css now `@import "../../common/styles/tokens.css"` (relative path —
  guaranteed to resolve, same base as the existing `@source`); local token
  blocks removed; homepage keeps only its bespoke atmosphere/.reveal/.panel.
- layouts spread `fontVars` onto <html> (+ suppressHydrationWarning), render
  <ThemeScript/> as the first body child, and wrap content in <ThemeProvider/>.
  Defaults: homepage = archive/ink (its warm look, now togglable); export +
  editor = base/system (parity, now togglable). A ThemeToggle is placed in the
  homepage header, the export header, and the editor sidebar.

Tests:
- homepage/e2e/theme.spec.ts, export/e2e/theme.spec.ts — assert the family
  default, that the mode toggle applies + persists across reloads, and that the
  persisted state is present pre-hydration (no FOUC).

Verification: typecheck clean (common, editor, export, homepage); homepage
production build + static export succeeds (proves cross-package @import and
next/font-in-common in prod); both theme specs pass; editor/export/homepage dev
smokes render with fonts + theme script + toggle and no compile/console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Acommon/components/ThemeProvider.tsx | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/ThemeScript.tsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/ThemeToggle.tsx | 36++++++++++++++++++++++++++++++++++++
Acommon/components/themeConfig.ts | 26++++++++++++++++++++++++++
Acommon/styles/fonts.ts | 35+++++++++++++++++++++++++++++++++++
Acommon/styles/tokens.css | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/globals.css | 55+++++--------------------------------------------------
Meditor/app/layout.tsx | 34++++++++++++++++++++++++----------
Mexport/app/globals.css | 50+++++---------------------------------------------
Mexport/app/layout.tsx | 42+++++++++++++++++++++---------------------
Aexport/e2e/theme.spec.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/components/Header.tsx | 2++
Mhomepage/app/globals.css | 60++++++------------------------------------------------------
Mhomepage/app/layout.tsx | 38+++++++++++++-------------------------
Ahomepage/e2e/theme.spec.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 620 insertions(+), 205 deletions(-)

diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { + THEME_KEY, + MODE_KEY, + type ThemeFamily, + type ThemeMode, +} from "./themeConfig"; + +// Runtime theme controller. The pre-paint <ThemeScript/> is the single source of +// truth for FIRST paint — it has already set the correct attributes on <html> +// from localStorage. This provider must therefore NEVER write to the DOM on +// mount (doing so would briefly fight the script whenever the persisted mode +// differs from `defaultMode`, causing a flash). It only writes to the DOM in +// response to explicit user changes and live OS-preference changes; on mount it +// merely adopts the persisted values into React state for the toggle UI. + +type ThemeContextValue = { + theme: ThemeFamily; + mode: ThemeMode; + /** Whether dark is currently applied (resolves "system"). */ + isDark: boolean; + setTheme: (t: ThemeFamily) => void; + setMode: (m: ThemeMode) => void; + /** Cycle light → dark → system → light. */ + cycleMode: () => void; +}; + +const ThemeContext = createContext<ThemeContextValue | null>(null); + +function systemPrefersDark(): boolean { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +function resolveDark(mode: ThemeMode): boolean { + return mode === "system" ? systemPrefersDark() : mode === "dark"; +} + +function applyToDom(theme: ThemeFamily, dark: boolean) { + const d = document.documentElement; + if (theme && theme !== "base") d.setAttribute("data-theme", theme); + else d.removeAttribute("data-theme"); + d.classList.toggle("dark", dark); +} + +export function ThemeProvider({ + defaultTheme = "base", + defaultMode = "system", + children, +}: { + defaultTheme?: ThemeFamily; + defaultMode?: ThemeMode; + children: React.ReactNode; +}) { + // Initial state MUST equal what the server rendered (defaults), so hydration + // matches; the persisted values are adopted in an effect just below. + const [theme, setThemeState] = useState<ThemeFamily>(defaultTheme); + const [mode, setModeState] = useState<ThemeMode>(defaultMode); + + // Adopt persisted values into React state (NOT the DOM — the script owns that). + useEffect(() => { + try { + const t = localStorage.getItem(THEME_KEY) as ThemeFamily | null; + const m = localStorage.getItem(MODE_KEY) as ThemeMode | null; + if (t) setThemeState(t); + if (m) setModeState(m); + } catch { + /* ignore */ + } + }, []); + + // Track OS changes while in "system" mode (the only case the script can't + // keep live after load). + useEffect(() => { + if (mode !== "system") return; + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => applyToDom(theme, mql.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, [mode, theme]); + + const setTheme = useCallback( + (t: ThemeFamily) => { + setThemeState(t); + applyToDom(t, resolveDark(mode)); + try { + localStorage.setItem(THEME_KEY, t); + } catch { + /* ignore */ + } + }, + [mode], + ); + + const setMode = useCallback( + (m: ThemeMode) => { + setModeState(m); + applyToDom(theme, resolveDark(m)); + try { + localStorage.setItem(MODE_KEY, m); + } catch { + /* ignore */ + } + }, + [theme], + ); + + const cycleMode = useCallback(() => { + setMode(mode === "light" ? "dark" : mode === "dark" ? "system" : "light"); + }, [mode, setMode]); + + const value = useMemo<ThemeContextValue>( + () => ({ theme, mode, isDark: resolveDark(mode), setTheme, setMode, cycleMode }), + [theme, mode, setTheme, setMode, cycleMode], + ); + + return ( + <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} diff --git a/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx @@ -0,0 +1,50 @@ +import { + THEME_KEY, + MODE_KEY, + ACCENT_KEY, + type ThemeFamily, + type ThemeMode, +} from "./themeConfig"; + +// Server component (NOT "use client"): renders an inline <script> that runs +// synchronously BEFORE first paint, so there is no flash of the wrong theme. +// Reads localStorage and sets `data-theme` (family) + `.dark` (mode) + an +// optional per-user `--accent` override on <html>. Works identically for +// static-export sites and the server editor. Pair with `suppressHydrationWarning` +// on <html> since this mutates the element before React hydrates. +// +// Accent precedence (highest first): user override (this script) > per-site +// default (an inline <html style="--accent:…"> baked at build) > family token. +export function ThemeScript({ + defaultTheme = "base", + defaultMode = "system", +}: { + defaultTheme?: ThemeFamily; + defaultMode?: ThemeMode; +}) { + const js = + "(function(){try{" + + "var d=document.documentElement;" + + "var t=localStorage.getItem(" + + JSON.stringify(THEME_KEY) + + ")||" + + JSON.stringify(defaultTheme) + + ";" + + "if(t&&t!=='base'){d.setAttribute('data-theme',t);}else{d.removeAttribute('data-theme');}" + + "var m=localStorage.getItem(" + + JSON.stringify(MODE_KEY) + + ")||" + + JSON.stringify(defaultMode) + + ";" + + "var dark=m==='dark'||(m!=='light'&&window.matchMedia('(prefers-color-scheme: dark)').matches);" + + "d.classList.toggle('dark',dark);" + + "var a=localStorage.getItem(" + + JSON.stringify(ACCENT_KEY) + + ");" + + "if(a){d.style.setProperty('--accent',a);}" + + "}catch(e){}})();"; + + return ( + <script dangerouslySetInnerHTML={{ __html: js }} suppressHydrationWarning /> + ); +} diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Sun, Moon, Monitor } from "lucide-react"; +import { useTheme } from "./ThemeProvider"; +import { cn } from "../lib/utils"; + +// Minimal mode toggle: cycles light → dark → system. A plain, dependency-light +// control for Phase 0; it gets re-skinned onto the shadcn kit (and gains a +// family/theme menu) in later phases. Must be rendered inside a <ThemeProvider/>. +const ICON = { light: Sun, dark: Moon, system: Monitor } as const; +const NEXT_LABEL = { + light: "Switch to dark", + dark: "Switch to system", + system: "Switch to light", +} as const; + +export function ThemeToggle({ className }: { className?: string }) { + const { mode, cycleMode } = useTheme(); + const Icon = ICON[mode]; + + return ( + <button + type="button" + onClick={cycleMode} + aria-label={NEXT_LABEL[mode]} + title={`Theme: ${mode}`} + data-theme-mode={mode} + className={cn( + "inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] hover:border-[var(--border-strong,var(--border))]", + className, + )} + > + <Icon className="h-4 w-4" aria-hidden="true" /> + </button> + ); +} diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts @@ -0,0 +1,26 @@ +// Shared, dependency-free theme constants + types used by both the pre-paint +// ThemeScript (server) and the runtime ThemeProvider (client). Keys are +// namespaced under the existing `ytdlp-tb:*` localStorage convention. + +export const THEME_KEY = "ytdlp-tb:theme"; +export const MODE_KEY = "ytdlp-tb:mode"; +export const ACCENT_KEY = "ytdlp-tb:accent"; + +// Theme FAMILY (palette personality) — orthogonal to light/dark MODE. +// "base" is the neutral default (no data-theme attribute). The rejected design +// directions ship here as selectable families in a later phase. +export type ThemeFamily = "base" | "archive" | "terminal" | "swiss"; + +export type ThemeMode = "light" | "dark" | "system"; + +// Families currently wired (Terminal/Swiss arrive in the polish phase). +export const THEME_FAMILIES: { id: ThemeFamily; label: string }[] = [ + { id: "base", label: "Base" }, + { id: "archive", label: "Archive" }, +]; + +export const THEME_MODES: { id: ThemeMode; label: string }[] = [ + { id: "light", label: "Light" }, + { id: "dark", label: "Dark" }, + { id: "system", label: "System" }, +]; diff --git a/common/styles/fonts.ts b/common/styles/fonts.ts @@ -0,0 +1,35 @@ +// Shared font source of truth for the whole family. One `next/font/google` call +// site, three canonical CSS-variable tokens consumed by tokens.css's @theme: +// --font-display (serif: wordmark, headline numerals) +// --font-sans (body / UI) +// --font-mono (queries, timestamps, IDs — the "power layer") +// +// Each app's root layout imports `fontVars` and spreads it onto <html>. This +// collapses the previous three divergent setups (export=Geist, editor=system, +// homepage=Source) onto one Adobe Source super-family voice. +// +// `next/font` is transformed by Next's SWC loader across the whole compiled +// module graph, and `transpilePackages: ["yt-dlp-transcript-common"]` puts this +// module in that graph for every app — so a shared call site works. If a build +// ever balks, the fallback is to duplicate these three calls per layout using +// the same variable names; the CSS token contract is the real invariant. +import { Source_Serif_4, Source_Sans_3, Source_Code_Pro } from "next/font/google"; + +export const fontDisplay = Source_Serif_4({ + variable: "--font-display", + subsets: ["latin"], + style: ["normal", "italic"], +}); + +export const fontSans = Source_Sans_3({ + variable: "--font-sans", + subsets: ["latin"], +}); + +export const fontMono = Source_Code_Pro({ + variable: "--font-mono", + subsets: ["latin"], +}); + +// Space-joined `.variable` classes for <html className={...}>. +export const fontVars = `${fontDisplay.variable} ${fontSans.variable} ${fontMono.variable}`; diff --git a/common/styles/tokens.css b/common/styles/tokens.css @@ -0,0 +1,150 @@ +/* ============================================================================= + Shared design tokens — the single source of truth for the whole family. + + Imported by every app's globals.css right after `@import "tailwindcss"`: + @import "../../common/styles/tokens.css"; + + Two orthogonal axes: + • `html[data-theme="…"]` selects the THEME FAMILY (palette personality). + • `html.dark` selects light/dark MODE within that family. + The `@custom-variant dark` below makes Tailwind's `dark:` utilities follow the + `.dark` class (not the OS media query), so a pre-paint script can set both axes + from localStorage. Default (no data-theme) = the neutral "base" family, which + reproduces the previous zinc look so editor/export keep visual parity. + + NOTE: the full shadcn "new-york" token contract (--primary, --ring, --input, + --card, --popover, --destructive, …) is intentionally NOT defined yet — it + lands in Phase 1 alongside the shadcn kit, together with resolving the + shadcn-`--accent` vs brand-`--accent` naming. For now `--accent` is the brand + brass (archive) / blue (base), matching existing usage. + ========================================================================== */ + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-border: var(--border); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --font-display: var(--font-display); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); +} + +/* --------------------------------------------------------------------------- + BASE family (default — no data-theme). Neutral zinc, blue accent. Reproduces + the prior editor/export look so Phase 0 is visual parity. Chart axis/grid/ + surface/tooltip tokens are now defined here too (they previously existed only + on the homepage), so editor/export charts get proper strokes. + --------------------------------------------------------------------------- */ +:root { + --background: #ffffff; + --foreground: #171717; + --border: #e4e4e7; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --accent: #2563eb; + --accent-strong: #1d4ed8; + --accent-soft: rgba(37, 99, 235, 0.12); + + --chart-1: #2563eb; + --chart-2: #16a34a; + --chart-3: #ea580c; + --chart-4: #9333ea; + --chart-5: #db2777; + --chart-surface: #ffffff; + --chart-grid: rgba(0, 0, 0, 0.08); + --chart-axis: #71717a; + --chart-tooltip-bg: #ffffff; +} + +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --border: #27272a; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --accent: #60a5fa; + --accent-strong: #93c5fd; + --accent-soft: rgba(96, 165, 250, 0.16); + + --chart-1: #60a5fa; + --chart-2: #4ade80; + --chart-3: #fb923c; + --chart-4: #c084fc; + --chart-5: #f472b6; + --chart-surface: #0a0a0a; + --chart-grid: rgba(255, 255, 255, 0.08); + --chart-axis: #a1a1aa; + --chart-tooltip-bg: #18181b; +} + +/* --------------------------------------------------------------------------- + ARCHIVE family — the public flagship "Reading Room". Brass accent. + Ships paper (light) + ink (dark). The ink values are the homepage's existing + warm-ink palette, hoisted here verbatim so the homepage is pixel-parity when + it defaults to data-theme="archive" + dark. + --------------------------------------------------------------------------- */ +[data-theme="archive"] { + /* Paper (light) — warm aged-paper ground, darkened brass for contrast. */ + --background: #faf6ec; + --surface: #f4ecdb; + --foreground: #211a0e; + --border: rgba(40, 30, 12, 0.12); + --border-strong: rgba(40, 30, 12, 0.2); + --muted: #efe6d3; + --muted-foreground: #6b6250; + --faint: #948b78; + --panel: rgba(255, 252, 244, 0.7); + --panel-2: rgba(244, 236, 219, 0.7); + --accent: #95661a; + --accent-strong: #714d10; + --accent-soft: rgba(149, 102, 26, 0.14); + --accent-ink: #fff7e7; + + --chart-1: #2563eb; + --chart-2: #0f9d6b; + --chart-3: #c2410c; + --chart-4: #7c3aed; + --chart-5: #be185d; + --chart-surface: #fbf7ef; + --chart-grid: rgba(40, 30, 12, 0.08); + --chart-axis: #8a7f66; + --chart-tooltip-bg: #fffaf0; +} + +[data-theme="archive"].dark { + /* Ink (dark) — the homepage's warm near-black "archive room". */ + --background: #0c0a08; + --surface: #141009; + --foreground: #efe7d8; + --border: rgba(233, 220, 197, 0.1); + --border-strong: rgba(233, 220, 197, 0.18); + --muted: #1c1710; + --muted-foreground: #a39a86; + --faint: #6f6757; + --panel: rgba(28, 23, 15, 0.55); + --panel-2: rgba(40, 33, 21, 0.5); + --accent: #e3b15c; + --accent-strong: #f3c977; + --accent-soft: rgba(227, 177, 92, 0.14); + --accent-ink: #1a1305; + + --chart-1: #6ea8ff; + --chart-2: #54d6a0; + --chart-3: #f2935b; + --chart-4: #c08cf0; + --chart-5: #f178b6; + --chart-surface: #16110a; + --chart-grid: rgba(233, 220, 197, 0.08); + --chart-axis: #8a8170; + --chart-tooltip-bg: #1b150d; +} diff --git a/editor/app/globals.css b/editor/app/globals.css @@ -1,58 +1,13 @@ @import "tailwindcss"; +@import "../../common/styles/tokens.css"; @source "../../common/components"; -:root { - --background: #ffffff; - --foreground: #171717; - /* Semantic tokens consumed by shadcn/ui chart primitives. */ - --border: #e4e4e7; - --muted: #f4f4f5; - --muted-foreground: #71717a; - /* Chart series palette. */ - --chart-1: #2563eb; - --chart-2: #16a34a; - --chart-3: #ea580c; - --chart-4: #9333ea; - --chart-5: #db2777; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-border: var(--border); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --border: #27272a; - --muted: #27272a; - --muted-foreground: #a1a1aa; - --chart-1: #60a5fa; - --chart-2: #4ade80; - --chart-3: #fb923c; - --chart-4: #c084fc; - --chart-5: #f472b6; - } -} +/* Design tokens, the `dark` variant, and theme palettes live in + common/styles/tokens.css. The editor stays on the neutral "base" family in + Phase 0 (visual parity); the command-first cockpit restyle lands later. */ body { background: var(--background); color: var(--foreground); - font-family: - system-ui, - -apple-system, - "Segoe UI", - Roboto, - Helvetica, - Arial, - sans-serif; + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; } diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -7,6 +7,10 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog"; import { listSites } from "yt-dlp-transcript-common/lib/site"; +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 { AppFrame } from "./components/AppFrame"; import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; @@ -142,21 +146,30 @@ export default async function RootLayout({ ); }; return ( - <html lang="en" className="h-full antialiased"> + <html + lang="en" + suppressHydrationWarning + className={`${fontVars} h-full antialiased`} + > <body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> + <ThemeScript /> + <ThemeProvider> <AppFrame sidebar={ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col"> - <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800"> - <Link - href="/" - className="font-semibold tracking-tight block leading-tight" - > - {headerLabel} - </Link> - <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5"> - editor + <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800 flex items-start justify-between gap-2"> + <div className="min-w-0"> + <Link + href="/" + className="font-semibold tracking-tight block leading-tight" + > + {headerLabel} + </Link> + <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5"> + editor + </div> </div> + <ThemeToggle /> </div> <Suspense fallback={null}> <SiteScopeSelect sites={sites} /> @@ -189,6 +202,7 @@ export default async function RootLayout({ > {children} </AppFrame> + </ThemeProvider> </body> </html> ); diff --git a/export/app/globals.css b/export/app/globals.css @@ -1,53 +1,13 @@ @import "tailwindcss"; +@import "../../common/styles/tokens.css"; @source "../../common/components"; -:root { - --background: #ffffff; - --foreground: #171717; - /* Semantic tokens consumed by shadcn/ui chart primitives. */ - --border: #e4e4e7; - --muted: #f4f4f5; - --muted-foreground: #71717a; - /* Chart series palette. */ - --chart-1: #2563eb; - --chart-2: #16a34a; - --chart-3: #ea580c; - --chart-4: #9333ea; - --chart-5: #db2777; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-border: var(--border); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --border: #27272a; - --muted: #27272a; - --muted-foreground: #a1a1aa; - --chart-1: #60a5fa; - --chart-2: #4ade80; - --chart-3: #fb923c; - --chart-4: #c084fc; - --chart-5: #f472b6; - } -} +/* Design tokens, the `dark` variant, and theme palettes live in + common/styles/tokens.css. Export stays on the neutral "base" family in + Phase 0 (visual parity); the Archive restyle lands in a later phase. */ body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; } diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -1,21 +1,14 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link"; +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 { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider"; import { currentSite } from "./lib/site"; import Footer from "./components/Footer"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export function generateMetadata(): Metadata { const s = currentSite(); return { @@ -36,10 +29,13 @@ export default async function RootLayout({ return ( <html lang="en" - className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} + suppressHydrationWarning + className={`${fontVars} h-full antialiased`} > <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans"> - <QueryProvider> + <ThemeScript /> + <ThemeProvider> + <QueryProvider> <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20"> <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-3"> <div className="flex items-center gap-5 min-w-0"> @@ -61,14 +57,17 @@ export default async function RootLayout({ </Link> </nav> </div> - <nav className="text-sm shrink-0"> - <Link - href="/changelog" - className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100" - > - Changelog - </Link> - </nav> + <div className="flex items-center gap-3 shrink-0"> + <nav className="text-sm"> + <Link + href="/changelog" + className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100" + > + Changelog + </Link> + </nav> + <ThemeToggle /> + </div> </div> </header> <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6"> @@ -76,6 +75,7 @@ export default async function RootLayout({ </main> <Footer /> </QueryProvider> + </ThemeProvider> </body> </html> ); diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, type Page } from "@playwright/test"; + +// Phase 0 foundation: the shared theme system on the export site. Export stays +// on the neutral "base" family (no data-theme attribute) in Phase 0; the mode +// toggle must apply, persist across reloads, and be set before hydration. + +async function state(page: Page) { + return page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + theme: document.documentElement.getAttribute("data-theme"), + mode: localStorage.getItem("ytdlp-tb:mode"), + })); +} + +async function cycleTo(page: Page, target: "dark" | "light") { + const toggle = page.getByRole("button", { name: /switch to/i }); + for (let i = 0; i < 3; i++) { + if ((await state(page)).mode === target) break; + await toggle.click(); + } +} + +test("base family; mode toggle applies + persists with no FOUC", async ({ + page, +}) => { + await page.goto("/"); + + // Base family => no data-theme attribute on <html>. + expect((await state(page)).theme).toBeNull(); + await expect(page.getByRole("button", { name: /switch to/i })).toBeVisible(); + + // Explicit dark, then reload-persists before hydration. + await cycleTo(page, "dark"); + let s = await state(page); + expect(s.mode).toBe("dark"); + expect(s.dark).toBe(true); + expect(s.theme).toBeNull(); + + await page.reload({ waitUntil: "commit" }); + let onCommit = await page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + mode: localStorage.getItem("ytdlp-tb:mode"), + })); + expect(onCommit.mode).toBe("dark"); + expect(onCommit.dark).toBe(true); + + // Explicit light, then reload-persists. + await cycleTo(page, "light"); + s = await state(page); + expect(s.mode).toBe("light"); + expect(s.dark).toBe(false); + + await page.reload({ waitUntil: "commit" }); + onCommit = await page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + mode: localStorage.getItem("ytdlp-tb:mode"), + })); + expect(onCommit.mode).toBe("light"); + expect(onCommit.dark).toBe(false); +}); diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { currentHomepage } from "../lib/homepage"; // The hub's header: a compact sticky bar with the brass mark + wordmark, the @@ -14,6 +15,7 @@ export default function Header() { {headerTitle} </span> </Link> + <ThemeToggle className="ml-auto" /> </div> </header> ); diff --git a/homepage/app/globals.css b/homepage/app/globals.css @@ -1,59 +1,11 @@ @import "tailwindcss"; +@import "../../common/styles/tokens.css"; @source "../../common/components"; -/* The hub is committed to one deliberate aesthetic — a warm-ink "archive room". - Force the dark variant onto a class so it never flips with the OS setting. */ -@custom-variant dark (&:where(.dark, .dark *)); - -:root { - /* Warm near-black ground + aged-paper ink. */ - --background: #0c0a08; - --surface: #141009; - --foreground: #efe7d8; - /* Hairlines + muted speech. */ - --border: rgba(233, 220, 197, 0.1); - --border-strong: rgba(233, 220, 197, 0.18); - --muted: #1c1710; - --muted-foreground: #a39a86; - --faint: #6f6757; - /* Glass panels. */ - --panel: rgba(28, 23, 15, 0.55); - --panel-2: rgba(40, 33, 21, 0.5); - /* Brass signature accent. */ - --accent: #e3b15c; - --accent-strong: #f3c977; - --accent-soft: rgba(227, 177, 92, 0.14); - --accent-ink: #1a1305; - /* Series palette, tuned to glow on ink. */ - --chart-1: #6ea8ff; - --chart-2: #54d6a0; - --chart-3: #f2935b; - --chart-4: #c08cf0; - --chart-5: #f178b6; - /* "Instrument panel": an opaque, slightly-lifted plot surface that occludes - the page glow/grain for maximum data contrast, plus crisp axis/grid inks. */ - --chart-surface: #16110a; - --chart-grid: rgba(233, 220, 197, 0.08); - --chart-axis: #8a8170; - --chart-tooltip-bg: #1b150d; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-border: var(--border); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --font-sans: var(--font-source-sans); - --font-mono: var(--font-source-code); - --font-display: var(--font-source-serif); -} +/* The hub is committed to the warm-ink "archive" family (it defaults to + data-theme="archive" + dark). Design tokens, the `dark` variant, and the + theme palettes now live in common/styles/tokens.css; only the homepage's + atmosphere + bespoke layer styles remain here. */ html { background-color: var(--background); @@ -61,7 +13,7 @@ html { body { color: var(--foreground); - font-family: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif; + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing: antialiased; } diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx @@ -1,28 +1,12 @@ import type { Metadata } from "next"; -import { Source_Serif_4, Source_Sans_3, Source_Code_Pro } from "next/font/google"; +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 { currentHomepage } from "./lib/homepage"; import Header from "./components/Header"; import Footer from "./components/Footer"; import "./globals.css"; -// Adobe Source superfamily — one coherent voice across three roles. -// Serif: the wordmark + headline numerals. Sans: body/UI. Code: tracked labels. -const sourceSerif = Source_Serif_4({ - variable: "--font-source-serif", - subsets: ["latin"], - style: ["normal", "italic"], -}); - -const sourceSans = Source_Sans_3({ - variable: "--font-source-sans", - subsets: ["latin"], -}); - -const sourceCode = Source_Code_Pro({ - variable: "--font-source-code", - subsets: ["latin"], -}); - export function generateMetadata(): Metadata { const c = currentHomepage(); return { @@ -42,14 +26,18 @@ export default function RootLayout({ return ( <html lang="en" - className={`dark ${sourceSerif.variable} ${sourceSans.variable} ${sourceCode.variable} h-full antialiased`} + suppressHydrationWarning + className={`${fontVars} h-full antialiased`} > <body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--accent-soft)] selection:text-[var(--accent-strong)]"> - <Header /> - <main className="flex-1 w-full max-w-6xl mx-auto px-5 sm:px-6 py-8 sm:py-12"> - {children} - </main> - <Footer /> + <ThemeScript defaultTheme="archive" defaultMode="dark" /> + <ThemeProvider defaultTheme="archive" defaultMode="dark"> + <Header /> + <main className="flex-1 w-full max-w-6xl mx-auto px-5 sm:px-6 py-8 sm:py-12"> + {children} + </main> + <Footer /> + </ThemeProvider> </body> </html> ); diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts @@ -0,0 +1,50 @@ +import { test, expect, type Page } from "@playwright/test"; + +// Phase 0 foundation: the shared theme system (common/styles/tokens.css + +// ThemeScript + ThemeProvider + ThemeToggle). The homepage commits to the +// "archive" family and defaults to ink (dark); the mode toggle must persist +// across reloads and be applied before hydration (no flash of the wrong theme). + +async function htmlState(page: Page) { + return page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + theme: document.documentElement.getAttribute("data-theme"), + mode: localStorage.getItem("ytdlp-tb:mode"), + })); +} + +test("archive ink default; mode toggle persists with no FOUC", async ({ + page, +}) => { + await page.goto("/"); + + // Default: archive family, ink (dark) mode. + let s = await htmlState(page); + expect(s.theme).toBe("archive"); + expect(s.dark).toBe(true); + + const toggle = page.getByRole("button", { name: /switch to/i }); + await expect(toggle).toBeVisible(); + + // Cycle to an explicit light mode (dark → system → light) — deterministic + // regardless of the runner's OS color-scheme preference. + for (let i = 0; i < 3; i++) { + s = await htmlState(page); + if (s.mode === "light") break; + await toggle.click(); + } + s = await htmlState(page); + expect(s.mode).toBe("light"); + expect(s.dark).toBe(false); + expect(s.theme).toBe("archive"); + + // Reload: the pre-paint inline script must re-apply the persisted mode on the + // very first commit, before React hydrates. + await page.reload({ waitUntil: "commit" }); + const onCommit = await page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + mode: localStorage.getItem("ytdlp-tb:mode"), + })); + expect(onCommit.mode).toBe("light"); + expect(onCommit.dark).toBe(false); +});