diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx
new file mode 100644
index 0000000..b61f179
--- /dev/null
+++ b/components/settings/settings-dialog.tsx
@@ -0,0 +1,331 @@
+"use client";
+/**
+ * THE SETTINGS DIALOG (settings-app wave, 2026-09-20): the thin settings
+ * page graduated into the designed surface — a centered modal over
+ * everything, left section nav + one scrolling content column.
+ *
+ * Owner decisions this wave:
+ * - Dialog, not a page: opens over whatever screen you're on (the payoff:
+ * appearance edits restyle the chat LIVE behind the dialog). Escape,
+ * backdrop, and ✕ all close. Modal-only — /settings redirects, so
+ * nothing links into a dead surface.
+ * - Left section nav in the app's mono chrome (text-only, session-list
+ * language — active = bg-accent), no icons, matching the minimal-color
+ * doctrine.
+ * - The dialog is surface-agnostic by construction: the sections talk to
+ * /api/settings* and localStorage only, so this shell can be re-mounted
+ * as a true OS window in the Electron round with near-zero churn.
+ *
+ * The record + the single `write` seam moved here from the thin page:
+ * one record instance for the whole dialog, sections write through it,
+ * switching sections never refetches.
+ */
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+import {
+ CURRENT_SETTINGS_VERSION,
+ type AgentSettings,
+ type AppearanceSettings,
+ type GraphSettings,
+ type UserSettings,
+} from "@/src/core";
+import { useAppearance } from "./appearance.tsx";
+import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
+import { AgentSection } from "./agent-section.tsx";
+import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
+import { KeyboardSection } from "./keyboard-section.tsx";
+
+const SECTIONS = [
+ { id: "general", label: "General", hint: "App-level behavior" },
+ {
+ id: "agent",
+ label: "Agent",
+ hint: "Background-role models, tools, and prompt overrides",
+ },
+ {
+ id: "appearance",
+ label: "Appearance",
+ hint: "Applied live as CSS variables",
+ },
+ {
+ id: "graph",
+ label: "Graph",
+ hint: "Dependency ladder — a disabled rung grays everything below it",
+ },
+ {
+ id: "keyboard",
+ label: "Keyboard",
+ hint: "Rebinds apply instantly — stored in this browser",
+ },
+] as const;
+type SectionId = (typeof SECTIONS)[number]["id"];
+
+const DialogContext = createContext<{ open: () => void }>({ open: () => {} });
+
+/** AppShell-level mount: the sidebar row and the mod+, chord call this. */
+export function SettingsDialogProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [show, setShow] = useState(false);
+ const open = useCallback(() => setShow(true), []);
+ return (
+
+ {children}
+ {show && setShow(false)} />}
+
+ );
+}
+
+export function useSettingsDialog() {
+ return useContext(DialogContext);
+}
+
+function SettingsDialog({ onClose }: { onClose: () => void }) {
+ // Plain dim backdrop (no blur — nothing decorative); the click
+ // closes, the dialog body stops propagation.
+ return createPortal(
+
+
+
,
+ document.body,
+ );
+}
+
+/** The dialog without the portal/backdrop — the SSR-testable frame
+ * (portals render nothing under renderToStaticMarkup). */
+export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
+ const { onAppearanceChange } = useAppearance();
+ const [record, setRecord] = useState(
+ undefined,
+ );
+ const [error, setError] = useState(null);
+ const [active, setActive] = useState("general");
+
+ // Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT
+ // (no record = factory everything), not a failure — the error state is
+ // a non-200 or a network throw only.
+ useEffect(() => {
+ fetch("/api/settings")
+ .then((r) =>
+ r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)),
+ )
+ .then((body: { settings: UserSettings | null }) =>
+ setRecord(body.settings ?? { version: CURRENT_SETTINGS_VERSION }),
+ )
+ .catch(() => setRecord(null));
+ }, []);
+
+ // Escape closes — the layered-dismiss convention (Esc is dead for the
+ // sections' own editors while they hold focus; this listener is the
+ // dialog's outer layer).
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ /** ONE writer for the whole dialog: PUT the whole section, keep the
+ * record mirror in sync, surface route errors inline (a 400 is a
+ * schema mistake caught by the validation surface). */
+ const write = useCallback(
+ async (
+ section: "general" | "agent" | "appearance" | "graph",
+ body: object,
+ ) => {
+ setError(null);
+ const res = await fetch(`/api/settings/${section}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const detail = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ setError(`${section}: ${detail.error ?? res.status}`);
+ return;
+ }
+ setRecord(
+ (prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings,
+ );
+ if (section === "appearance")
+ onAppearanceChange(body as AppearanceSettings);
+ },
+ [onAppearanceChange],
+ );
+
+ return (
+
e.stopPropagation()}
+ >
+ {/* Header — the app's quiet CMU title language. */}
+
+
Settings
+
+
+
+
+ {/* Left nav — text-only, the session-list mono language,
+ active = bg-accent. */}
+
+
+ {/* Content column — one section at a time, this scrolls. */}
+
+ {error && (
+
+ {error}
+
+ )}
+ {record === undefined ? (
+
+ Loading settings…
+
+ ) : record === null ? (
+
+ Settings could not be loaded — refresh to retry.
+