Archilyzer · Source

archilyzer

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

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:
Mcommon/components/ThemeMenu.tsx | 63++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/components/ThemeProvider.tsx | 268+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcommon/components/ThemeScript.tsx | 70+++++++++++++++++++---------------------------------------------------
Mcommon/components/ThemeToggle.tsx | 35+++++++++++++++++++----------------
Mcommon/components/themeConfig.ts | 42+++++++++++++++++++++++++-----------------
Mcommon/components/ui/sonner.tsx | 9+++++----
Mexport/app/components/MobileMenu.tsx | 76++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mexport/app/layout.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mhomepage/app/layout.tsx | 17+++++++++--------
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 />