commit 12c90eb2b07028987bbe6c5f609a3fad51255a9a
parent d97213426c799e8855d9e6eee22e9be227ea5622
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 14:43:38 -0400
redesign Phase 5: Terminal + Swiss themes + a theme-family picker (final phase)
The last redesign phase: two alternate selectable theme families and the first
family-picker UI, plus the CHANGELOG close-out. Themes are pure CSS — each family
is a [data-theme="…"] token block; no component branches on the theme and fonts
stay the unified Source family, so picking one only recolors via the CSS vars.
Palettes (common/styles/tokens.css) — two new families, each the full 41-property
contract (shadcn surface/text + --brand* + extras --surface/--border-strong/
--faint/--panel/--panel-2/--brand-ink + chart tokens), light + dark:
- terminal: an ice-cyan signal terminal — cyan #22d3ee on blue-black #080f12
(dark) / darker cyan #0891b2 on cool near-white (light); charts cyan/teal/
sky/indigo/mint.
- swiss: International Typographic — red #d4001a + black primary on white with
strong black --border-strong rules (light) / white-on-black, brand #ff2b3e
(dark); charts red/ink/steel/blue/sand.
Defining the full extras (--panel/--faint/--border-strong/--chart-*) keeps the
homepage's bespoke atmosphere and the charts correct under every family.
Registry (common/components/themeConfig.ts): append { terminal }, { swiss } to
THEME_FAMILIES (the ThemeFamily type already accepted them; ThemeScript +
ThemeProvider.applyToDom already emit data-theme="terminal"/"swiss").
Picker (common/components/ThemeMenu.tsx, NEW): a kit DropdownMenu (Palette
trigger, aria-label "Choose theme") with radio groups over THEME_FAMILIES and
THEME_MODES → setTheme/setMode. The existing one-click ThemeToggle stays; the
menu renders beside it in all three headers (export Header, homepage Header,
editor sidebar) — so the new picker works everywhere and the mode-toggle e2e is
untouched.
Gate: common typecheck; production builds of all three apps (new theme CSS
confirmed compiled — data-theme terminal/swiss + #22d3ee/#d4001a present); new
export/e2e/theme-family.spec (pick Terminal → data-theme=terminal, reload-
persists pre-paint, Swiss, back to Archive) + existing export theme/site-branding
(6 passed) + homepage theme (1 passed) all green. CHANGELOGs updated.
This completes the family-wide redesign (Phases 0–5); the
redesign/phase-0-foundation branch is ready to merge to local main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
9 files changed, 316 insertions(+), 5 deletions(-)
diff --git a/common/components/ThemeMenu.tsx b/common/components/ThemeMenu.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { Palette } from "lucide-react";
+import { useTheme } from "./ThemeProvider";
+import {
+ THEME_FAMILIES,
+ THEME_MODES,
+ type ThemeFamily,
+ type ThemeMode,
+} from "./themeConfig";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { cn } from "../lib/utils";
+
+// The theme-family picker: a palette dropdown to choose the family (Base /
+// Archive / Terminal / Swiss) and the mode (light / dark / system). Sits beside
+// the quick-toggle ThemeToggle. Each family is a pure CSS token swap, so picking
+// one only changes `data-theme` on <html> (handled by setTheme). Must render
+// inside a <ThemeProvider/>.
+export function ThemeMenu({ className }: { className?: string }) {
+ const { theme, mode, setTheme, setMode } = useTheme();
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger
+ aria-label="Choose theme"
+ title="Theme"
+ className={cn(
+ "inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ className,
+ )}
+ >
+ <Palette className="h-4 w-4" aria-hidden="true" />
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="min-w-44">
+ <DropdownMenuLabel>Theme</DropdownMenuLabel>
+ <DropdownMenuRadioGroup
+ value={theme}
+ onValueChange={(v) => setTheme(v as ThemeFamily)}
+ >
+ {THEME_FAMILIES.map((f) => (
+ <DropdownMenuRadioItem key={f.id} value={f.id}>
+ {f.label}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ <DropdownMenuSeparator />
+ <DropdownMenuLabel>Mode</DropdownMenuLabel>
+ <DropdownMenuRadioGroup
+ value={mode}
+ onValueChange={(v) => setMode(v as ThemeMode)}
+ >
+ {THEME_MODES.map((m) => (
+ <DropdownMenuRadioItem key={m.id} value={m.id}>
+ {m.label}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -7,16 +7,18 @@ 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.
+// "base" is the neutral default (no data-theme attribute). Each family is a pure
+// CSS token swap (a [data-theme="…"] block in tokens.css); no markup differs.
export type ThemeFamily = "base" | "archive" | "terminal" | "swiss";
export type ThemeMode = "light" | "dark" | "system";
-// Families currently wired (Terminal/Swiss arrive in the polish phase).
+// Selectable families. Order = display order in the ThemeMenu picker.
export const THEME_FAMILIES: { id: ThemeFamily; label: string }[] = [
{ id: "base", label: "Base" },
{ id: "archive", label: "Archive" },
+ { id: "terminal", label: "Terminal" },
+ { id: "swiss", label: "Swiss" },
];
export const THEME_MODES: { id: ThemeMode; label: string }[] = [
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -244,3 +244,183 @@
--chart-axis: #8a8170;
--chart-tooltip-bg: #1b150d;
}
+
+/* ---------------------------------------------------------------------------
+ TERMINAL family — an "ice-cyan signal" terminal. Cyan brand on blue-black
+ (dark) / cool near-white (light). Pure token swap — same markup, recolored.
+ --------------------------------------------------------------------------- */
+[data-theme="terminal"] {
+ /* Light terminal — cyan ink on a cool near-white. */
+ --background: #f2f7f9;
+ --surface: #e7f0f3;
+ --foreground: #07242c;
+ --card: #ffffff;
+ --card-foreground: #07242c;
+ --popover: #ffffff;
+ --popover-foreground: #07242c;
+ --primary: #0891b2;
+ --primary-foreground: #ecfeff;
+ --secondary: #d7eaef;
+ --secondary-foreground: #07242c;
+ --muted: #e1eef1;
+ --muted-foreground: #466a72;
+ --accent: #d7eaef;
+ --accent-foreground: #07242c;
+ --destructive: #be123c;
+ --destructive-foreground: #ecfeff;
+ --border: rgba(7, 36, 44, 0.12);
+ --border-strong: rgba(7, 36, 44, 0.22);
+ --input: rgba(7, 36, 44, 0.16);
+ --ring: #0891b2;
+ --faint: #6b8a91;
+ --panel: rgba(255, 255, 255, 0.7);
+ --panel-2: rgba(231, 240, 243, 0.7);
+
+ --brand: #0891b2;
+ --brand-strong: #0e7490;
+ --brand-soft: rgba(8, 145, 178, 0.14);
+ --brand-ink: #ecfeff;
+
+ --chart-1: #0891b2;
+ --chart-2: #0d9488;
+ --chart-3: #0284c7;
+ --chart-4: #4f46e5;
+ --chart-5: #059669;
+ --chart-surface: #ffffff;
+ --chart-grid: rgba(7, 36, 44, 0.08);
+ --chart-axis: #5f828a;
+ --chart-tooltip-bg: #ffffff;
+}
+
+[data-theme="terminal"].dark {
+ /* Dark terminal — phosphor cyan on blue-black. */
+ --background: #080f12;
+ --surface: #0c161b;
+ --foreground: #cdeef5;
+ --card: #0c161b;
+ --card-foreground: #cdeef5;
+ --popover: #0f1d23;
+ --popover-foreground: #cdeef5;
+ --primary: #22d3ee;
+ --primary-foreground: #04141a;
+ --secondary: #122b33;
+ --secondary-foreground: #cdeef5;
+ --muted: #0f2129;
+ --muted-foreground: #7fb3c0;
+ --accent: #102329;
+ --accent-foreground: #cdeef5;
+ --destructive: #fb7185;
+ --destructive-foreground: #04141a;
+ --border: rgba(140, 220, 235, 0.12);
+ --border-strong: rgba(140, 220, 235, 0.24);
+ --input: rgba(140, 220, 235, 0.16);
+ --ring: #22d3ee;
+ --faint: #5a8a96;
+ --panel: rgba(12, 26, 33, 0.6);
+ --panel-2: rgba(18, 38, 46, 0.5);
+
+ --brand: #22d3ee;
+ --brand-strong: #67e8f9;
+ --brand-soft: rgba(34, 211, 238, 0.16);
+ --brand-ink: #04141a;
+
+ --chart-1: #22d3ee;
+ --chart-2: #2dd4bf;
+ --chart-3: #38bdf8;
+ --chart-4: #818cf8;
+ --chart-5: #34d399;
+ --chart-surface: #0c161b;
+ --chart-grid: rgba(140, 220, 235, 0.1);
+ --chart-axis: #6f9aa6;
+ --chart-tooltip-bg: #0f1d23;
+}
+
+/* ---------------------------------------------------------------------------
+ SWISS family — International Typographic Style. Red + black + white, strong
+ hairline rules. Light-first; dark = white-on-black. Pure token swap.
+ --------------------------------------------------------------------------- */
+[data-theme="swiss"] {
+ /* Light — white ground, black primary, international red brand. */
+ --background: #ffffff;
+ --surface: #f5f5f5;
+ --foreground: #0a0a0a;
+ --card: #ffffff;
+ --card-foreground: #0a0a0a;
+ --popover: #ffffff;
+ --popover-foreground: #0a0a0a;
+ --primary: #0a0a0a;
+ --primary-foreground: #ffffff;
+ --secondary: #f0f0f0;
+ --secondary-foreground: #0a0a0a;
+ --muted: #f0f0f0;
+ --muted-foreground: #525252;
+ --accent: #ececec;
+ --accent-foreground: #0a0a0a;
+ --destructive: #b00016;
+ --destructive-foreground: #ffffff;
+ --border: #d4d4d4;
+ --border-strong: #0a0a0a;
+ --input: #c8c8c8;
+ --ring: #0a0a0a;
+ --faint: #8a8a8a;
+ --panel: rgba(255, 255, 255, 0.85);
+ --panel-2: rgba(245, 245, 245, 0.85);
+
+ --brand: #d4001a;
+ --brand-strong: #b00016;
+ --brand-soft: rgba(212, 0, 26, 0.1);
+ --brand-ink: #ffffff;
+
+ --chart-1: #d4001a;
+ --chart-2: #0a0a0a;
+ --chart-3: #6b7280;
+ --chart-4: #1d4ed8;
+ --chart-5: #b8a589;
+ --chart-surface: #ffffff;
+ --chart-grid: rgba(10, 10, 10, 0.1);
+ --chart-axis: #525252;
+ --chart-tooltip-bg: #ffffff;
+}
+
+[data-theme="swiss"].dark {
+ /* Dark — black ground, white primary, brighter red brand. */
+ --background: #0a0a0a;
+ --surface: #141414;
+ --foreground: #fafafa;
+ --card: #141414;
+ --card-foreground: #fafafa;
+ --popover: #161616;
+ --popover-foreground: #fafafa;
+ --primary: #fafafa;
+ --primary-foreground: #0a0a0a;
+ --secondary: #1f1f1f;
+ --secondary-foreground: #fafafa;
+ --muted: #1c1c1c;
+ --muted-foreground: #a3a3a3;
+ --accent: #222222;
+ --accent-foreground: #fafafa;
+ --destructive: #ff5a6a;
+ --destructive-foreground: #0a0a0a;
+ --border: rgba(250, 250, 250, 0.14);
+ --border-strong: rgba(250, 250, 250, 0.4);
+ --input: rgba(250, 250, 250, 0.18);
+ --ring: #fafafa;
+ --faint: #6b6b6b;
+ --panel: rgba(20, 20, 20, 0.6);
+ --panel-2: rgba(31, 31, 31, 0.5);
+
+ --brand: #ff2b3e;
+ --brand-strong: #ff5a6a;
+ --brand-soft: rgba(255, 43, 62, 0.16);
+ --brand-ink: #0a0a0a;
+
+ --chart-1: #ff2b3e;
+ --chart-2: #fafafa;
+ --chart-3: #9ca3af;
+ --chart-4: #60a5fa;
+ --chart-5: #cbb89a;
+ --chart-surface: #141414;
+ --chart-grid: rgba(250, 250, 250, 0.1);
+ --chart-axis: #a3a3a3;
+ --chart-tooltip-bg: #161616;
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Two more selectable themes + a theme picker.** A palette menu beside the light/dark toggle (in every app — editor, export, homepage) switches the theme family between **Archive**, **Terminal** (ice-cyan), **Swiss** (red/black/white), and **Base**, each in light/dark/system, persisted client-side. Each family is a pure CSS token swap in `common/styles/tokens.css` (a `[data-theme]` block) — no markup changes — wired through `common/components/{themeConfig.ts,ThemeMenu.tsx}`.
- **Per-site brand accent.** Each site's config (under Sites) gains an optional **Brand accent** field — a hex color that overrides the family brass on that site's public build. Validated on save; blank inherits the family accent. The long-running action logs (download, transcribe, build, deploy, …) are also restyled onto the shared kit (Button + design tokens), keeping their exact accessible labels. See `common/lib/accent.ts`, `common/lib/site.ts`, `common/components/StreamActionLog.tsx`, `editor/app/sites/{components/SiteForm.tsx,actions.ts}`, and `export/app/layout.tsx`.
- **Command-first cockpit: a ⌘K command palette that runs actions, plus a live dashboard.** Press **⌘K / Ctrl-K** anywhere to open a fast command palette (rebuilt on cmdk) that jumps to any page **and runs pool/site actions inline** — Sync all channels, Refresh all reports, Detect duplicate shorts, Retry all failed jobs, Drain the queue, Pause/Resume all workers — each firing the server action and reporting the result as a **toast** (Sonner). On a channel page it also offers the channel's status filters. The sidebar and palette now read from one shared nav source (`editor/app/lib/nav.ts`) so they never drift (the old palette listed a stale 5-route subset), and every nav item gains an icon. The **Dashboard** gains a **Live jobs** panel that polls running jobs in real time (reusing the active-jobs monitor), and the whole editor shell is restyled compact onto the shared design tokens/kit with the Source type family. See `editor/app/{layout.tsx,page.tsx,lib/nav.ts,components/{CommandPalette.tsx,ChangelogNavLink.tsx}}` and `common/components/ui/sonner.tsx`.
- **New "Family hub URL" setting.** Settings gains an optional hub URL (e.g. `https://archilyzer.com`); every export site renders a link back to it in the header/footer, tying the family of public sites together. Leave it blank for no hub link. Normalized to an absolute http(s) URL on save (`settings.homepageUrl`; see `common/lib/settings.ts`, `editor/app/settings/{components/SettingsForm.tsx,actions.ts}`). Part of the family-wide UI redesign that also restyles the public export sites.
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -11,6 +11,7 @@ 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 { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
import { AppFrame } from "./components/AppFrame";
import { AutoRefresh } from "./components/AutoRefresh";
import { CommandPalette } from "./components/CommandPalette";
@@ -133,7 +134,10 @@ export default async function RootLayout({
editor
</div>
</div>
- <ThemeToggle />
+ <div className="flex items-center gap-1.5 shrink-0">
+ <ThemeMenu />
+ <ThemeToggle />
+ </div>
</div>
<Suspense fallback={null}>
<SiteScopeSelect sites={sites} />
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Two more themes + a theme picker.** A palette menu next to the light/dark toggle lets you switch the whole site's look between **Archive** (the warm reading room), **Terminal** (an ice-cyan signal terminal), **Swiss** (red/black/white editorial), and **Base** (neutral) — each in light or dark, and your choice is remembered. Switching is instant and purely cosmetic (just colors; the layout never changes).
- **Optional per-site brand accent.** A site can set its own accent color (a hex, in the editor's site config) to replace the family brass everywhere on its public build — the wordmark mark, links, badges, and highlights. It's baked onto the page at build time so it's correct on the very first paint, and applies in both light and dark. Sites that don't set one keep the family brass.
- **New look: the "Archive / Reading Room" identity.** The public site adopts the family design system — a serif display face (Source Serif 4) for headings/counts, clean sans for body, and mono for the query/power layer, over a warm paper (light) / ink (dark) palette with a brass accent. A light/dark/system theme toggle in the header replaces OS-only dark mode and persists your choice. Header, footer, and the whole search UI (query builder, filters, scopes, charts) are rebuilt on a shared component kit for a calmer, more consistent feel — the simple one-box search is unchanged; the power tools just read better.
- **"New since your last visit."** A dismissible banner on the home page tells you how many transcripts were added since you were last here, and first-time visitors get a one-line nudge toward the power tools (layered queries, scopes, filters, charts). Entirely client-side — nothing about your visit leaves your browser.
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -3,6 +3,7 @@ import { ArrowUpLeft } from "lucide-react";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { listSites, resolveRelatedSites } from "yt-dlp-transcript-common/lib/site";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
+import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
import { currentSite } from "../lib/site";
import SiblingSwitcher from "./SiblingSwitcher";
@@ -56,6 +57,7 @@ export default function Header() {
>
Changelog
</Link>
+ <ThemeMenu />
<ThemeToggle />
</div>
</div>
diff --git a/export/e2e/theme-family.spec.ts b/export/e2e/theme-family.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect, type Page } from "@playwright/test";
+
+// Phase 5: the ThemeMenu family picker. Each family is a pure CSS token swap, so
+// picking one only sets data-theme on <html> (persisted, re-applied pre-paint).
+
+async function familyState(page: Page) {
+ return page.evaluate(() => ({
+ theme: document.documentElement.getAttribute("data-theme"),
+ persisted: localStorage.getItem("ytdlp-tb:theme"),
+ }));
+}
+
+async function pickFamily(page: Page, label: string) {
+ await page.getByRole("button", { name: "Choose theme" }).click();
+ await page.getByRole("menuitemradio", { name: label }).click();
+}
+
+test("theme-family picker switches + persists data-theme with no FOUC", async ({
+ page,
+}) => {
+ await page.goto("/");
+ // Export defaults to the archive family.
+ expect((await familyState(page)).theme).toBe("archive");
+ await expect(
+ page.getByRole("button", { name: "Choose theme" }),
+ ).toBeVisible();
+
+ await pickFamily(page, "Terminal");
+ let s = await familyState(page);
+ expect(s.theme).toBe("terminal");
+ expect(s.persisted).toBe("terminal");
+
+ // Reload: the pre-paint script re-applies data-theme before React hydrates.
+ await page.reload({ waitUntil: "commit" });
+ expect(
+ await page.evaluate(() =>
+ document.documentElement.getAttribute("data-theme"),
+ ),
+ ).toBe("terminal");
+
+ await pickFamily(page, "Swiss");
+ s = await familyState(page);
+ expect(s.theme).toBe("swiss");
+ expect(s.persisted).toBe("swiss");
+
+ await pickFamily(page, "Archive");
+ expect((await familyState(page)).theme).toBe("archive");
+});
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
+import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
import { currentHomepage } from "../lib/homepage";
// The hub's header: a compact sticky bar with the brass mark + wordmark, the
@@ -15,7 +16,10 @@ export default function Header() {
{headerTitle}
</span>
</Link>
- <ThemeToggle className="ml-auto" />
+ <div className="ml-auto flex items-center gap-2">
+ <ThemeMenu />
+ <ThemeToggle />
+ </div>
</div>
</header>
);