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.
139 lines
6.2 KiB
TypeScript
139 lines
6.2 KiB
TypeScript
/**
|
|
* 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" });
|
|
});
|
|
}); |