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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user