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.
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
} |