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:
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);
+});