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:
+5
-1
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"`));
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user