commit 9dbe4f87695e2d364daf7a236633690f4bb74823
parent 9e468b83b533f2257032dd033ce2aebd45276a24
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 21:43:41 -0400
common: the theme runtime and pickers are base × accent; the layouts set the defaults (brand S2)
ThemeScript inlines buildThemeScript({defaultBase}). ThemeProvider takes
{defaultBase = "system", siteAccent = DEFAULT_ACCENT} and exposes {base,
resolvedBase, isDark, accent, siteAccent, setBase, cycleBase, setAccent}:
- a useLayoutEffect adopts the stored choice (running the same one-time legacy
migration as the script) and re-asserts data-base, .dark, data-accent and
theme-color — hydration can reset <html>, and without it the site's accent
would overwrite a reader's pick. Nothing is written before adoption.
- a live systemDark from the matchMedia listener, so "system" follows an OS
change while the page is open (isDark used to go stale).
- setAccent(site default) REMOVES the stored key; "custom" is never stored.
- cycleBase: system → light → sepia → dark → system.
ThemeMenu keeps aria-label "Choose theme": a "Base" group of menuitemradios
(System, Light, Sepia, Dark) and an "Accent" group — swatch dot, name, a
visible "default" tag on the site's own; a custom-hex site gets "Site colour"
first (themeConfig.accentOptions). ThemeToggle: "Switch to …" the next base,
Monitor/Sun/BookOpen/Moon. MobileMenu: native radio groups "Base" and
"Accent". sonner: theme={isDark ? "dark" : "light"}.
Layouts: an export site renders <html data-accent> from
resolveAccent(site.accent) (+ the inline --accent-custom-* for a custom hex)
and opens on the system base; the hub and the homepage open dark in Signal.
generateViewport: a site's theme-color is the BASE_GROUNDS light/dark media
pair, the hub's and the homepage's BASE_GROUNDS.dark — FALLBACK_THEME_COLOR,
HUB_THEME_COLOR, the homepage THEME_COLOR and the hex-only parseAccent read
S0 flagged are gone. The editor keeps its layout (system base, Signal).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 377 insertions(+), 282 deletions(-)
diff --git a/common/components/ThemeMenu.tsx b/common/components/ThemeMenu.tsx
@@ -3,10 +3,10 @@
import { Palette } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import {
- THEME_FAMILIES,
- THEME_MODES,
- type ThemeFamily,
- type ThemeMode,
+ THEME_BASES,
+ accentOptions,
+ isThemeAccent,
+ isThemeBase,
} from "./themeConfig";
import {
DropdownMenu,
@@ -19,13 +19,14 @@ import {
} from "./ui/dropdown-menu";
import { cn } from "../lib/utils";
-// The theme-family picker: a palette dropdown to choose the family (Base /
-// Archive / Selenized / Swiss) and the mode (light / dark / system). Sits beside
-// the quick-toggle ThemeToggle. Each family is a CSS token swap (color, plus a
-// per-family radius + type voice), so picking one only changes `data-theme` on
-// <html> (handled by setTheme). Must render inside a <ThemeProvider/>.
+// The theme picker: a palette dropdown with two radio groups — the BASE
+// (System / Light / Sepia / Dark) and the ACCENT (the seven named accents, the
+// site's own tagged "default"; a custom-hex site adds its colour first as
+// "Site colour"). Sits beside the quick base toggle (ThemeToggle). Picking only
+// changes attributes on <html>; tokens.css does the rest. The `menuitemradio`
+// names are an e2e contract. Must render inside a <ThemeProvider/>.
export function ThemeMenu({ className }: { className?: string }) {
- const { theme, mode, setTheme, setMode } = useTheme();
+ const { base, accent, siteAccent, setBase, setAccent } = useTheme();
return (
<DropdownMenu>
@@ -39,27 +40,43 @@ export function ThemeMenu({ className }: { className?: string }) {
>
<Palette className="h-4 w-4" aria-hidden="true" />
</DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="min-w-44">
- <DropdownMenuLabel>Theme</DropdownMenuLabel>
+ <DropdownMenuContent align="end" className="min-w-52">
+ <DropdownMenuLabel>Base</DropdownMenuLabel>
<DropdownMenuRadioGroup
- value={theme}
- onValueChange={(v) => setTheme(v as ThemeFamily)}
+ aria-label="Base"
+ value={base}
+ onValueChange={(v) => {
+ if (isThemeBase(v)) setBase(v);
+ }}
>
- {THEME_FAMILIES.map((f) => (
- <DropdownMenuRadioItem key={f.id} value={f.id}>
- {f.label}
+ {THEME_BASES.map((b) => (
+ <DropdownMenuRadioItem key={b.id} value={b.id}>
+ {b.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
- <DropdownMenuLabel>Mode</DropdownMenuLabel>
+ <DropdownMenuLabel>Accent</DropdownMenuLabel>
<DropdownMenuRadioGroup
- value={mode}
- onValueChange={(v) => setMode(v as ThemeMode)}
+ aria-label="Accent"
+ value={accent}
+ onValueChange={(v) => {
+ if (isThemeAccent(v)) setAccent(v);
+ }}
>
- {THEME_MODES.map((m) => (
- <DropdownMenuRadioItem key={m.id} value={m.id}>
- {m.label}
+ {accentOptions(siteAccent).map((o) => (
+ <DropdownMenuRadioItem key={o.id} value={o.id}>
+ <span
+ aria-hidden="true"
+ className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
+ style={{ background: o.swatch }}
+ />
+ <span>{o.label}</span>
+ {o.isSiteDefault && (
+ <span className="ml-auto pl-3 font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
+ default
+ </span>
+ )}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx
@@ -4,154 +4,208 @@ import {
createContext,
useCallback,
useContext,
- useLayoutEffect,
useEffect,
+ useLayoutEffect,
useMemo,
useState,
} from "react";
+import { BASE_GROUNDS, DEFAULT_ACCENT, isAccentId, type AccentId } from "../lib/brand";
import {
- THEME_KEY,
- MODE_KEY,
- type ThemeFamily,
- type ThemeMode,
+ ACCENT_KEY,
+ BASE_KEY,
+ LEGACY_MODE_KEY,
+ LEGACY_THEME_KEY,
+ isThemeBase,
+ migrateLegacy,
+ nextBase,
+ resolveBase,
+ type ResolvedBase,
+ type ThemeAccent,
+ type ThemeBase,
} from "./themeConfig";
-// Runtime theme controller. The pre-paint <ThemeScript/> colors the FIRST paint
-// by setting attributes on <html> from localStorage before hydration. But <html>
-// is server-rendered with a static className that omits `.dark`, so that script
-// mutation is lost across the hydration boundary and nothing restores it — the
-// page would stay light until a user toggle. So on mount this provider RE-ASSERTS
-// the *persisted, resolved* theme to the DOM (via useLayoutEffect, before paint).
-// That is idempotent — it recomputes the exact same value the script already used
-// from the same localStorage, so it never fights the script and never flashes.
-// After mount it writes to the DOM only on explicit user changes and live
-// OS-preference changes.
+// Runtime theme controller: the reader's BASE (light | sepia | dark | system)
+// and ACCENT (one of the seven, defaulting to the site's own).
+//
+// The pre-paint <ThemeScript/> colours the FIRST paint by setting attributes on
+// <html> from localStorage before hydration. Hydration can reset <html>'s
+// attributes to what the server rendered — the className without `.dark`, and
+// the SITE's `data-accent` over a reader's pick — so on mount this provider
+// RE-ASSERTS the persisted, resolved theme (data-base, .dark, data-accent,
+// theme-color) in a useLayoutEffect, before paint. That is idempotent: it
+// recomputes what the script computed from the same storage, so it never
+// fights the script and never flashes. After that it writes to the DOM only on
+// a reader's change and on a live OS-preference change.
type ThemeContextValue = {
- theme: ThemeFamily;
- mode: ThemeMode;
- /** Whether dark is currently applied (resolves "system"). */
+ /** The reader's choice, "system" included. */
+ base: ThemeBase;
+ /** The ground actually painted ("system" resolved against the OS). */
+ resolvedBase: ResolvedBase;
+ /** Whether the dark base is applied. */
isDark: boolean;
- setTheme: (t: ThemeFamily) => void;
- setMode: (m: ThemeMode) => void;
- /** Cycle light → dark → system → light. */
- cycleMode: () => void;
+ /** The accent applied: the reader's pick, else the site's. */
+ accent: ThemeAccent;
+ /** The site's own accent ("custom" for a site with its own hex). */
+ siteAccent: ThemeAccent;
+ setBase: (b: ThemeBase) => void;
+ /** system → light → sepia → dark → system. */
+ cycleBase: () => void;
+ /** Picking the site's own accent REMOVES the stored pick, so the reader
+ * follows the site's default from then on. */
+ setAccent: (a: ThemeAccent) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
+const DARK_QUERY = "(prefers-color-scheme: dark)";
+
function systemPrefersDark(): boolean {
- return (
- typeof window !== "undefined" &&
- window.matchMedia("(prefers-color-scheme: dark)").matches
- );
+ try {
+ return window.matchMedia(DARK_QUERY).matches;
+ } catch {
+ return false;
+ }
}
-function resolveDark(mode: ThemeMode): boolean {
- return mode === "system" ? systemPrefersDark() : mode === "dark";
+// What storage holds, after the same one-time legacy migration the pre-paint
+// script runs (a no-op when the script already ran it).
+function readStored(): { base: ThemeBase | null; accent: AccentId | null } {
+ try {
+ const s = window.localStorage;
+ let base = s.getItem(BASE_KEY);
+ const theme = s.getItem(LEGACY_THEME_KEY);
+ const mode = s.getItem(LEGACY_MODE_KEY);
+ if (theme !== null || mode !== null) {
+ if (base === null) {
+ const migrated = migrateLegacy({ theme, mode });
+ if (migrated) {
+ s.setItem(BASE_KEY, migrated);
+ base = migrated;
+ }
+ }
+ s.removeItem(LEGACY_THEME_KEY);
+ s.removeItem(LEGACY_MODE_KEY);
+ }
+ const accent = s.getItem(ACCENT_KEY);
+ return {
+ base: isThemeBase(base) ? base : null,
+ accent: isAccentId(accent) ? accent : null,
+ };
+ } catch {
+ return { base: null, accent: null };
+ }
}
-function applyToDom(theme: ThemeFamily, dark: boolean) {
+function applyToDom(resolved: ResolvedBase, accent: ThemeAccent) {
const d = document.documentElement;
- if (theme && theme !== "base") d.setAttribute("data-theme", theme);
- else d.removeAttribute("data-theme");
- d.classList.toggle("dark", dark);
+ d.setAttribute("data-base", resolved);
+ d.classList.toggle("dark", resolved === "dark");
+ d.setAttribute("data-accent", accent);
+ for (const m of document.querySelectorAll('meta[name="theme-color"]')) {
+ m.setAttribute("content", BASE_GROUNDS[resolved]);
+ }
+}
+
+function store(key: string, value: string | null) {
+ try {
+ if (value === null) window.localStorage.removeItem(key);
+ else window.localStorage.setItem(key, value);
+ } catch {
+ /* storage disabled: the choice holds for this page only */
+ }
}
export function ThemeProvider({
- defaultTheme = "base",
- defaultMode = "system",
+ defaultBase = "system",
+ siteAccent = DEFAULT_ACCENT,
children,
}: {
- defaultTheme?: ThemeFamily;
- defaultMode?: ThemeMode;
+ defaultBase?: ThemeBase;
+ siteAccent?: ThemeAccent;
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 AND re-assert them to the DOM before
- // paint, so the theme survives the hydration boundary (see block comment above).
- // useLayoutEffect runs synchronously before the browser paints the hydrated tree.
+ // Initial state MUST equal what the server rendered (the defaults), so
+ // hydration matches; the persisted values are adopted just below.
+ const [base, setBaseState] = useState<ThemeBase>(defaultBase);
+ const [accent, setAccentState] = useState<ThemeAccent>(siteAccent);
+ const [systemDark, setSystemDark] = useState(false);
+ const [adopted, setAdopted] = useState(false);
+
+ // Adopt the persisted choice before paint. The state updates re-render
+ // synchronously, and the effect below then re-asserts it to <html>.
useLayoutEffect(() => {
+ const stored = readStored();
+ setBaseState(stored.base ?? defaultBase);
+ setAccentState(stored.accent ?? siteAccent);
+ setSystemDark(systemPrefersDark());
+ setAdopted(true);
+ // Effectively once: the defaults are stable props from the layout.
+ }, [defaultBase, siteAccent]);
+
+ // A LIVE OS preference: "system" follows a change made while the page is
+ // open (the pre-paint script can only read it once).
+ useEffect(() => {
+ let mql: MediaQueryList;
try {
- let t = localStorage.getItem(THEME_KEY) as ThemeFamily | "terminal" | null;
- const m = localStorage.getItem(MODE_KEY) as ThemeMode | null;
- // The "terminal" family was renamed to "selenized"; migrate any stored value
- // (and rewrite it) so the picker highlights the right entry.
- if (t === "terminal") {
- t = "selenized";
- try {
- localStorage.setItem(THEME_KEY, t);
- } catch {
- /* ignore */
- }
- }
- if (t) setThemeState(t);
- if (m) setModeState(m);
- // Re-apply the resolved persisted values (falling back to the defaults the
- // server rendered) so <html> carries the correct `data-theme`/`.dark` again.
- const resolvedTheme = t ?? defaultTheme;
- const resolvedMode = m ?? defaultMode;
- applyToDom(resolvedTheme, resolveDark(resolvedMode));
+ mql = window.matchMedia(DARK_QUERY);
} catch {
- /* ignore */
+ return;
}
- // Effectively runs once: the defaults are stable props (passed by the
- // layout and never changed), so this re-adopts persisted state on mount and
- // does not fight later user changes made through setTheme/setMode.
- }, [defaultTheme, defaultMode]);
-
- // 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);
+ const onChange = () => setSystemDark(mql.matches);
+ onChange();
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 resolvedBase = resolveBase(base, systemDark);
- const setMode = useCallback(
- (m: ThemeMode) => {
- setModeState(m);
- applyToDom(theme, resolveDark(m));
- try {
- localStorage.setItem(MODE_KEY, m);
- } catch {
- /* ignore */
+ // Re-assert <html> — once adopted, and on every change after that. Nothing is
+ // written before adoption, so the server's defaults never overwrite what the
+ // pre-paint script set.
+ useLayoutEffect(() => {
+ if (adopted) applyToDom(resolvedBase, accent);
+ }, [adopted, resolvedBase, accent]);
+
+ const setBase = useCallback((b: ThemeBase) => {
+ if (!isThemeBase(b)) return;
+ setBaseState(b);
+ store(BASE_KEY, b);
+ }, []);
+
+ const cycleBase = useCallback(() => setBase(nextBase(base)), [base, setBase]);
+
+ const setAccent = useCallback(
+ (a: ThemeAccent) => {
+ if (a === siteAccent) {
+ setAccentState(a);
+ store(ACCENT_KEY, null);
+ return;
}
+ // "custom" only ever means THIS site's own colour (above); a named
+ // accent is the only thing a reader can store.
+ if (!isAccentId(a)) return;
+ setAccentState(a);
+ store(ACCENT_KEY, a);
},
- [theme],
+ [siteAccent],
);
- 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],
+ () => ({
+ base,
+ resolvedBase,
+ isDark: resolvedBase === "dark",
+ accent,
+ siteAccent,
+ setBase,
+ cycleBase,
+ setAccent,
+ }),
+ [base, resolvedBase, accent, siteAccent, setBase, cycleBase, setAccent],
);
- return (
- <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
- );
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
diff --git a/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx
@@ -1,61 +1,29 @@
-import {
- THEME_KEY,
- MODE_KEY,
- ACCENT_KEY,
- type ThemeFamily,
- type ThemeMode,
-} from "./themeConfig";
+import { buildThemeScript, type ThemeBase } 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 `--brand` 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.
+// From localStorage it migrates the retired theme/mode keys once, then sets
+// `data-base` (the resolved light | sepia | dark ground) and `.dark` on <html>,
+// `data-accent` when the reader stored an accent of their own, the browser
+// chrome colour, and — last — the `data-theme-ready` marker the e2e no-flash
+// tests wait for. The script's source and its order are
+// themeConfig.buildThemeScript (unit-tested in node:vm).
//
-// Accent precedence (highest first): user override (this script) > per-site
-// default (an inline <html style="--brand:…"> baked at build) > family token.
-// `--brand` is the family brand color (brass/blue); shadcn's neutral `--accent`
-// is a separate token and is never overridden here.
+// The site's OWN accent is not this script's business: the layout
+// server-renders it as `<html data-accent>`, and the script only replaces it
+// with a reader's valid stored pick. Works identically for static-export sites
+// and the server editor. Pair with `suppressHydrationWarning` on <html>, since
+// this mutates the element before React hydrates — and with ThemeProvider,
+// which re-asserts the same attributes after hydration.
export function ThemeScript({
- defaultTheme = "base",
- defaultMode = "system",
+ defaultBase = "system",
}: {
- defaultTheme?: ThemeFamily;
- defaultMode?: ThemeMode;
+ defaultBase?: ThemeBase;
}) {
- const js =
- "(function(){try{" +
- "var d=document.documentElement;" +
- "var t=localStorage.getItem(" +
- JSON.stringify(THEME_KEY) +
- ")||" +
- JSON.stringify(defaultTheme) +
- ";" +
- "if(t==='terminal'){t='selenized';}" + // renamed family; migrate old stored value
- "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('--brand',a);}" +
- // Last statement of the same synchronous try block, so the marker can only
- // be present once every attribute above is already set. e2e's "no FOUC"
- // tests reload with waitUntil:"commit", which resolves as soon as the
- // navigation commits — possibly before this head script has run at all —
- // and then read data-theme. They wait for this marker instead of racing.
- // It proves nothing weaker than before: a <head> script still runs before
- // any React, so "set before hydration" is exactly what is observed.
- "d.setAttribute('data-theme-ready','1');" +
- "}catch(e){}})();";
-
return (
- <script dangerouslySetInnerHTML={{ __html: js }} suppressHydrationWarning />
+ <script
+ dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase }) }}
+ suppressHydrationWarning
+ />
);
}
diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx
@@ -1,30 +1,33 @@
"use client";
-import { Sun, Moon, Monitor } from "lucide-react";
+import { BookOpen, Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider";
+import { nextBase, type ThemeBase } from "./themeConfig";
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;
+// The quick base toggle: cycles system → light → sepia → dark → system. The
+// icon shows the base in force, the label names the next one ("Switch to …" —
+// the e2e specs find it by /switch to/i). The accent lives in ThemeMenu. Must
+// be rendered inside a <ThemeProvider/>.
+const ICON = { system: Monitor, light: Sun, sepia: BookOpen, dark: Moon } as const;
+const LABEL: Record<ThemeBase, string> = {
+ system: "system",
+ light: "light",
+ sepia: "sepia",
+ dark: "dark",
+};
export function ThemeToggle({ className }: { className?: string }) {
- const { mode, cycleMode } = useTheme();
- const Icon = ICON[mode];
+ const { base, cycleBase } = useTheme();
+ const Icon = ICON[base];
return (
<button
type="button"
- onClick={cycleMode}
- aria-label={NEXT_LABEL[mode]}
- title={`Theme: ${mode}`}
- data-theme-mode={mode}
+ onClick={cycleBase}
+ aria-label={`Switch to ${LABEL[nextBase(base)]}`}
+ title={`Theme: ${LABEL[base]}`}
+ data-theme-base={base}
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,
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -16,6 +16,7 @@
// PURE: no React, no DOM at import time. lib/brand.ts is pure too.
import {
+ ACCENTS,
ACCENT_IDS,
BASE_GROUNDS,
isAccentId,
@@ -60,6 +61,30 @@ export function isThemeAccent(v: unknown): v is ThemeAccent {
return v === "custom" || isAccentId(v);
}
+// The accent choices a picker offers on a site whose own accent is
+// `siteAccent`: the seven named accents in brand order, each with its swatch
+// (`--swatch-<id>` is that accent's value on the base in force, so the dot
+// shows what the pick will paint) and a flag on the site's own. A custom-hex
+// site adds its colour first, as "Site colour".
+export type AccentOption = {
+ id: ThemeAccent;
+ label: string;
+ swatch: string;
+ isSiteDefault: boolean;
+};
+
+export function accentOptions(siteAccent: ThemeAccent): AccentOption[] {
+ const named: AccentOption[] = ACCENT_IDS.map((id) => ({
+ id,
+ label: ACCENTS[id].name,
+ swatch: `var(--swatch-${id})`,
+ isSiteDefault: id === siteAccent,
+ }));
+ return siteAccent === "custom"
+ ? [{ id: "custom", label: "Site colour", swatch: "var(--swatch-custom)", isSiteDefault: true }, ...named]
+ : named;
+}
+
// ThemeToggle's cycle: system → light → sepia → dark → system.
export function nextBase(b: ThemeBase): ThemeBase {
return b === "system" ? "light" : b === "light" ? "sepia" : b === "sepia" ? "dark" : "system";
@@ -201,20 +226,3 @@ export function buildThemeScript({
);
}
-// ── Retired (removed once ThemeProvider/ThemeMenu/MobileMenu move over) ─────
-export const THEME_KEY = LEGACY_THEME_KEY;
-export const MODE_KEY = LEGACY_MODE_KEY;
-export type ThemeFamily = "base" | "archive" | "selenized" | "swiss" | "archilyzer";
-export type ThemeMode = "light" | "dark" | "system";
-export const THEME_FAMILIES: { id: ThemeFamily; label: string }[] = [
- { id: "base", label: "Base" },
- { id: "archive", label: "Archive" },
- { id: "selenized", label: "Selenized" },
- { id: "swiss", label: "Swiss" },
- { id: "archilyzer", label: "Archilyzer" },
-];
-export const THEME_MODES: { id: ThemeMode; label: string }[] = [
- { id: "light", label: "Light" },
- { id: "dark", label: "Dark" },
- { id: "system", label: "System" },
-];
diff --git a/common/components/ui/sonner.tsx b/common/components/ui/sonner.tsx
@@ -12,13 +12,14 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"
import { useTheme } from "../ThemeProvider"
const Toaster = ({ ...props }: ToasterProps) => {
- // Reuse the family theme controller (light | dark | system) rather than
- // next-themes, so toast chrome tracks the same mode as the rest of the app.
- const { mode } = useTheme()
+ // Reuse the family theme controller rather than next-themes, so toast chrome
+ // tracks the base in force: sepia is a light ground, and "system" is already
+ // resolved (live) by the provider.
+ const { isDark } = useTheme()
return (
<Sonner
- theme={mode as ToasterProps["theme"]}
+ theme={isDark ? "dark" : "light"}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx
@@ -13,24 +13,26 @@ import {
} from "yt-dlp-transcript-common/components/ui/sheet";
import { useTheme } from "yt-dlp-transcript-common/components/ThemeProvider";
import {
- THEME_FAMILIES,
- THEME_MODES,
- type ThemeFamily,
- type ThemeMode,
+ THEME_BASES,
+ accentOptions,
+ isThemeAccent,
+ isThemeBase,
} from "yt-dlp-transcript-common/components/themeConfig";
import type { SwitcherGroup } from "./SiblingSwitcher";
// The phone half of the masthead. Below `md` the header keeps only the brand,
-// the mode toggle and this trigger; everything else the header offers — the nav,
-// the sibling sites, the hub backlink, the theme family — moves in here, which
-// is also how the Hub backlink and the sites list become reachable on a phone at
-// all (they were `hidden sm:*` and a dropdown too small to hit).
+// the base toggle and this trigger; everything else the header offers — the
+// nav, the sibling sites, the hub backlink, the base and accent pickers —
+// moves in here, which is also how the Hub backlink and the sites list become
+// reachable on a phone at all (they were `hidden sm:*` and a dropdown too small
+// to hit).
//
// Header is a server component, so the link groups arrive as plain serialisable
-// props. The theme lists are read from the client ThemeProvider and rendered as
-// NATIVE radio groups, not by nesting ThemeMenu's DropdownMenu inside this
-// dialog — one popover layer inside another is a focus-trap fight, and the
-// dropdown's `menuitemradio`s are a spec hook that belongs to the md+ header.
+// props. The base and accent lists are read from the client ThemeProvider and
+// rendered as NATIVE radio groups ("Base", "Accent"), not by nesting
+// ThemeMenu's DropdownMenu inside this dialog — one popover layer inside
+// another is a focus-trap fight, and the dropdown's `menuitemradio`s are a spec
+// hook that belongs to the md+ header.
export default function MobileMenu({
links,
sites,
@@ -41,7 +43,7 @@ export default function MobileMenu({
hubUrl?: string;
}) {
const [open, setOpen] = useState(false);
- const { theme, mode, setTheme, setMode } = useTheme();
+ const { base, accent, siteAccent, setBase, setAccent } = useTheme();
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -119,24 +121,28 @@ export default function MobileMenu({
)}
<div className="mt-4 px-2">
- <MenuHeading>Theme</MenuHeading>
+ <MenuHeading>Base</MenuHeading>
<RadioList
- name="mobile-theme-family"
- label="Theme"
- value={theme}
- options={THEME_FAMILIES}
- onPick={(v) => setTheme(v as ThemeFamily)}
+ name="mobile-theme-base"
+ label="Base"
+ value={base}
+ options={THEME_BASES}
+ onPick={(v) => {
+ if (isThemeBase(v)) setBase(v);
+ }}
/>
</div>
<div className="mt-4 px-2">
- <MenuHeading>Mode</MenuHeading>
+ <MenuHeading>Accent</MenuHeading>
<RadioList
- name="mobile-theme-mode"
- label="Mode"
- value={mode}
- options={THEME_MODES}
- onPick={(v) => setMode(v as ThemeMode)}
+ name="mobile-theme-accent"
+ label="Accent"
+ value={accent}
+ options={accentOptions(siteAccent)}
+ onPick={(v) => {
+ if (isThemeAccent(v)) setAccent(v);
+ }}
/>
</div>
</SheetContent>
@@ -162,7 +168,13 @@ function RadioList({
name: string;
label: string;
value: string;
- options: { id: string; label: string }[];
+ options: ReadonlyArray<{
+ id: string;
+ label: string;
+ // An accent's dot (a CSS colour) and whether it is the site's own.
+ swatch?: string;
+ isSiteDefault?: boolean;
+ }>;
onPick: (id: string) => void;
}) {
return (
@@ -180,7 +192,19 @@ function RadioList({
onChange={() => onPick(o.id)}
className="size-4 accent-[var(--brand)]"
/>
+ {o.swatch && (
+ <span
+ aria-hidden="true"
+ className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
+ style={{ background: o.swatch }}
+ />
+ )}
{o.label}
+ {o.isSiteDefault && (
+ <span className="ml-auto font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
+ default
+ </span>
+ )}
</label>
))}
</div>
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -3,7 +3,15 @@ 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 { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
-import { parseAccent, siteAccentVars } from "yt-dlp-transcript-common/lib/accent";
+import {
+ customAccentVars,
+ resolveAccent,
+} from "yt-dlp-transcript-common/lib/accent";
+import { BASE_GROUNDS, DEFAULT_ACCENT } from "yt-dlp-transcript-common/lib/brand";
+import type {
+ ThemeAccent,
+ ThemeBase,
+} from "yt-dlp-transcript-common/components/themeConfig";
import { currentSite } from "./lib/site";
import { instanceMode, shipsPwa } from "./lib/mode";
import Header from "./components/Header";
@@ -11,23 +19,29 @@ import Footer from "./components/Footer";
import { ServiceWorkerRegister } from "./components/ServiceWorkerRegister";
import "./globals.css";
-// Browser-chrome themeColor fallback when a site defines no per-site accent.
-// Tracks the base family's brand blue (tokens.css --brand) now that export
-// defaults to base.
-const FALLBACK_THEME_COLOR = "#2563eb";
-
-// The hub is the TOOL, not an archive: it wears the project's own instrument
-// face — the "archilyzer" family, dark by default — exactly as the homepage
-// does (homepage/app/layout.tsx), so the two read as one product. Every site
-// build keeps the base family and the visitor's system mode. The browser chrome
-// colour is the homepage's too (the family's dark ground, tokens.css).
+// The reader theme's starting point (plans/brand-and-themes.md). A site opens
+// on the reader's system base in its OWN accent (site.json `accent`: a named
+// id, or a custom hex fitted to each ground). The hub is the TOOL, not an
+// archive: it opens dark in Signal, the family's own accent, exactly as the
+// homepage does (homepage/app/layout.tsx), so the two read as one product.
+// Either way the reader can pick any base and any accent (ThemeMenu).
// instanceMode() is server-only; this whole file is a server component.
-const HUB_THEME = { defaultTheme: "archilyzer", defaultMode: "dark" } as const;
-const SITE_THEME = { defaultTheme: "base", defaultMode: "system" } as const;
-const HUB_THEME_COLOR = "#151b20";
-
-function themeDefaults() {
- return instanceMode() === "hub" ? HUB_THEME : SITE_THEME;
+function themeDefaults(): {
+ defaultBase: ThemeBase;
+ accent: ThemeAccent;
+ // The inline `--accent-custom-light|sepia|dark` a custom-hex site needs
+ // (tokens.css `--swatch-custom` reads them); null for a named accent.
+ accentVars: Record<string, string> | null;
+} {
+ if (instanceMode() === "hub") {
+ return { defaultBase: "dark", accent: DEFAULT_ACCENT, accentVars: null };
+ }
+ const setting = currentSite().accent;
+ return {
+ defaultBase: "system",
+ accent: resolveAccent(setting).id,
+ accentVars: customAccentVars(setting),
+ };
}
export function generateMetadata(): Metadata {
@@ -59,10 +73,16 @@ export function generateMetadata(): Metadata {
export function generateViewport(): Viewport {
return {
- // Browser chrome color tracks the site's brand accent (falls back to base brand).
+ // Browser chrome continues the page's GROUND, not its accent. A site
+ // follows the OS (a light/dark pair), the hub opens dark; the pre-paint
+ // ThemeScript then points every theme-color meta at the base in force.
themeColor:
- parseAccent(currentSite().accent) ??
- (instanceMode() === "hub" ? HUB_THEME_COLOR : FALLBACK_THEME_COLOR),
+ instanceMode() === "hub"
+ ? BASE_GROUNDS.dark
+ : [
+ { media: "(prefers-color-scheme: light)", color: BASE_GROUNDS.light },
+ { media: "(prefers-color-scheme: dark)", color: BASE_GROUNDS.dark },
+ ],
width: "device-width",
initialScale: 1,
// Let the layout run under the notch/home indicator; the safe-area insets
@@ -81,26 +101,25 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- // Per-site accent: bake the brand-color override onto <html> at prerender so
- // it's present on first paint (before any CSS/JS). Unset → family brass.
- const accentVars = siteAccentVars(currentSite().accent);
+ // The accent is baked onto <html> at prerender, so it is right on first
+ // paint before any script: `data-accent` selects the tokens.css rule, and a
+ // custom hex adds its fitted per-base values inline. A reader's stored pick
+ // replaces `data-accent` pre-paint (ThemeScript).
const theme = themeDefaults();
return (
<html
lang="en"
suppressHydrationWarning
className={`${fontVars} h-full antialiased`}
- style={accentVars ?? undefined}
+ data-accent={theme.accent}
+ style={(theme.accentVars as React.CSSProperties | null) ?? undefined}
>
<body className="min-h-full flex flex-col bg-background text-foreground font-sans">
- <ThemeScript
- defaultTheme={theme.defaultTheme}
- defaultMode={theme.defaultMode}
- />
+ <ThemeScript defaultBase={theme.defaultBase} />
{shipsPwa() && <ServiceWorkerRegister />}
<ThemeProvider
- defaultTheme={theme.defaultTheme}
- defaultMode={theme.defaultMode}
+ defaultBase={theme.defaultBase}
+ siteAccent={theme.accent}
>
<QueryProvider>
<Header />
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 { BASE_GROUNDS, DEFAULT_ACCENT } from "yt-dlp-transcript-common/lib/brand";
import {
PROJECT_NAME,
PROJECT_TAGLINE,
@@ -11,11 +12,6 @@ import Header from "./components/Header";
import Footer from "./components/Footer";
import "./globals.css";
-// Browser-chrome color: the archilyzer family's dark ground (tokens.css). Not
-// an accent — this family has no decorative brand hue, and the phone's chrome
-// should continue the faceplate rather than announce a colour.
-const THEME_COLOR = "#151b20";
-
// PRODUCT identity, not the operator's. This used to read `currentHomepage()`,
// the editable config in sites/_homepage/homepage.json — correct when this was
// "our hub", wrong now that it is the software's own site. One visible
@@ -54,8 +50,10 @@ export const metadata: Metadata = {
twitter: { card: "summary" },
};
+// Browser chrome continues the page's ground (the dark base the site opens
+// on), not an accent; the pre-paint ThemeScript follows a reader's other base.
export function generateViewport(): Viewport {
- return { themeColor: THEME_COLOR };
+ return { themeColor: BASE_GROUNDS.dark };
}
export default function RootLayout({
@@ -68,10 +66,13 @@ export default function RootLayout({
lang="en"
suppressHydrationWarning
className={`${fontVars} h-full antialiased`}
+ data-accent={DEFAULT_ACCENT}
>
<body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--brand-soft)] selection:text-[var(--foreground)]">
- <ThemeScript defaultTheme="archilyzer" defaultMode="dark" />
- <ThemeProvider defaultTheme="archilyzer" defaultMode="dark">
+ {/* The project's own site opens on the dark base in Signal, the
+ family's accent; a reader can pick any other. */}
+ <ThemeScript defaultBase="dark" />
+ <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT}>
<Header />
<main className="flex-1 w-full">{children}</main>
<Footer />