"use client"; /** * THE APPEARANCE APPLICATION (thin settings wave, 2026-09-19): the settings * record's appearance section actually DOES something — resolved against * the factory defaults and applied as RUNTIME CSS variables on . * * Why variables and not className swaps: every seam the settings touch was * built token-first (--font-prose, --lh-prose-size, --lh-ink-strong, …), * so application is setProperty and nothing re-renders. The ONE thing the * variables can't reach — the mode — is dark-only this wave (the light * palette is designed in the later design wave; is * static in app/layout.tsx). * * The provider is mounted at the ROOT LAYOUT level (like ActivityProvider): * every page picks appearance up on load. The settings page writes a * section, hands the new appearance here, and the vars move immediately. * * (The open-settings keyboard chord lives in components/keyboard-shortcuts.tsx * with the other navigations — mod+, pushes /settings from anywhere.) */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { FACTORY_SETTINGS, type AppearanceSettings } from "@/src/core"; /** The resolved, fully-concrete appearance view (delta + factory merged). */ export function resolveAppearance(delta: AppearanceSettings | undefined) { const f = FACTORY_SETTINGS.appearance; return { mode: delta?.mode ?? f.mode, background: delta?.background ?? f.background, fontFamily: delta?.fontFamily ?? f.fontFamily, proseFontSize: delta?.proseFontSize ?? f.proseFontSize, sessionNameSerif: delta?.sessionNameSerif ?? f.sessionNameSerif, mdRendering: { headingWeight: delta?.mdRendering?.headingWeight ?? f.mdRendering.headingWeight, boldColor: delta?.mdRendering?.boldColor ?? f.mdRendering.boldColor, italicColor: delta?.mdRendering?.italicColor ?? f.mdRendering.italicColor, }, }; } /** One channel clamped on the dark curve: additive offsets matching today's * hand-picked palette (#141414 → card #1c1c1c (+8) → accent #2a2a2a (+22) * → border #434343 (+47)) — the "per-mode derivation in code" the schema * promises, as the thinnest curve that keeps the palette's relationships. */ function raise(hex: string, add: number): string { const n = hex.length === 4 ? hex.slice(1).split("").map((c) => parseInt(c + c, 16)) : [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; return `#${n.map((c) => Math.min(255, c + add).toString(16).padStart(2, "0")).join("")}`; } /** The derived palette a chosen background implies (dark curve): background * itself, the raised surfaces (card/popover/secondary/muted, +8), the * accent (+22), the edges (border/input, +47), and primary-foreground * (the inverted text on primary = the background's own color, as today). */ export function derivedPalette(background: string): Record { const card = raise(background, 8); const accent = raise(background, 22); const border = raise(background, 47); return { "--background": background, "--card": card, "--popover": card, "--secondary": card, "--muted": card, "--accent": accent, "--border": border, "--input": border, "--primary-foreground": background, }; } /** THE mapping: resolved appearance → the CSS variables to set on . * Absent entries = leave the factory stylesheet value in place. Pure — * exported for tests. */ export function appearanceVars(a: ReturnType): Record { const vars: Record = {}; if (a.background !== null) Object.assign(vars, derivedPalette(a.background)); if (a.fontFamily !== null) { vars["--font-prose"] = `"${a.fontFamily}", var(--font-sans3), ui-sans-serif, system-ui, sans-serif`; } // The ONE documented reading-size variable (typography round 4): prose, // inputs, and session titles all read it, so one setProperty moves them. vars["--lh-prose-size"] = `${a.proseFontSize}px`; // Session names: CMU Serif by default; the toggle swaps the title font // to the prose family — covers chat.tsx + new-session.tsx titles with // zero component edits (they use the .font-title utility). if (!a.sessionNameSerif) vars["--font-title"] = "var(--font-prose)"; // Markdown rendering: heading weight + the emphasis inks (the vars // were built "ready for the settings UI"). vars["--lh-heading-weight"] = String(a.mdRendering.headingWeight); if (a.mdRendering.boldColor !== null) vars["--lh-ink-strong"] = a.mdRendering.boldColor; if (a.mdRendering.italicColor !== null) vars["--lh-ink-italic"] = a.mdRendering.italicColor; return vars; } interface AppearanceContextValue { /** Resolved appearance; factory until the record arrives. */ appearance: ReturnType; /** Called by the settings page after a successful section PUT. */ onAppearanceChange: (delta: AppearanceSettings | undefined) => void; } const AppearanceContext = createContext({ appearance: resolveAppearance(undefined), onAppearanceChange: () => {}, }); export function useAppearance() { return useContext(AppearanceContext); } export function AppearanceProvider({ children }: { children: React.ReactNode }) { const [delta, setDelta] = useState(undefined); const appliedRef = useRef(""); // The vars apply on every change — stringified as the change signal. const vars = useMemo(() => appearanceVars(resolveAppearance(delta)), [delta]); const key = useMemo(() => JSON.stringify(vars), [vars]); useEffect(() => { if (appliedRef.current === key) return; appliedRef.current = key; const root = document.documentElement; for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value); }, [key, vars]); // One fetch per app load: the record (sanitized server-side) carries the // delta; resolution happens here against the compiled factory. useEffect(() => { let alive = true; fetch("/api/settings") .then((r) => r.json()) .then((body: { settings?: Record }) => { if (!alive) return; // The record arrives sanitized server-side; the appearance // slice is unknown-shaped here, so it goes through the same // resolve (absent/invalid fields = factory anyway). const appearance = body.settings?.appearance; setDelta( appearance && typeof appearance === "object" ? (appearance as AppearanceSettings) : undefined, ); }) .catch(() => {/* settings stay factory — not worth a crash */}); return () => { alive = false; }; }, []); const onAppearanceChange = useCallback((next: AppearanceSettings | undefined) => { setDelta(next); }, []); const value = useMemo( () => ({ appearance: resolveAppearance(delta), onAppearanceChange }), [delta, onAppearanceChange], ); return {children}; }