Files
learnharness/components/settings/keyboard-section.tsx
T
amosblomqvist c32915b232 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.
2026-09-19 18:27:37 +03:00

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>
</>
);
}