Settings-dialog round 1b: un-double the section inset
The dialog's content wrapper added px-6 py-5 ON TOP of SettingsSection's own padding (48px horizontal, 81px top — measured live). The wrapper is now bare; the section component owns its rhythm, and the loading/error states carry their own padding. Section box now spans the scroll column (61px from the dialog top = the 60px header band), text inset 24px. Gates: typecheck, lint, 701/701.
This commit is contained in:
@@ -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 (
|
||||
<DialogContext.Provider value={{ open }}>
|
||||
{children}
|
||||
{show && <SettingsDialog onClose={() => setShow(false)} />}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||
data-testid="settings-backdrop"
|
||||
onClick={onClose}
|
||||
>
|
||||
<SettingsDialogBody onClose={onClose} />
|
||||
</div>,
|
||||
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<UserSettings | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [active, setActive] = useState<SectionId>("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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Settings"
|
||||
data-testid="settings-dialog"
|
||||
className="settings-dialog-pop flex h-[620px] max-h-[85vh] w-[920px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header — the app's quiet CMU title language. */}
|
||||
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
||||
<h2 className="font-title text-2xl tracking-tight">Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close settings"
|
||||
data-testid="settings-close"
|
||||
className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="mx-auto size-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left nav — text-only, the session-list mono language,
|
||||
active = bg-accent. */}
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
data-testid="settings-nav"
|
||||
className="flex w-44 shrink-0 flex-col border-r border-border p-2"
|
||||
>
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
data-testid={`settings-nav-${s.id}`}
|
||||
aria-current={active === s.id}
|
||||
className={`rounded-sm px-3 py-1.5 text-left font-ui text-sm transition-colors ${
|
||||
active === s.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActive(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content column — one section at a time, this scrolls. */}
|
||||
<div
|
||||
className="min-w-0 flex-1 overflow-y-auto"
|
||||
data-testid="settings-content"
|
||||
>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
|
||||
data-testid="settings-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{record === undefined ? (
|
||||
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
||||
Loading settings…
|
||||
</p>
|
||||
) : record === null ? (
|
||||
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
||||
Settings could not be loaded — refresh to retry.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{active === "general" && (
|
||||
<SettingsSection
|
||||
title="General"
|
||||
hint="App-level behavior"
|
||||
testid="section-general"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Auto-update"
|
||||
hint="The update mechanism ships in a later wave"
|
||||
testid="row-auto-update"
|
||||
>
|
||||
<Toggle
|
||||
checked={record.general?.autoUpdate ?? true}
|
||||
label="auto update"
|
||||
onChange={(on) =>
|
||||
void write("general", { autoUpdate: on })
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "agent" && (
|
||||
<SettingsSection
|
||||
title="Agent"
|
||||
hint="Background-role models, tools, and prompt overrides"
|
||||
testid="section-agent"
|
||||
>
|
||||
<AgentSection
|
||||
agent={record.agent}
|
||||
onSectionChange={(next: AgentSettings) =>
|
||||
void write("agent", next)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "appearance" && (
|
||||
<SettingsSection
|
||||
title="Appearance"
|
||||
hint="Applied live as CSS variables"
|
||||
testid="section-appearance"
|
||||
>
|
||||
<AppearanceSection
|
||||
appearance={record.appearance}
|
||||
onSectionChange={(next: AppearanceSettings) =>
|
||||
void write("appearance", next)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "graph" && (
|
||||
<SettingsSection
|
||||
title="Graph"
|
||||
hint="Dependency ladder — a disabled rung grays everything below it"
|
||||
testid="section-graph"
|
||||
>
|
||||
<GraphSection
|
||||
graph={record.graph}
|
||||
onSectionChange={(next: GraphSettings) =>
|
||||
void write("graph", next)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "keyboard" && (
|
||||
<SettingsSection
|
||||
title="Keyboard"
|
||||
hint="Rebinds apply instantly — stored in this browser"
|
||||
testid="section-keyboard"
|
||||
>
|
||||
<KeyboardSection />
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user