Thin working settings UI (owner-approved plan): /settings + live appearance + keyboard rebinding

The validation surface for the settings foundation — deliberately thin,
real read/write against the delta record, polish is a later wave.

Mount (owner decisions): dedicated page, single scroll; entry = sidebar
bottom row + ⌘/Ctrl+, (open-settings in the registry); chat defaults NOT
shown (they live on /chat/new); mode dark-only (light palette = design
wave); final tool list = research/demo/visual/pdf (master-adjudicated).

Sections (PUT /api/settings/[section], replace-whole per section):
- General: auto-update toggle.
- Agent: six background-role model+thinking selects (Default = config
  pin; a stored id stays VISIBLE even when the catalog hasn't loaded or
  dropped it — a select showing Default over a live override would lie),
  the four tool toggles, prompt override switches (file-gated) with
  source/override text editors over the segment routes.
- Appearance: applied LIVE as runtime CSS variables by an
  AppearanceProvider at the root layout — proseFontSize → the one
  reading-size var, fontFamily → --font-prose, background → --background
  + the derived raised/accent/edge palette (dark curve, today's exact
  spacing: +8/+22/+47 per channel, clamped), sessionNameSerif →
  --font-title (titles flip with ZERO chat.tsx edits), headingWeight →
  new --lh-heading-weight token, bold/italic colors → the lh-ink vars.
  Mode renders honestly DISABLED.
- Graph: the ladder grays a rung when any ANCESTOR is effectively off
  (stored values persist, resolve inert — switch shows stored, graying
  shows effective).
- Keyboard: view/rebind/reset over the registry; overrides persist in
  lh:keymap and apply INSTANTLY (dispatcher reads per keydown); bare
  modifier presses are ignored while armed; displayOnly actions list but
  never rebind.

First-boot fix: {settings: null} is no-record, not fetch-failure.

CROSS-LANE (flagged for master): src/core/keyboard.ts gained the
open-settings action/binding and exports saveOverrides/loadOverrides
(one-liners; no existing pins); app/layout.tsx mounts the provider.

Live-verified: structure, record writes (general/tools/graph/appearance),
prose-size applying live (16→20px), rebind + chord navigation from
another page, ladder graying, no console errors. Gates: typecheck,
lint, 689/689 (28 new), build.
This commit is contained in:
amosblomqvist
2026-09-19 18:27:37 +03:00
parent 524bf6024a
commit c32915b232
14 changed files with 1465 additions and 9 deletions
+5 -1
View File
@@ -48,6 +48,8 @@
the reskin's hand-tuned 16.5 to the ladder's 16.) */
--lh-prose-size: 16px;
--lh-prose-leading: 1.5;
/* Markdown heading weight (appearance settings; 900 = the Black cut). */
--lh-heading-weight: 900;
}
/* The app is always dark (<html class="dark">); mirror the same values so
@@ -80,6 +82,8 @@
the reskin's hand-tuned 16.5 to the ladder's 16.) */
--lh-prose-size: 16px;
--lh-prose-leading: 1.5;
/* Markdown heading weight (appearance settings; 900 = the Black cut). */
--lh-heading-weight: 900;
}
/* CMU Serif bold (titles) — bundled local file, no CDN dependency. */
@@ -185,7 +189,7 @@ body {
:where keeps specificity zero. Chrome titles (CMU Serif) don't live
under .lh-prose and stay untouched. */
.lh-prose :where(h1, h2, h3, h4, h5, h6) {
font-weight: 900;
font-weight: var(--lh-heading-weight, 900);
}
/* lh-input (owner, typography round 2): lh-prose's 1.5 READING leading
+8 -5
View File
@@ -4,6 +4,7 @@ import "katex/dist/katex.min.css";
import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar";
import { ActivityProvider } from "@/components/activity-dock";
import { AppearanceProvider } from "@/components/settings/appearance";
// Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU
// Serif (the bundled lh-cmu @font-face in globals.css — no CDN).
@@ -19,11 +20,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" className={`dark ${ui.variable} ${prose.variable}`}>
<body className="min-h-dvh antialiased">
<SidebarProvider>
<ActivityProvider>
<AppShell>{children}</AppShell>
</ActivityProvider>
</SidebarProvider>
<AppearanceProvider>
<SidebarProvider>
<ActivityProvider>
<AppShell>{children}</AppShell>
</ActivityProvider>
</SidebarProvider>
</AppearanceProvider>
</body>
</html>
);
+8
View File
@@ -0,0 +1,8 @@
/** The settings route (thin settings wave): server shell over the client
* page — same shape as the chat routes (AppShell wraps via the root
* layout, so the sidebar + its Settings entry come along). */
import { SettingsPage } from "@/components/settings/settings-page.tsx";
export default function Page() {
return <SettingsPage />;
}
+3
View File
@@ -94,6 +94,9 @@ export function KeyboardShortcuts() {
case "new-session":
handlersRef.current.router.push("/chat/new");
break;
case "open-settings":
handlersRef.current.router.push("/settings");
break;
case "jump-graph":
handlersRef.current.router.push("/");
break;
+309
View File
@@ -0,0 +1,309 @@
"use client";
/**
* The Agent section (thin settings wave): background-role model overrides,
* the tool toggles, and the prompt override switches — three blocks, ONE
* section (PUT /api/settings/agent replaces the whole agent object, so
* every change composes models+tools+prompts together from the record).
*
* Tools — the owner's final list (2026-09-19): research, demo, visual, pdf.
* Absent key = enabled in the schema; the UI writes explicit booleans for
* all four (simpler to reason about, sanitize-equivalent).
*
* Models: each of the six background roles gets a Default option (absent
* override = config.ts's pin) plus the /api/models catalog. The thinking
* select shows the CHOSEN model's supported levels when a model override
* exists; without one the factory model is unknown client-side, so the
* standard levels list (the server sanitizer accepts any valid level).
*
* Prompts: GET /api/settings/prompts segments; the switch requires an
* override FILE (route-enforced), the text writes through the segment
* route. Switch state lives in this section's PUT; text does not.
*/
import { useEffect, useState } from "react";
import {
BACKGROUND_MODEL_ROLES,
type AgentSettings,
type BackgroundModelRole,
} from "@/src/core";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ModelCatalogEntry } from "@/src/core";
import { SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
/** The role's display label — schema ids are camelCase, the label is human. */
const ROLE_LABEL: Record<BackgroundModelRole, string> = {
researcher: "Researcher",
demoBuilder: "Demo builder",
visualMaker: "Visual maker",
extractor: "Extractor",
auditor: "Auditor",
labeler: "Labeler",
};
/** The owner's final toggleable-tool list (confirm with the user before
* changing — the Teacher stream settled this 2026-09-19). */
export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
/** SegmentInfo as GET /api/settings/prompts returns it. */
interface SegmentInfo {
path: string;
sourceText: string;
overrideText: string | null;
enabled: boolean;
shared: boolean;
}
/** Compose: one tool toggled, the rest of the section carried verbatim.
* Pure — exported for tests. */
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
return { ...agent, tools: { ...agent?.tools, [id]: on } };
}
/** Compose: one role's model/thinking override. Empty model = absent
* override (Default); empty thinking = thinking falls back. Pure — tests. */
export function composeRoleModel(
agent: AgentSettings | undefined,
role: BackgroundModelRole,
modelId: string,
thinking: string,
): AgentSettings {
const prev = agent?.models?.[role];
const models = { ...agent?.models };
if (!modelId && !thinking) delete models[role];
else {
models[role] = {
...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}),
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
};
}
return { ...agent, models };
}
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
* (absent = off is the schema's resting state; explicit false is the
* same thing but noisier). Pure — tests. */
export function composePromptSwitch(agent: AgentSettings | undefined, path: string, on: boolean): AgentSettings {
const prompts = { ...agent?.prompts };
if (on) prompts[path] = true;
else delete prompts[path];
return { ...agent, prompts };
}
export function AgentSection({
agent,
onSectionChange,
}: {
agent: AgentSettings | undefined;
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
onSectionChange: (next: AgentSettings) => void;
}) {
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
const [openSegment, setOpenSegment] = useState<string | null>(null);
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
const [segmentStatus, setSegmentStatus] = useState<string | null>(null);
// The catalog + the prompt segments — one fetch each per page load.
useEffect(() => {
fetch("/api/models")
.then((r) => r.json())
.then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models))
.catch(() => setCatalog(null));
fetch("/api/settings/prompts")
.then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
.catch(() => setPrompts(null));
}, []);
async function saveSegment(path: string, text: string) {
setSegmentStatus(null);
const res = await fetch(`/api/settings/prompts/${path}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
return;
}
// Refetch: file presence gates the switch, so the view must track it.
fetch("/api/settings/prompts")
.then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
setSegmentStatus(`Saved ${path}`);
}
async function deleteSegment(path: string) {
setSegmentStatus(null);
const res = await fetch(`/api/settings/prompts/${path}`, { method: "DELETE" });
if (!res.ok) {
setSegmentStatus(`Delete failed: ${res.status}`);
return;
}
fetch("/api/settings/prompts")
.then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
}
const segmentRow = (info: SegmentInfo) => {
const fileExists = info.overrideText !== null;
const open = openSegment === info.path;
return (
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
<SettingsRow
label={info.path}
hint={fileExists ? undefined : "no override file — write one below to enable the switch"}
testid={`prompt-row-${info.path}`}
>
<Toggle
checked={info.enabled && fileExists}
disabled={!fileExists}
label={`prompt ${info.path}`}
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
/>
</SettingsRow>
<button
type="button"
onClick={() => {
setOpenSegment(open ? null : info.path);
setSegmentStatus(null);
}}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
{open ? "Hide text" : "Show text"}
</button>
{open && (
<div className="mt-1 flex flex-col gap-2">
{info.shared && (
<p className="text-xs text-muted-foreground/70">Shared base — appears in both agents.</p>
)}
<div>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
<textarea
readOnly
value={info.sourceText}
aria-label={`source ${info.path}`}
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
/>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
<textarea
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
aria-label={`override ${info.path}`}
placeholder="Write the override text…"
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
className="text-xs text-foreground underline-offset-2 hover:underline"
>
Save override
</button>
{fileExists && (
<button
type="button"
onClick={() => void deleteSegment(info.path)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete override
</button>
)}
</div>
</div>
)}
</div>
);
};
return (
<>
{/* ── Models ── */}
{BACKGROUND_MODEL_ROLES.map((role) => {
const override = agent?.models?.[role];
const modelId = override?.model?.id ?? "";
const thinking = override?.thinkingLevel ?? "";
const chosen = catalog?.find((m) => m.id === modelId);
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
return (
<SettingsRow
key={role}
label={ROLE_LABEL[role]}
hint={modelId ? undefined : "Default — the config pin applies"}
testid={`role-row-${role}`}
>
<span className="flex items-center gap-2">
<ThinSelect
value={modelId}
ariaLabel={`${ROLE_LABEL[role]} model`}
testid={`role-model-${role}`}
onChange={(id) => onSectionChange(composeRoleModel(agent, role, id, thinking))}
>
<option value="">Default</option>
{/* The stored id stays VISIBLE even when the catalog
hasn't loaded (SSR) or no longer contains it —
a select that silently shows "Default" over a
live override would lie about the record. */}
{modelId && !catalog?.some((m) => m.id === modelId) && (
<option value={modelId}>{modelId}</option>
)}
{catalog?.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</ThinSelect>
<ThinSelect
value={thinking}
ariaLabel={`${ROLE_LABEL[role]} thinking`}
testid={`role-thinking-${role}`}
onChange={(level) => onSectionChange(composeRoleModel(agent, role, modelId, level))}
>
<option value="">Default</option>
{levels.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</ThinSelect>
</span>
</SettingsRow>
);
})}
{/* ── Tools ── */}
{TOGGLE_TOOLS.map((id) => (
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}>
<Toggle
checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
))}
{/* ── Prompts ── */}
{prompts === null ? (
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
) : (
<div className="flex flex-col gap-4">
{(["prober", "teacher"] as const).map((who) => (
<div key={who}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
<div className="flex flex-col gap-2">
{prompts[who].map((info) => segmentRow(info))}
</div>
</div>
))}
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
</div>
)}
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
/**
* The Appearance + Graph sections (thin settings wave).
*
* Appearance controls resolve against the factory in the provider; writes
* PUT the whole appearance section and hand the delta back so the CSS
* variables move IMMEDIATELY (the section's whole point — it must DO
* something). Hex inputs commit on blur/Enter; the background derives the
* raised/accent/edge palette in code (components/settings/appearance.tsx).
* Mode is dark-only this wave: control rendered DISABLED with a note — the
* light palette is the design wave's work, and a lying control is worse
* than an honest disabled one.
*
* Graph is the dependency ladder (owner decision, 2026-09-19): a disabled
* parent grays its dependents — stored values persist but resolve inert.
*/
import type { AppearanceSettings, GraphSettings } from "@/src/core";
import { useAppearance } from "./appearance.tsx";
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
export function AppearanceSection({
appearance,
onSectionChange,
}: {
/** The record's appearance delta (absent = factory). */
appearance: AppearanceSettings | undefined;
/** PUT the whole appearance section; the page re-applies on success. */
onSectionChange: (next: AppearanceSettings) => void;
}) {
const { appearance: resolved } = useAppearance();
const md = appearance?.mdRendering;
return (
<>
<SettingsRow label="Mode" hint="Light mode ships with the design wave" testid="row-mode">
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
<option value="dark">Dark</option>
</ThinSelect>
</SettingsRow>
<SettingsRow
label="Background"
hint={`One color; raised/accent/edges derive from it. Empty = the dark default (${resolved.background ?? "#141414"})`}
testid="row-background"
>
<CommitInput
value={appearance?.background ?? ""}
placeholder="#141414"
ariaLabel="background hex"
testid="input-background"
onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })}
/>
</SettingsRow>
<SettingsRow
label="Reading font"
hint="Font family name, by name — everything that reads today's Source Sans 3. Empty = Source Sans 3"
testid="row-font"
>
<CommitInput
value={appearance?.fontFamily ?? ""}
placeholder="Source Sans 3"
ariaLabel="font family"
testid="input-font"
onCommit={(v) => onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })}
/>
</SettingsRow>
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px — prose, inputs, session titles scale together`} testid="row-prose-size">
<CommitInput
type="number"
value={String(appearance?.proseFontSize ?? "")}
placeholder="16"
ariaLabel="prose font size"
testid="input-prose-size"
onCommit={(v) => {
const n = Number(v);
if (!Number.isFinite(n)) return; // bad input: no write, factory stays
onSectionChange({ ...appearance, proseFontSize: Math.min(24, Math.max(12, Math.round(n))) });
}}
/>
</SettingsRow>
<SettingsRow label="Session names in serif" hint="CMU Serif titles on/off" testid="row-session-serif">
<Toggle
checked={appearance?.sessionNameSerif ?? true}
label="session names serif"
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
/>
</SettingsRow>
<SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight} — CSS resolves the nearest cut`} testid="row-heading-weight">
<CommitInput
type="number"
value={String(md?.headingWeight ?? "")}
placeholder="900"
ariaLabel="heading weight"
testid="input-heading-weight"
onCommit={(v) => {
const n = Number(v);
if (!Number.isFinite(n)) return;
onSectionChange({
...appearance,
mdRendering: { ...md, headingWeight: Math.min(900, Math.max(100, Math.round(n))) },
});
}}
/>
</SettingsRow>
<SettingsRow label="Bold color" hint="**Bold** spans in teaching prose. Empty = the default ink" testid="row-bold-color">
<CommitInput
value={md?.boldColor ?? ""}
placeholder="#c96b4a"
ariaLabel="bold color"
testid="input-bold-color"
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
/>
</SettingsRow>
<SettingsRow label="Italic color" hint="*Italic* spans. Empty = the default ink" testid="row-italic-color">
<CommitInput
value={md?.italicColor ?? ""}
placeholder="#6fa9d6"
ariaLabel="italic color"
testid="input-italic-color"
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
/>
</SettingsRow>
</>
);
}
const LADDER: readonly {
key: keyof GraphSettings;
label: string;
needs: keyof GraphSettings | null;
hint: string;
}[] = [
{ key: "enabled", label: "Graph", needs: null, hint: "Master: off = no extraction, embedding, or injection" },
{ key: "embedding", label: "Embedding", needs: "enabled", hint: "Vectors + graph-context injection" },
{ key: "clustering", label: "Clustering", needs: "embedding", hint: "Cluster computation + settle" },
{ key: "labeling", label: "Labeling", needs: "clustering", hint: "The LLM cluster-namer" },
];
export function GraphSection({
graph,
onSectionChange,
}: {
graph: GraphSettings | undefined;
onSectionChange: (next: GraphSettings) => void;
}) {
// Stored values (the switches show these) vs EFFECTIVE values (a
// stored-on rung under a broken chain is inert — the schema's ladder).
const stored = {
enabled: graph?.enabled ?? true,
embedding: graph?.embedding ?? true,
clustering: graph?.clustering ?? true,
labeling: graph?.labeling ?? true,
};
const effective = {
enabled: stored.enabled,
embedding: stored.enabled && stored.embedding,
clustering: stored.enabled && stored.embedding && stored.clustering,
labeling: stored.enabled && stored.embedding && stored.clustering && stored.labeling,
};
return (
<>
{LADDER.map(({ key, label, needs, hint }) => {
// Touchable only when every ancestor is EFFECTIVELY on.
const parentOn = needs === null ? true : effective[needs];
return (
<SettingsRow key={key} label={label} hint={!parentOn ? `${hint} — needs ${needs} on` : hint} testid={`graph-row-${key}`}>
<Toggle
checked={stored[key]}
disabled={!parentOn}
label={`graph ${key}`}
onChange={(on) => onSectionChange({ ...graph, [key]: on })}
/>
</SettingsRow>
);
})}
</>
);
}
+159
View File
@@ -0,0 +1,159 @@
"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 <html>.
*
* 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; <html class="dark"> 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<string, string> {
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 <html>.
* Absent entries = leave the factory stylesheet value in place. Pure —
* exported for tests. */
export function appearanceVars(a: ReturnType<typeof resolveAppearance>): Record<string, string> {
const vars: Record<string, string> = {};
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<typeof resolveAppearance>;
/** Called by the settings page after a successful section PUT. */
onAppearanceChange: (delta: AppearanceSettings | undefined) => void;
}
const AppearanceContext = createContext<AppearanceContextValue>({
appearance: resolveAppearance(undefined),
onAppearanceChange: () => {},
});
export function useAppearance() {
return useContext(AppearanceContext);
}
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
const [delta, setDelta] = useState<AppearanceSettings | undefined>(undefined);
const appliedRef = useRef<string>("");
// 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<string, unknown> }) => {
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 <AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>;
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
/**
* Thin settings primitives (thin settings wave): the minimal control kit
* the settings page needs — deliberately plain. No design system, no
* animation polish: this wave is a WORKING validation surface over the
* settings record, not the designed settings app (that's a later wave).
*
* Everything SSRs clean (render tests pin the sections' markup).
*/
import { cn } from "@/lib/utils";
/** A section block: header + rows, separated by the app's border color. */
export function SettingsSection({
title,
hint,
children,
testid,
}: {
title: string;
/** One-line quiet note under the header (what the section controls). */
hint?: string;
children: React.ReactNode;
testid?: string;
}) {
return (
<section data-testid={testid} className="border-t border-border px-6 py-5 first:border-t-0">
<h2 className="text-xs uppercase tracking-widest text-muted-foreground/70">{title}</h2>
{hint && <p className="mt-1 text-xs text-muted-foreground/70">{hint}</p>}
<div className="mt-3 flex flex-col gap-3">{children}</div>
</section>
);
}
/** One label + one control. Label left (with optional muted hint), control
* right — the settings row shape. */
export function SettingsRow({
label,
hint,
children,
testid,
}: {
label: string;
hint?: string;
children: React.ReactNode;
testid?: string;
}) {
return (
<div data-testid={testid} className="flex items-center justify-between gap-4 py-1">
<span className="min-w-0">
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>}
</span>
{children}
</div>
);
}
/** The on/off switch — a plain button in the switch role. */
export function Toggle({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
/** Accessible name for the switch role. */
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
onClick={() => onChange(!checked)}
className={cn(
"relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors",
checked ? "bg-foreground" : "bg-muted",
disabled && "cursor-not-allowed opacity-40",
)}
>
<span
className={cn(
"absolute top-0.5 size-3 rounded-full transition-transform",
checked ? "translate-x-4.5 bg-background" : "translate-x-0.5 bg-muted-foreground",
)}
/>
</button>
);
}
/** A thin select — native element, options as given. */
export function ThinSelect({
value,
onChange,
children,
ariaLabel,
testid,
disabled,
}: {
value: string;
onChange: (next: string) => void;
children: React.ReactNode;
ariaLabel: string;
testid?: string;
disabled?: boolean;
}) {
return (
<select
value={value}
aria-label={ariaLabel}
data-testid={testid}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={cn(
"h-7 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground outline-none",
disabled && "cursor-not-allowed opacity-40",
)}
>
{children}
</select>
);
}
/** A thin hex/text input committing on blur + Enter (settings pace, not
* typing pace — every commit is a section PUT). */
export function CommitInput({
value,
placeholder,
onCommit,
ariaLabel,
testid,
type = "text",
}: {
/** "" = unset (the factory default). */
value: string;
placeholder?: string;
onCommit: (next: string) => void;
ariaLabel: string;
testid?: string;
type?: "text" | "number";
}) {
return (
<input
type={type}
defaultValue={value}
placeholder={placeholder}
aria-label={ariaLabel}
data-testid={testid}
onBlur={(e) => {
const next = e.target.value.trim();
if (next !== value) onCommit(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") {
e.currentTarget.value = value;
e.currentTarget.blur();
}
}}
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
/>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
/**
* The Keyboard section (thin settings wave): view / rebind / reset over
* core/keyboard.ts's registry. The overrides persist in localStorage
* (lh:keymap, overrides-only, merged over DEFAULT_BINDINGS), so rebinding
* is INSTANT — the dispatcher reads the map per keydown, no reload.
*
* Rebind flow: click Rebind → the row listens for the next keydown
* (capture phase, preventDefault — nothing types anywhere), eventBinding
* normalizes it, saveOverrides persists. Escape cancels. displayOnly
* actions (a surface owns those keys — the question dock's row machine)
* are listed for reading but never rebindable.
*
* Thin: no conflict detection — if a new binding collides with another
* action's, the first match in registry order wins; the cheat sheet shows
* both. Detection belongs to the designed wave.
*/
import { useEffect, useState } from "react";
import {
KEYBOARD_ACTIONS,
eventBinding,
formatBinding,
loadOverrides,
saveOverrides,
type KeyAction,
type KeyScope,
} from "@/src/core";
import { SettingsRow } from "./controls.tsx";
const SCOPES: { scope: KeyScope; label: string }[] = [
{ scope: "global", label: "Global" },
{ scope: "chat", label: "Chat" },
{ scope: "graph", label: "Graph" },
];
/** The overrides map after ONE change (pure — exported for tests):
* binding null = reset the action to its default (key removed). */
export function nextOverrides(
current: Partial<Record<KeyAction, string>>,
id: KeyAction,
binding: string | null,
): Partial<Record<KeyAction, string>> {
const next = { ...current };
if (binding === null) delete next[id];
else next[id] = binding;
return next;
}
export function KeyboardSection() {
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
const [rebinding, setRebinding] = useState<KeyAction | null>(null);
const [nonce, setNonce] = useState(0);
// localStorage is read post-mount (SSR-safe); `nonce` re-reads after writes.
useEffect(() => {
setOverrides(loadOverrides());
}, [nonce]);
// The capture listener — only while a rebind is armed.
useEffect(() => {
if (!rebinding) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setRebinding(null);
return;
}
// Modifier keydowns arrive one by one (⌘ down, then ⇧, then the
// key) — each is a keydown this capture listener sees. They are
// not bindings; keep listening for the real key.
if (e.key === "Control" || e.key === "Meta" || e.key === "Alt" || e.key === "Shift") return;
const binding = eventBinding(e);
if (!binding) return;
const next = nextOverrides(loadOverrides(), rebinding, binding);
saveOverrides(next);
setRebinding(null);
setNonce((n) => n + 1);
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [rebinding]);
function resetOne(id: KeyAction) {
saveOverrides(nextOverrides(loadOverrides(), id, null));
setNonce((n) => n + 1);
}
function resetAll() {
saveOverrides({});
setNonce((n) => n + 1);
}
const overridden = (id: KeyAction) => overrides[id] !== undefined;
return (
<>
{SCOPES.map(({ scope, label }) => {
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
if (!actions.length) return null;
return (
<div key={scope} data-testid={`kbd-scope-${scope}`}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{label}</p>
<div className="flex flex-col gap-2">
{actions.map((def) => {
const effective = overrides[def.id] ?? def.binding;
return (
<SettingsRow
key={def.id}
label={def.label}
hint={def.displayOnly ? "Owned by a surface — not rebindable" : undefined}
testid={`kbd-row-${def.id}`}
>
<span className="flex items-center gap-2">
<kbd className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-ui text-xs">
{rebinding === def.id ? "Press keys…" : formatBinding(effective)}
</kbd>
{def.displayOnly ? null : rebinding === def.id ? (
<button
type="button"
onClick={() => setRebinding(null)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
) : (
<>
<button
type="button"
onClick={() => setRebinding(def.id)}
data-testid={`kbd-rebind-${def.id}`}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Rebind
</button>
{overridden(def.id) && (
<button
type="button"
onClick={() => resetOne(def.id)}
data-testid={`kbd-reset-${def.id}`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Reset
</button>
)}
</>
)}
</span>
</SettingsRow>
);
})}
</div>
</div>
);
})}
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">Stored in this browser (lh:keymap) — overrides only.</span>
<button
type="button"
onClick={resetAll}
data-testid="kbd-reset-all"
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Reset all to defaults
</button>
</div>
</>
);
}
+135
View File
@@ -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<UserSettings | null | undefined>(undefined);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
{/* The app header — same 60px band, same border, same CMU title
(the alignment constant from header round 6). */}
<header className="flex h-15 items-center gap-3 border-b border-border px-3">
<SidebarToggle />
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">Settings</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-2xl pb-16">
{error && (
<p role="alert" className="m-3 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500">
{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>
) : (
<>
<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>
<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>
<SettingsSection title="Appearance" hint="Applied live as CSS variables" testid="section-appearance">
<AppearanceSection
appearance={record.appearance}
onSectionChange={(next: AppearanceSettings) => void write("appearance", next)}
/>
</SettingsSection>
<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>
<SettingsSection title="Keyboard" hint="Rebinds apply instantly — stored in this browser" testid="section-keyboard">
<KeyboardSection />
</SettingsSection>
</>
)}
</div>
</div>
</div>
</div>
);
}
+15 -1
View File
@@ -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). */}
<ActivitySection />
{/* 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). */}
<div className="px-3 pb-3 pt-1">
<Link
href="/settings"
data-testid="settings-entry"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<SettingsIcon className="size-3" />
Settings
</Link>
</div>
</div>
</aside>
);
+17 -2
View File
@@ -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<Record<KeyAction, string>> {
export function loadOverrides(): Partial<Record<KeyAction, string>> {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
@@ -104,6 +106,19 @@ function loadOverrides(): Partial<Record<KeyAction, string>> {
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<Record<KeyAction, string>>): 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<string, KeyAction> {
const overrides = loadOverrides();
+150
View File
@@ -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<string, unknown>).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(<AgentSection agent={undefined} onSectionChange={() => {}} />);
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(<AgentSection agent={undefined} onSectionChange={() => {}} />);
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(<AgentSection agent={undefined} onSectionChange={() => {}} />);
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(
<AgentSection
agent={{
models: { researcher: { model: { provider: "openrouter", id: "z-ai/glm-4.7-flash" } } },
tools: { demo: false },
}}
onSectionChange={() => {}}
/>,
);
// 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(<AgentSection agent={undefined} onSectionChange={() => {}} />);
assert.ok(html.includes("Loading prompt segments"));
});
});
describe("GraphSection — the dependency ladder", () => {
it("defaults: all four rungs on, none disabled", () => {
const html = renderToStaticMarkup(<GraphSection graph={undefined} onSectionChange={() => {}} />);
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(
<GraphSection graph={{ enabled: false, embedding: true, clustering: true, labeling: true }} onSectionChange={() => {}} />,
);
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(
<GraphSection graph={{ enabled: true, embedding: true, clustering: false }} onSectionChange={() => {}} />,
);
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(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
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(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
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(
<AppearanceSection appearance={{ background: "#1a1a2e" }} onSectionChange={() => {}} />,
);
assert.ok(html.includes(`value="#1a1a2e"`));
});
});
describe("KeyboardSection (thin SSR)", () => {
it("renders every registry action in its scope group", () => {
const html = renderToStaticMarkup(<KeyboardSection />);
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(<KeyboardSection />);
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"`));
});
});
+139
View File
@@ -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" });
});
});