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
+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" });
});
});