+ Stored in this browser (lh:keymap) — overrides only.
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx
new file mode 100644
index 0000000..edcae8a
--- /dev/null
+++ b/components/settings/settings-page.tsx
@@ -0,0 +1,135 @@
+"use client";
+/**
+ * THE SETTINGS PAGE (thin settings wave, 2026-09-19): one column, section
+ * blocks in fixed order — General, Agent, Appearance, Graph, Keyboard —
+ * over the versioned delta record at GET /api/settings, written per
+ * section through PUT /api/settings/[section] (replace-whole).
+ *
+ * Thin by owner decision: simple controls, real read/write, the point is
+ * a WORKING validation surface that exercises the whole settings schema —
+ * not the designed settings app. Polish lives in a later wave.
+ *
+ * The chat section (new-session defaults) is deliberately NOT here
+ * (owner decision): its controls live on the /chat/new surface; two
+ * editors for one value is a drift bug. The auth/provider-keys page is
+ * deliberately absent (needs auth.json plumbing that doesn't exist).
+ */
+
+import { useCallback, useEffect, useState } from "react";
+import { CURRENT_SETTINGS_VERSION, type AgentSettings, type AppearanceSettings, type GraphSettings, type UserSettings } from "@/src/core";
+import { SidebarToggle } from "@/components/sidebar.tsx";
+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";
+
+export function SettingsPage() {
+ const { onAppearanceChange } = useAppearance();
+ const [record, setRecord] = useState(undefined);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ // 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.
+ 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));
+ }, []);
+
+ /** ONE writer per page: PUT the whole section, keep the record mirror
+ * in sync, surface route errors inline (a 400 is this wave's point —
+ * the validation surface catching schema mistakes). */
+ 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 (
+
+
+ {/* The app header — same 60px band, same border, same CMU title
+ (the alignment constant from header round 6). */}
+
+
+
Settings
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {record === undefined ? (
+
Loading settings…
+ ) : record === null ? (
+
+ Settings could not be loaded — refresh to retry.
+
+ );
+}
\ No newline at end of file
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index 91f170c..89d05f6 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -3,7 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
-import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
+import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock";
import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner";
@@ -482,6 +482,20 @@ function SessionsSidebar() {
flex-1, so this pins to the bottom. Sidebar closed = the
learner opted out (no ambient signal, user decision). */}
+
+ {/* Settings entry (thin settings wave): the same row language
+ as New session — quiet, bottom-pinned, one chord away is
+ mod+, (the keyboard registry's open-settings action). */}
+
+
+
+ Settings
+
+
);
diff --git a/src/core/keyboard.ts b/src/core/keyboard.ts
index 9f911df..c188882 100644
--- a/src/core/keyboard.ts
+++ b/src/core/keyboard.ts
@@ -39,7 +39,8 @@ export type KeyAction =
| "question-next-row"
| "question-prev-row"
| "question-pick-row"
- | "question-submit";
+ | "question-submit"
+ | "open-settings";
/** Where the cheat sheet groups an action (display only — dispatch is
* global; a seam that isn't mounted simply no-ops). */
@@ -59,6 +60,7 @@ export interface KeyActionDef {
export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
{ id: "toggle-dictation", label: "Toggle dictation", scope: "global", binding: "mod+d" },
+ { id: "open-settings", label: "Open settings", scope: "global", binding: "mod+," },
{ id: "open-model-picker", label: "Model selector", scope: "global", binding: "mod+k" },
{ id: "cycle-thinking", label: "Cycle thinking level", scope: "global", binding: "shift+tab" },
{ id: "toggle-sidebar", label: "Toggle sessions sidebar", scope: "global", binding: "mod+b" },
@@ -91,7 +93,7 @@ const STORAGE_KEY = "lh:keymap";
/** localStorage overrides ({actionId: binding}) — read once per module
* instance; the settings editor will write this key. Guarded: SSR and a
* corrupt/absent store both fall back to defaults. */
-function loadOverrides(): Partial> {
+export function loadOverrides(): Partial> {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
@@ -104,6 +106,19 @@ function loadOverrides(): Partial> {
return {};
}
+/** Write the full overrides map ({actionId: binding}) — the settings
+ * editor's rebind/reset persistence. Guarded: SSR no-ops. An empty
+ * map clears the store entirely (all-defaults). */
+export function saveOverrides(overrides: Partial>): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (Object.keys(overrides).length) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
+ else window.localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ /* storage unavailable = overrides don't persist, defaults keep working */
+ }
+}
+
/** The live key map: defaults with user overrides applied. */
export function effectiveKeymap(): Map {
const overrides = loadOverrides();
diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx
new file mode 100644
index 0000000..e44f3f3
--- /dev/null
+++ b/tests/render-settings.test.tsx
@@ -0,0 +1,150 @@
+/**
+ * Settings-surface render tests (thin settings wave): SSR of the section
+ * components pins the THIN markup — toggle rows, the graph ladder's
+ * graying, the mode control's honest disabled state, the role rows and
+ * tool rows. Fetches don't run under renderToStaticMarkup (effects never
+ * fire), so the SSR state IS the loading state — asserted as such.
+ * Same tsx classic-JSX shim as render-sidebar.test.tsx.
+ */
+import React from "react";
+(globalThis as Record).React = React;
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section.tsx";
+import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx";
+import { KeyboardSection } from "../components/settings/keyboard-section.tsx";
+import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
+
+/** The full opening tag of the element carrying a testid — attribute
+ * order in React output is not contractual, so assertions run on the
+ * whole tag instead of ordered regex chains. */
+function tagOf(html: string, testid: string): string {
+ const i = html.indexOf(`data-testid="${testid}"`);
+ assert.ok(i >= 0, `missing ${testid}`);
+ return html.slice(html.lastIndexOf("<", i), html.indexOf(">", i) + 1);
+}
+
+describe("AgentSection (thin SSR)", () => {
+ it("renders all six background-role rows, Default-first selects", () => {
+ const html = renderToStaticMarkup( {}} />);
+ for (const role of ["researcher", "demoBuilder", "visualMaker", "extractor", "auditor", "labeler"]) {
+ assert.ok(html.includes(`data-testid="role-row-${role}"`), role);
+ assert.ok(html.includes(`data-testid="role-model-${role}"`), `${role} model select`);
+ }
+ assert.equal((html.match(/>Default<\/option>/g) ?? []).length, 12, "six roles × two selects");
+ });
+
+ it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
+ assert.deepEqual([...TOGGLE_TOOLS], ["research", "demo", "visual", "pdf"]);
+ const html = renderToStaticMarkup( {}} />);
+ for (const id of TOGGLE_TOOLS) assert.ok(html.includes(`data-testid="tool-row-${id}"`), id);
+ });
+
+ it("tools default ON (absent key = enabled)", () => {
+ const html = renderToStaticMarkup( {}} />);
+ for (const id of TOGGLE_TOOLS) {
+ assert.ok(tagOf(html, `toggle-tool-${id}`).includes('aria-checked="true"'), `${id} defaults on`);
+ }
+ });
+
+ it("an overridden role shows its model in the select, tools carry the delta", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ // SSR has no catalog: the stored id renders as its own option (a select
+ // that showed "Default" over a live override would lie about the record).
+ assert.ok(html.includes(`value="z-ai/glm-4.7-flash" selected=""`), "the override id is selected, visible");
+ assert.ok(tagOf(html, "toggle-tool-demo").includes('aria-checked="false"'), "demo off");
+ assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
+ });
+
+ it("prompts show the loading state under SSR (effects never fire)", () => {
+ const html = renderToStaticMarkup( {}} />);
+ assert.ok(html.includes("Loading prompt segments"));
+ });
+});
+
+describe("GraphSection — the dependency ladder", () => {
+ it("defaults: all four rungs on, none disabled", () => {
+ const html = renderToStaticMarkup( {}} />);
+ for (const key of ["enabled", "embedding", "clustering", "labeling"]) {
+ assert.ok(html.includes(`data-testid="graph-row-${key}"`), key);
+ assert.ok(!tagOf(html, `toggle-graph-${key}`).includes("disabled"), `${key} touchable`);
+ }
+ });
+
+ it("master off grays EVERY dependent (stored values persist, resolve inert)", () => {
+ const html = renderToStaticMarkup(
+ {}} />,
+ );
+ for (const key of ["embedding", "clustering", "labeling"]) {
+ assert.ok(tagOf(html, `toggle-graph-${key}`).includes("disabled"), `${key} disabled`);
+ }
+ assert.ok(!tagOf(html, "toggle-graph-enabled").includes("disabled"), "master itself stays touchable");
+ });
+
+ it("a mid-ladder break grays only what's below it", () => {
+ const html = renderToStaticMarkup(
+ {}} />,
+ );
+ assert.ok(!tagOf(html, "toggle-graph-embedding").includes("disabled"), "embedding touchable");
+ assert.ok(!tagOf(html, "toggle-graph-clustering").includes("disabled"), "clustering touchable");
+ assert.ok(tagOf(html, "toggle-graph-labeling").includes("disabled"), "labeling grayed");
+ });
+});
+
+describe("AppearanceSection (thin SSR)", () => {
+ it("mode is rendered DISABLED — dark-only this wave, no lying control", () => {
+ const html = renderToStaticMarkup( {}} />);
+ assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled");
+ assert.ok(html.includes("Light mode ships with the design wave"));
+ });
+
+ it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => {
+ const html = renderToStaticMarkup( {}} />);
+ assert.ok(html.includes(`placeholder="#141414"`));
+ assert.ok(html.includes(`placeholder="Source Sans 3"`));
+ assert.ok(html.includes(`placeholder="#c96b4a"`));
+ assert.ok(html.includes("16px — prose, inputs, session titles scale together"));
+ });
+
+ it("a set background shows in the input, not the placeholder", () => {
+ const html = renderToStaticMarkup(
+ {}} />,
+ );
+ assert.ok(html.includes(`value="#1a1a2e"`));
+ });
+});
+
+describe("KeyboardSection (thin SSR)", () => {
+ it("renders every registry action in its scope group", () => {
+ const html = renderToStaticMarkup();
+ for (const def of KEYBOARD_ACTIONS) {
+ assert.ok(html.includes(`data-testid="kbd-row-${def.id}"`), def.id);
+ }
+ assert.ok(html.includes("kbd-scope-global"));
+ assert.ok(html.includes("kbd-scope-chat"));
+ });
+
+ it("open-settings is in the registry with the settings chord", () => {
+ const def = KEYBOARD_ACTIONS.find((a) => a.id === "open-settings");
+ assert.ok(def, "the action exists");
+ assert.equal(def?.scope, "global");
+ assert.equal(def?.binding, "mod+,");
+ });
+
+ it("displayOnly actions list but never offer Rebind", () => {
+ const html = renderToStaticMarkup();
+ assert.ok(html.includes("Owned by a surface — not rebindable"));
+ assert.ok(!html.includes(`data-testid="kbd-rebind-question-next-row"`), "surface keys stay put");
+ assert.ok(!html.includes(`data-testid="kbd-rebind-model-menu-pick"`));
+ });
+});
\ No newline at end of file
diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts
new file mode 100644
index 0000000..1e8d6f4
--- /dev/null
+++ b/tests/settings-pure.test.ts
@@ -0,0 +1,139 @@
+/**
+ * The settings wave's pure layers (thin settings wave): the appearance →
+ * CSS-variable mapping (the section must actually DO something), the
+ * dark-curve background derivation, the agent section's compose helpers
+ * (the replace-whole PUT discipline), and the keyboard overrides map.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { appearanceVars, derivedPalette, resolveAppearance } from "../components/settings/appearance.tsx";
+import { composePromptSwitch, composeRoleModel, composeTool } from "../components/settings/agent-section.tsx";
+import { nextOverrides } from "../components/settings/keyboard-section.tsx";
+
+describe("resolveAppearance — delta over factory", () => {
+ it("absent everything = the factory view", () => {
+ const a = resolveAppearance(undefined);
+ assert.equal(a.mode, "dark");
+ assert.equal(a.background, null);
+ assert.equal(a.fontFamily, null);
+ assert.equal(a.proseFontSize, 16);
+ assert.equal(a.sessionNameSerif, true);
+ assert.deepEqual(a.mdRendering, { headingWeight: 900, boldColor: null, italicColor: null });
+ });
+
+ it("explicit deltas win field-by-field", () => {
+ const a = resolveAppearance({ proseFontSize: 20, fontFamily: "Iowan Old Style" });
+ assert.equal(a.proseFontSize, 20);
+ assert.equal(a.fontFamily, "Iowan Old Style");
+ assert.equal(a.background, null);
+ });
+});
+
+describe("derivedPalette — the dark curve keeps today's relationships", () => {
+ it("the factory background reproduces today's palette EXACTLY", () => {
+ assert.deepEqual(derivedPalette("#141414"), {
+ "--background": "#141414",
+ "--card": "#1c1c1c",
+ "--popover": "#1c1c1c",
+ "--secondary": "#1c1c1c",
+ "--muted": "#1c1c1c",
+ "--accent": "#2a2a2a",
+ "--border": "#434343",
+ "--input": "#434343",
+ "--primary-foreground": "#141414",
+ });
+ });
+
+ it("an off-factory background shifts every surface with it (clamped at 255)", () => {
+ const p = derivedPalette("#f0f0f0");
+ assert.equal(p["--background"], "#f0f0f0");
+ assert.equal(p["--card"], "#f8f8f8"); // 240 + 8 per channel
+ assert.equal(p["--border"], "#ffffff", "240 + 47 clamps to 255 on every channel");
+ });
+
+ it("3-digit hex expands", () => {
+ assert.equal(derivedPalette("#abc")["--card"], "#b2c3d4"); // aa bb cc each +8
+ });
+});
+
+describe("appearanceVars — the mapping the provider applies", () => {
+ it("factory appearance: only the always-applied tokens move", () => {
+ const vars = appearanceVars(resolveAppearance(undefined));
+ assert.equal(vars["--lh-prose-size"], "16px");
+ assert.equal(vars["--lh-heading-weight"], "900");
+ assert.equal(vars["--font-prose"], undefined, "no font override at factory");
+ assert.equal(vars["--font-title"], undefined, "CMU stays");
+ assert.equal(vars["--background"], undefined, "factory palette untouched");
+ assert.equal(vars["--lh-ink-strong"], undefined, "default ink");
+ });
+
+ it("full delta: every knob lands on its token", () => {
+ const vars = appearanceVars(
+ resolveAppearance({
+ background: "#101820",
+ fontFamily: "Georgia",
+ proseFontSize: 18,
+ sessionNameSerif: false,
+ mdRendering: { headingWeight: 700, boldColor: "#ff0000", italicColor: "#00ff00" },
+ }),
+ );
+ assert.equal(vars["--background"], "#101820");
+ assert.equal(vars["--card"], "#182028", "raised +8 per channel: 10→18, 18→20, 20→28");
+ assert.equal(vars["--font-prose"], `"Georgia", var(--font-sans3), ui-sans-serif, system-ui, sans-serif`);
+ assert.equal(vars["--lh-prose-size"], "18px");
+ assert.equal(vars["--font-title"], "var(--font-prose)", "serif off → titles follow the prose font");
+ assert.equal(vars["--lh-heading-weight"], "700");
+ assert.equal(vars["--lh-ink-strong"], "#ff0000");
+ assert.equal(vars["--lh-ink-italic"], "#00ff00");
+ });
+
+ it("sessionNameSerif false alone: titles move, nothing else does", () => {
+ const vars = appearanceVars(resolveAppearance({ sessionNameSerif: false }));
+ assert.equal(vars["--font-title"], "var(--font-prose)");
+ assert.equal(Object.keys(vars).length, 3, "prose-size, heading-weight, font-title only");
+ });
+});
+
+describe("agent section compose helpers — the replace-whole PUT body", () => {
+ it("composeTool carries the rest of the section verbatim", () => {
+ const before = { tools: { demo: false }, prompts: { "system-prompt.md": true } };
+ const after = composeTool(before, "visual", false);
+ assert.deepEqual(after.tools, { demo: false, visual: false });
+ assert.deepEqual(after.prompts, { "system-prompt.md": true }, "prompts untouched");
+ });
+
+ it("composeRoleModel: empty+empty REMOVES the role (absent = config pin)", () => {
+ const after = composeRoleModel({ models: { researcher: { model: { provider: "openrouter", id: "x" } }, auditor: {} } }, "researcher", "", "");
+ assert.equal(after.models?.researcher, undefined, "role dropped");
+ assert.deepEqual(after.models?.auditor, {}, "sibling untouched");
+ });
+
+ it("composeRoleModel: model alone, thinking alone, and both", () => {
+ assert.deepEqual(composeRoleModel(undefined, "labeler", "z-ai/glm-4.7-flash", "").models?.labeler, {
+ model: { provider: "openrouter", id: "z-ai/glm-4.7-flash" },
+ });
+ assert.deepEqual(composeRoleModel(undefined, "labeler", "", "low").models?.labeler, { thinkingLevel: "low" });
+ assert.deepEqual(composeRoleModel(undefined, "labeler", "m1", "high").models?.labeler, {
+ model: { provider: "openrouter", id: "m1" },
+ thinkingLevel: "high",
+ });
+ });
+
+ it("composePromptSwitch: on = explicit true, off = key REMOVED (absent = off)", () => {
+ assert.deepEqual(composePromptSwitch({ prompts: { a: true } }, "b", true).prompts, { a: true, b: true });
+ assert.deepEqual(composePromptSwitch({ prompts: { a: true, b: true } }, "b", false).prompts, { a: true });
+ });
+});
+
+describe("nextOverrides — the rebind persistence map", () => {
+ it("a rebind sets the action's override, keeping the others", () => {
+ const next = nextOverrides({ "toggle-sidebar": "mod+b" }, "open-settings", "mod+shift+s");
+ assert.deepEqual(next, { "toggle-sidebar": "mod+b", "open-settings": "mod+shift+s" });
+ });
+
+ it("null = reset that action to its default (key removed)", () => {
+ const next = nextOverrides({ "toggle-sidebar": "mod+b", find: "mod+f" }, "toggle-sidebar", null);
+ assert.deepEqual(next, { find: "mod+f" });
+ });
+});
\ No newline at end of file