ui: plan canvas ink extent + the keyboard-first round

Plan canvas (owner report: arrows clipped at the viewBox edge in dist —
measured 14px/20px cuts in the owner's real plans): blockLayout now
unions node boxes with every edge path's control-point hull (a bezier
never leaves it; the layout calls edgePathD with the renderer's exact
inputs). Dagre's node extent stays the floor, so ordinary plans render
byte-identical; the viewBox origin shifts only when ink bulges past
zero. Verified on both real chats; pins use the owner's real
linear-algebra tree (golden-real-data).

Keyboard-first (owner's law: Enter always submits, arrows always cycle;
text fields the only exception):
- Enter guard split (enter-submits): a focused BUTTON keeps native
  Enter only when keyboard-walked (:focus-visible) or inside an open
  overlay (menus/dialogs); pointer-clicked transcript buttons submit,
  preventDefault suppressing the re-activation underneath.
- Focus hygiene (focus-hygiene): pointer clicks on transient controls
  (agent/user thinking toggles, copy, mermaid expand, plan chip) blur
  to body — the state clicking empty space produces.
- Session search owns the walk: Up/Down wrap-cycle the filtered rows,
  Enter opens the walked row or the first match (quick-open), then
  blurs and keeps the query. The Alt-arrow session cycle is RETIRED.
- Registry: model-menu walk/pick entries removed (obvious in-field
  navigation, not settings material); question-dock keys stay
  displayOnly per owner ruling.

Gates: 1489/1489, typecheck, lint. Verified live end-to-end.
This commit is contained in:
amosblomqvist
2026-10-10 09:43:12 +03:00
parent 643e7596f0
commit 26bc305d66
14 changed files with 403 additions and 72 deletions
+6
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -159,6 +160,11 @@ export const ReasoningTrigger = memo(
className className
)} )}
{...props} {...props}
/* FOCUS HYGIENE (owner, keyboard round 2026-10-10): a pointer click
on the thinking toggle must not leave focus here — the learner's
next Enter submits, it must not re-toggle. Radix composes this
with its own toggle handler. */
onClick={(e) => blurAfterPointerClick(e)}
> >
{children ?? ( {children ?? (
<> <>
+5 -1
View File
@@ -24,6 +24,7 @@ import { artifactRenderers } from "@/components/artifacts";
import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { useEnterSubmits } from "@/components/enter-submits"; import { useEnterSubmits } from "@/components/enter-submits";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { dockStateKey } from "@/components/dock-state-key"; import { dockStateKey } from "@/components/dock-state-key";
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
@@ -1264,7 +1265,10 @@ export function Chat({
<button <button
type="button" type="button"
data-testid="plan-chip" data-testid="plan-chip"
onClick={() => setPlanOpen(true)} onClick={(e) => {
blurAfterPointerClick(e);
setPlanOpen(true);
}}
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground" className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
> >
<WaypointsIcon className="size-3.5 text-primary" aria-hidden /> <WaypointsIcon className="size-3.5 text-primary" aria-hidden />
+37 -3
View File
@@ -13,10 +13,23 @@
* What the window listener refuses to touch: * What the window listener refuses to touch:
* - typing surfaces (input/textarea/select/contenteditable) — the focused * - typing surfaces (input/textarea/select/contenteditable) — the focused
* surface owns its Enter (the dock field, the composer, settings); * surface owns its Enter (the dock field, the composer, settings);
* - BUTTONS — Enter on a focused button is its click (keyboard button
* semantics: the collapsed Problem bar must expand, not submit);
* - modified Enter (⇧↵ is a newline, ⌘/Ctrl↵ belongs to the surface). * - modified Enter (⇧↵ is a newline, ⌘/Ctrl↵ belongs to the surface).
* *
* BUTTONS — the pointer/keyboard split (owner, keyboard round 2026-10-10):
* a mouse click on a transcript control (a thinking toggle, a copy button)
* leaves focus ON that button; the learner's next Enter must SUBMIT, not
* re-activate the toggle — the report that born this rule. But two button
* populations keep native Enter-activates:
* 1. KEYBOARD-focused buttons — :focus-visible means focus arrived by
* keyboard (the arrow-walked menus; Tab is dead app-wide, so this
* only happens inside overlays);
* 2. buttons inside an OPEN OVERLAY — menus walk focus with arrows and
* Enter activates the walked button natively (menu-shell's contract),
* dialogs are their own surfaced layer (the ONE-ESC LAW's layers).
* The pointer-clicked transcript button fails both tests → Enter is ours
* (preventDefault also suppresses the browser's default activation, so
* the toggle does not fire underneath the submit).
*
* The submit callbacks self-guard (empty thinking / canSubmit) — this hook * The submit callbacks self-guard (empty thinking / canSubmit) — this hook
* never checks answer validity, only focus honesty. * never checks answer validity, only focus honesty.
*/ */
@@ -24,6 +37,26 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { isTypingSurface } from "@/src/core"; import { isTypingSurface } from "@/src/core";
/** Overlay roots whose buttons keep native Enter (hand-rolled menus mark
* themselves data-lh-menu; Radix poppers/dialogs carry their own attrs). */
const OVERLAY_ROOTS = '[data-lh-menu], [data-radix-popper-content-wrapper], [role="menu"], [role="dialog"]';
/** Pure decision, exported for pins: does a focused BUTTON keep native
* Enter (true) or does Enter belong to submit (false)? */
export function buttonKeepsNativeEnter(el: HTMLElement): boolean {
// Keyboard-walked focus: the menu arrows landed here — Enter activates.
// (Guarded: a selector engine without the pseudo — linkedom in the
// test harness — degrades to "not keyboard focus", never throws on
// every Enter.)
try {
if (el.matches(":focus-visible")) return true;
} catch {
// unsupported pseudo — treat as pointer focus
}
// Inside an open overlay: the overlay owns its keys.
return !!el.closest(OVERLAY_ROOTS);
}
/** Mount-scoped: while the dock is mounted (a problem/question is active), /** Mount-scoped: while the dock is mounted (a problem/question is active),
* plain Enter anywhere outside a typing surface or button submits it. */ * plain Enter anywhere outside a typing surface or button submits it. */
export function useEnterSubmits(submit: () => void): void { export function useEnterSubmits(submit: () => void): void {
@@ -34,7 +67,8 @@ export function useEnterSubmits(submit: () => void): void {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (isTypingSurface(e)) return; if (isTypingSurface(e)) return;
if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return; const target = e.target as HTMLElement | null;
if (target?.tagName === "BUTTON" && buttonKeepsNativeEnter(target)) return;
e.preventDefault(); e.preventDefault();
submitRef.current(); submitRef.current();
}; };
+32
View File
@@ -0,0 +1,32 @@
"use client";
/**
* FOCUS HYGIENE — transient transcript controls don't keep focus
* (owner, keyboard round 2026-10-10). A pointer click on a thinking
* toggle, a copy button, a plan chip used to LEAVE focus on that
* button; the learner's next Enter then re-activated the toggle
* instead of submitting (see enter-submits.ts for the other half of
* the fix). Here we restore, after pointer activation, exactly the
* state clicking empty transcript space produces: focus falls back to
* the body — Enter submits, arrows cycle the question rows, nothing
* re-toggles. Deliberately NOT a focus-into-a-text-field: a focused
* field would own the arrows as caret moves, against the arrows-always
* law (owner, 2026-10-10).
*
* Keyboard-activated clicks are untouched: e.detail === 0 marks the
* synthesized click of Enter/Space on a keyboard-focused button — the
* keyboard user keeps their focus.
*/
/** Attach as (or compose into) a transient control's onClick: blurs the
* control after POINTER clicks (keyboard clicks keep focus). Node-safe
* duck-typing — no HTMLElement global needed (the test harness runs
* bare node with linkedom). */
export function blurAfterPointerClick(e: {
detail: number;
currentTarget: EventTarget | null;
}): void {
if (e.detail === 0) return;
const el = e.currentTarget as { blur?: unknown } | null;
if (el && typeof el.blur === "function") (el.blur as () => void).call(el);
}
+7 -2
View File
@@ -39,6 +39,7 @@ import {
import type { ExtraProps } from "streamdown"; import type { ExtraProps } from "streamdown";
import { MERMAID_FONT_SIZE, renderMermaid } from "@/components/mermaid-render"; import { MERMAID_FONT_SIZE, renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error"; import { MermaidError } from "@/components/mermaid-error";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { chartSize, useSessionMermaid } from "@/components/session-overlay"; import { chartSize, useSessionMermaid } from "@/components/session-overlay";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
@@ -101,7 +102,8 @@ export function CopyCorner({
aria-label={label} aria-label={label}
data-testid="md-copy" data-testid="md-copy"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/md:opacity-100" className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/md:opacity-100"
onClick={() => { onClick={(e) => {
blurAfterPointerClick(e);
void copyText(getText()).then((ok) => { void copyText(getText()).then((ok) => {
if (!ok) return; if (!ok) return;
setCopied(true); setCopied(true);
@@ -210,7 +212,10 @@ function MermaidInline({ chart }: { chart: string }) {
aria-label="Expand diagram" aria-label="Expand diagram"
data-testid="md-mermaid-expand" data-testid="md-mermaid-expand"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100" className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
onClick={() => overlay.openMermaid(chart)} onClick={(e) => {
blurAfterPointerClick(e);
overlay.openMermaid(chart);
}}
> >
<MaximizeIcon className="size-3.5" /> <MaximizeIcon className="size-3.5" />
</button> </button>
+63 -3
View File
@@ -189,14 +189,69 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{
const pts: Array<{ x: number; y: number }> | undefined = g.edge(e.from, e.to)?.points; const pts: Array<{ x: number; y: number }> | undefined = g.edge(e.from, e.to)?.points;
if (pts) edgePts.set(`${e.from}->${e.to}`, pts.map((pt) => ({ x: pt.x, y: pt.y }))); if (pts) edgePts.set(`${e.from}->${e.to}`, pts.map((pt) => ({ x: pt.x, y: pt.y })));
} }
// THE CANVAS MUST COVER THE ARROWS (owner, 2026-10-10): dagre's
// graph extent is the NODE bounding box — the edge curves drawn on
// top can bow past it (cross-column edges' bezier control points;
// measured 14–20px cut on the right in the owner's plans) and the
// viewBox clipped them. The ink extent unions the node boxes with
// every edge path's control-point hull: a bezier never leaves the
// hull of its control points, and parsing the d-string edgePathD
// builds (the SAME call the renderer makes, same inputs) bounds every
// segment — M, L and C pairs alternate strictly, so even/odd matches
// are x/y. Dagre's extent stays the FLOOR: plans whose arrows stay
// inside keep byte-identical viewBoxes (no reflow of every pinned
// render), and the origin only moves when ink bulges past zero.
let inkMinX = Infinity;
let inkMinY = Infinity;
let inkMaxX = -Infinity;
let inkMaxY = -Infinity;
const stretch = (px: number, py: number) => {
if (px < inkMinX) inkMinX = px;
if (py < inkMinY) inkMinY = py;
if (px > inkMaxX) inkMaxX = px;
if (py > inkMaxY) inkMaxY = py;
};
for (const n of nodes) {
const nx = x.get(n.id);
const ny = y.get(n.id);
if (nx === undefined || ny === undefined) continue;
stretch(nx, ny);
stretch(nx + (nodeW.get(n.id) ?? NODE_W_MIN), ny + (nodeH.get(n.id) ?? NODE_H));
}
for (const e of routed) {
const pts = edgePts.get(`${e.from}->${e.to}`);
if (!pts) continue;
const tx = x.get(e.to);
const ty = y.get(e.to);
const target =
tx !== undefined && ty !== undefined
? {
x: tx,
y: ty,
w: nodeW.get(e.to) ?? NODE_W_MIN,
h: nodeH.get(e.to) ?? NODE_H,
}
: undefined;
const nums = edgePathD(pts, target).match(/-?\d+(?:\.\d+)?/g);
if (!nums) continue;
for (let i = 0; i + 1 < nums.length; i += 2) {
stretch(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
}
}
// Dagre coordinates start at 0; only ink past zero on the negative
// side moves the origin (viewBox x/y shift with it).
const originX = Math.min(0, inkMinX === Infinity ? 0 : inkMinX);
const originY = Math.min(0, inkMinY === Infinity ? 0 : inkMinY);
return { return {
x, x,
y, y,
nodeH, nodeH,
nodeW, nodeW,
edgePts, edgePts,
width: Math.max(g.graph().width ?? 0, 1), originX,
height: Math.max(g.graph().height ?? 0, 1), originY,
width: Math.max((g.graph().width ?? 0) - originX, inkMaxX === -Infinity ? 0 : inkMaxX - originX, 1),
height: Math.max((g.graph().height ?? 0) - originY, inkMaxY === -Infinity ? 0 : inkMaxY - originY, 1),
}; };
} }
@@ -462,9 +517,14 @@ export function PlanDiagram({
const h = Math.max(layout.height, 1); const h = Math.max(layout.height, 1);
const vbW = w + 2 * pad; const vbW = w + 2 * pad;
const vbH = h + 2 * pad; const vbH = h + 2 * pad;
// The origin rides the ink extent (owner, 2026-10-10): an arrow
// bulging past zero shifts the viewBox with it instead of clipping.
// Zero-origin plans (the ordinary case) render exactly as before.
const ox = viewBoxOverride ? 0 : (layout.originX ?? 0);
const oy = viewBoxOverride ? 0 : (layout.originY ?? 0);
return ( return (
<svg <svg
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`} viewBox={viewBoxOverride ?? `${ox - pad} ${oy - pad} ${vbW} ${vbH}`}
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`} className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
style={{ style={{
// The READING font (owner, 2026-09-26) — via the runtime var, NOT // The READING font (owner, 2026-09-26) — via the runtime var, NOT
+48 -48
View File
@@ -496,14 +496,16 @@ function SessionsSidebar() {
[], [],
); );
/* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns /* ── Keyboard seams (shortcut wave A, 2026-09-17; search-walk round
every session-list interaction, so the shortcuts land here: ⌘⇧F 2026-10-10) ── ⌘⇧F focuses the search field — and THE SEARCH OWNS THE
focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar WALK: ↑/↓ cycle the filtered list (wrap — "cycle"), Enter opens the
if it's closed — the keys work from anywhere, like the toggle does); walked row, or the FIRST match when nothing is walked (owner,
Alt+Enter opens the selected session. The selection is a row index 2026-10-10 — the quick-open pattern: ⌘⇧F, type, Enter, done). The
into `shown`, so a typed query narrows the cycle to the matches. Alt+↑/↓/↵ global cycle was RETIRED the same round: barely used,
Mouse and keyboard coexist: hovering doesn't claim, a click or an felt laggy. The selection is a row index into `shown`, so the walk
open clears the selection. */ follows whatever the query + kind filter leave. Mouse and keyboard
coexist: hovering doesn't claim, a click or an open clears the
selection. */
const [kbdIndex, setKbdIndex] = useState<number | null>(null); const [kbdIndex, setKbdIndex] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
@@ -601,42 +603,6 @@ function SessionsSidebar() {
return () => window.removeEventListener("lh:kbd-sessions-search", onFocusSearch); return () => window.removeEventListener("lh:kbd-sessions-search", onFocusSearch);
}, [open, toggle]); }, [open, toggle]);
useEffect(() => {
const cycle = (dir: 1 | -1) => {
setKbdIndex((i) => {
const n = shown?.length ?? 0;
if (n === 0) return null;
if (i === null) return dir === 1 ? 0 : n - 1;
return (i + dir + n) % n;
});
};
const onPrev = () => {
if (!open) toggle();
cycle(-1);
};
const onNext = () => {
if (!open) toggle();
cycle(1);
};
const onOpenSelected = () => {
const target = kbdIndex !== null ? shown?.[kbdIndex] : undefined;
if (target) {
router.push(`/chat/${target.id}`);
setKbdIndex(null);
}
};
window.addEventListener("lh:kbd-cycle-session-prev", onPrev);
window.addEventListener("lh:kbd-cycle-session-next", onNext);
window.addEventListener("lh:kbd-open-session", onOpenSelected);
return () => {
window.removeEventListener("lh:kbd-cycle-session-prev", onPrev);
window.removeEventListener("lh:kbd-cycle-session-next", onNext);
window.removeEventListener("lh:kbd-open-session", onOpenSelected);
};
// kbdIndex is read (not upserted) so Alt+Enter pushes OUTSIDE any
// state updater — a router.push inside one is setState-in-render.
}, [open, toggle, shown, router, kbdIndex]);
// Keep the kbd-selected row in view (the rows are the container's only // Keep the kbd-selected row in view (the rows are the container's only
// buttons, so the index matches). // buttons, so the index matches).
useEffect(() => { useEffect(() => {
@@ -733,14 +699,48 @@ function SessionsSidebar() {
ref={searchRef} ref={searchRef}
data-testid="session-search" data-testid="session-search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
// Esc leaves the search (owner, keyboard wave): blur back setQuery(e.target.value);
// to normal, and claim the key so the window Esc chain // The list reorders under the query every keystroke —
// (dictation / abort) doesn't also fire. // a stale walk index would point at the wrong row.
setKbdIndex(null);
}}
// THE SEARCH OWNS THE WALK (owner, 2026-10-10 — the
// retired Alt+↑/↓/↵ cycle's successor): ↑/↓ cycle the
// filtered rows (wrap), Enter opens the walked row —
// or the FIRST match when nothing is walked (the
// quick-open pattern) — then blurs (focus returns to
// the app per the Enter law; the query stays for
// re-searching). Esc leaves the search (owner,
// keyboard wave): blur back to normal, and claim the
// key so the window Esc chain (dictation / abort)
// doesn't also fire.
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
e.currentTarget.blur(); e.currentTarget.blur();
return;
}
// Modified keys belong to the surface (⇧↵ etc.).
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
const n = shown?.length ?? 0;
if (n === 0) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
// The rows move, not the caret.
e.preventDefault();
const dir = e.key === "ArrowDown" ? 1 : -1;
setKbdIndex((i) =>
i === null || i >= n ? (dir === 1 ? 0 : n - 1) : (i + dir + n) % n,
);
return;
}
if (e.key === "Enter") {
const target = shown![kbdIndex !== null && kbdIndex < n ? kbdIndex : 0];
if (!target) return;
e.preventDefault();
router.push(`/chat/${target.id}`);
setKbdIndex(null);
e.currentTarget.blur();
} }
}} }}
placeholder="Search…" placeholder="Search…"
+5 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual /** The unified user-thinking row (owner round, 2026-10-05): ONE visual
@@ -115,7 +116,10 @@ export function ThinkingBlock({
<button <button
type="button" type="button"
data-testid={testId ? `${testId}-trigger` : undefined} data-testid={testId ? `${testId}-trigger` : undefined}
onClick={() => setOpen((v) => !v)} onClick={(e) => {
setOpen((v) => !v);
blurAfterPointerClick(e);
}}
aria-expanded={open} aria-expanded={open}
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground" className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
> >
+7 -2
View File
@@ -47,8 +47,10 @@ second pillar of input).
| Cycle thinking level | Shift+Tab | `lh:kbd-cycle-thinking` (model-picker.tsx) | | Cycle thinking level | Shift+Tab | `lh:kbd-cycle-thinking` (model-picker.tsx) |
| Toggle sidebar | ⌘B | `lh:toggle-sidebar` (sidebar.tsx) | | Toggle sidebar | ⌘B | `lh:toggle-sidebar` (sidebar.tsx) |
| Sessions search | ⌘⇧F | `lh:kbd-sessions-search` (sidebar.tsx) | | Sessions search | ⌘⇧F | `lh:kbd-sessions-search` (sidebar.tsx) |
| Cycle session | ⌥↑ / ⌥↓ | `lh:kbd-cycle-session-prev/next` (sidebar.tsx) | | Walk search results | ↑ / ↓ *in the search field* | sidebar.tsx (cycles the filtered rows, wraps) |
| Open selected session | ⌥Enter | `lh:kbd-open-session` (sidebar.tsx) | | Open search result | Enter *in the search field* | sidebar.tsx (walked row, else the first match) |
| ~~Cycle session~~ | ~~⌥↑ / ⌥↓~~ | RETIRED (owner, 2026-10-10) — the search walk above replaces it |
| ~~Open selected session~~ | ~~⌥Enter~~ | RETIRED (owner, 2026-10-10) — the search Enter above replaces it |
| Jump to graph (home) | ⌘G | router.push in keyboard-shortcuts.tsx | | Jump to graph (home) | ⌘G | router.push in keyboard-shortcuts.tsx |
| New session | ⌘N | router.push in keyboard-shortcuts.tsx | | New session | ⌘N | router.push in keyboard-shortcuts.tsx |
| Rename session | F2 | `lh:kbd-rename-session` (chat.tsx) | | Rename session | F2 | `lh:kbd-rename-session` (chat.tsx) |
@@ -57,6 +59,9 @@ second pillar of input).
## Model menu keyboard machine (owner polish, 2026-09-17) ## Model menu keyboard machine (owner polish, 2026-09-17)
(Removed from the settings/cheat-sheet registry 2026-10-10 — obvious
in-field navigation; the behavior below is unchanged.)
While the model menu is open, focus stays in the search field and the While the model menu is open, focus stays in the search field and the
menu owns ↑/↓/Enter inside the input's own handler: menu owns ↑/↓/Enter inside the input's own handler:
+8 -10
View File
@@ -25,16 +25,11 @@ export type KeyAction =
| "cycle-thinking" | "cycle-thinking"
| "toggle-sidebar" | "toggle-sidebar"
| "sessions-search" | "sessions-search"
| "cycle-session-prev"
| "cycle-session-next"
| "open-session"
| "jump-graph" | "jump-graph"
| "new-session" | "new-session"
| "rename-session" | "rename-session"
| "cheat-sheet" | "cheat-sheet"
| "find" | "find"
| "model-menu-walk"
| "model-menu-pick"
| "question-focus" | "question-focus"
| "question-next-row" | "question-next-row"
| "question-prev-row" | "question-prev-row"
@@ -66,9 +61,10 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
{ id: "cycle-thinking", label: "Cycle thinking level", scope: "global", binding: "shift+tab" }, { id: "cycle-thinking", label: "Cycle thinking level", scope: "global", binding: "shift+tab" },
{ id: "toggle-sidebar", label: "Toggle sessions sidebar", scope: "global", binding: "mod+b" }, { id: "toggle-sidebar", label: "Toggle sessions sidebar", scope: "global", binding: "mod+b" },
{ id: "sessions-search", label: "Search sessions", scope: "global", binding: "mod+shift+f" }, { id: "sessions-search", label: "Search sessions", scope: "global", binding: "mod+shift+f" },
{ id: "cycle-session-prev", label: "Previous session", scope: "global", binding: "alt+arrowup" }, // (The Alt+↑/↓/↵ session cycle was RETIRED, owner 2026-10-10: barely
{ id: "cycle-session-next", label: "Next session", scope: "global", binding: "alt+arrowdown" }, // used, felt laggy. Session navigation lives IN the search now — ↑/↓
{ id: "open-session", label: "Open selected session", scope: "global", binding: "alt+enter" }, // walk the filtered list, Enter opens (first match when nothing
// walked) — see sidebar.tsx's search input.)
{ id: "jump-graph", label: "Graph view", scope: "global", binding: "mod+g" }, { id: "jump-graph", label: "Graph view", scope: "global", binding: "mod+g" },
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" }, { id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" }, { id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
@@ -82,8 +78,10 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
// the keys while a question is expanded; the registry never dispatches // the keys while a question is expanded; the registry never dispatches
// them, so bare arrows stay free everywhere else. // them, so bare arrows stay free everywhere else.
{ id: "question-focus", label: "Question · focus thinking ↔ rows", scope: "chat", binding: "tab", displayOnly: true }, { id: "question-focus", label: "Question · focus thinking ↔ rows", scope: "chat", binding: "tab", displayOnly: true },
{ id: "model-menu-walk", label: "Model menu · walk results", scope: "global", binding: "arrowup", displayOnly: true }, // (The model-menu entries — "walk results" ↑/↓, "pick result" ↵ — were
{ id: "model-menu-pick", label: "Model menu · pick result", scope: "global", binding: "enter", displayOnly: true }, // REMOVED, owner 2026-10-10: obvious in-field menu navigation, never
// dispatched, never rebindable — not settings material. The menu's own
// arrow machine (menu-shell.tsx) is unchanged.)
{ id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true }, { id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true },
{ id: "question-prev-row", label: "Question · previous row", scope: "chat", binding: "arrowup", displayOnly: true }, { id: "question-prev-row", label: "Question · previous row", scope: "chat", binding: "arrowup", displayOnly: true },
{ id: "question-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true }, { id: "question-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true },
+82
View File
@@ -0,0 +1,82 @@
/**
* enter-submits + focus-hygiene — the pointer/keyboard Enter split and the
* transient-control blur (owner, keyboard round 2026-10-10).
*
* The regression this pins: clicking a transcript control (a thinking
* toggle) left focus on its <button>; the learner's next Enter re-activated
* the toggle instead of submitting. The law now: Enter from a
* POINTER-focused transcript button submits (the window listener also
* preventDefaults, suppressing the default activation underneath);
* keyboard-focused buttons and buttons inside open overlays (menus walk
* focus with arrows, dialogs are their own layer) keep native Enter.
*/
import assert from "node:assert/strict";
import { parseHTML } from "linkedom";
import { describe, it } from "node:test";
import { buttonKeepsNativeEnter } from "../components/enter-submits.ts";
import { blurAfterPointerClick } from "../components/focus-hygiene.ts";
const { document } = parseHTML("<html><body></body></html>");
function buttonIn(attrs: Record<string, string>): HTMLElement {
const host = document.createElement("div");
for (const [k, v] of Object.entries(attrs)) host.setAttribute(k, v);
const b = document.createElement("button");
b.type = "button";
host.appendChild(b);
document.body.appendChild(host);
return b;
}
describe("buttonKeepsNativeEnter (the pointer/keyboard Enter split)", () => {
it("a transcript button (pointer-focused) does NOT keep native Enter — Enter submits", () => {
const b = buttonIn({});
assert.equal(buttonKeepsNativeEnter(b), false);
});
it("a keyboard-focused button keeps native Enter (:focus-visible)", () => {
const b = buttonIn({});
// linkedom lacks the pseudo — stub the one call the helper makes.
(b as unknown as { matches: (sel: string) => boolean }).matches = (sel: string) =>
sel === ":focus-visible";
assert.equal(buttonKeepsNativeEnter(b), true);
});
it("buttons inside overlays keep native Enter: hand-rolled menu, Radix popper, dialog, menu role", () => {
assert.equal(buttonKeepsNativeEnter(buttonIn({ "data-lh-menu": "" })), true);
assert.equal(
buttonKeepsNativeEnter(buttonIn({ "data-radix-popper-content-wrapper": "" })),
true,
);
assert.equal(buttonKeepsNativeEnter(buttonIn({ role: "dialog" })), true);
assert.equal(buttonKeepsNativeEnter(buttonIn({ role: "menu" })), true);
});
it("a selector engine without :focus-visible degrades to pointer-focus, never throws", () => {
const b = buttonIn({});
assert.doesNotThrow(() => buttonKeepsNativeEnter(b));
assert.equal(buttonKeepsNativeEnter(b), false);
});
});
describe("blurAfterPointerClick (focus hygiene)", () => {
it("a POINTER click (detail > 0) blurs the transient control", () => {
const b = buttonIn({});
let blurred = false;
b.blur = () => {
blurred = true;
};
blurAfterPointerClick({ detail: 1, currentTarget: b });
assert.equal(blurred, true);
});
it("a KEYBOARD-activated click (detail 0) keeps focus", () => {
const b = buttonIn({});
let blurred = false;
b.blur = () => {
blurred = true;
};
blurAfterPointerClick({ detail: 0, currentTarget: b });
assert.equal(blurred, false);
});
});
+18
View File
@@ -5,6 +5,7 @@ import {
bindingForAction, bindingForAction,
formatBinding, formatBinding,
kbdEventForAction, kbdEventForAction,
KEYBOARD_ACTIONS,
} from "../src/core/keyboard.ts"; } from "../src/core/keyboard.ts";
import { kbdEventForAction as kbdEventForActionFromUi } from "../components/keyboard-shortcuts.tsx"; import { kbdEventForAction as kbdEventForActionFromUi } from "../components/keyboard-shortcuts.tsx";
@@ -54,4 +55,21 @@ describe("the thinking pill suffix — ⇧⇥ via the registry", () => {
assert.equal(actionForBinding("shift+tab"), "cycle-thinking"); assert.equal(actionForBinding("shift+tab"), "cycle-thinking");
assert.equal(formatBinding("shift+tab"), "⇧⇥"); assert.equal(formatBinding("shift+tab"), "⇧⇥");
}); });
});
describe("the Alt session cycle is RETIRED (owner, 2026-10-10)", () => {
// Barely used, felt laggy — session navigation lives IN the search
// now (↑/↓ walk the filtered list, Enter opens; sidebar.tsx).
it("⌥↑ / ⌥↓ / ⌥↵ bind to NOTHING — no stray action fires", () => {
assert.equal(actionForBinding("alt+arrowup") ?? null, null);
assert.equal(actionForBinding("alt+arrowdown") ?? null, null);
assert.equal(actionForBinding("alt+enter") ?? null, null);
});
it("the actions are gone from the table (cheat sheet + settings render the registry)", () => {
const ids = KEYBOARD_ACTIONS.map((a) => a.id as string);
assert.ok(!ids.includes("cycle-session-prev"));
assert.ok(!ids.includes("cycle-session-next"));
assert.ok(!ids.includes("open-session"));
});
}); });
+80 -1
View File
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { PlanMarginalia, planExists } from "../components/plan-window.tsx"; import { PlanMarginalia, planExists } from "../components/plan-window.tsx";
import { blockLayout, type PopupMessage } from "../components/plan-block.tsx"; import { blockLayout, edgePathD, type LayoutNode, type PopupMessage } from "../components/plan-block.tsx";
import type { PlanTree } from "../src/core/contracts.ts"; import type { PlanTree } from "../src/core/contracts.ts";
// The plan marginalia: the bare diagram in the dock band's right flank — // The plan marginalia: the bare diagram in the dock band's right flank —
@@ -88,3 +88,82 @@ describe("PlanMarginalia render", () => {
); );
}); });
}); });
// ─── The canvas must cover the arrows (owner, 2026-10-10) ────────────────────
// The owner's REAL linear-algebra plan (chat_mv19m6xe, plan_init's mermaid
// parsed to nodes/edges — golden fixtures use real data, owner ruling
// 2026-10-10): a cross-column edge's bezier control points reached x=776
// while dagre's node-extent width was 754 — 14px of arrow clipped at the
// viewBox edge (the Hume plan cut 20px the same way).
const LINEAR_ALGEBRA_NODES: LayoutNode[] = [
{ id: "vector-space-axioms", title: "What a vector space is" },
{ id: "span-basis-dimension", title: "Span, basis, dimension" },
{ id: "matrix-as-map", title: "Matrices as maps" },
{ id: "elimination-rank", title: "Elimination exposes rank" },
{ id: "determinant-volume", title: "Det as volume scaling" },
{ id: "orthogonality-complements", title: "Orthogonality and complements" },
{ id: "projection-theorem", title: "The projection theorem" },
{ id: "transpose-adjoint", title: "The transpose as bridge" },
{ id: "normal-equations", title: "Least squares, normal equations" },
{ id: "eigen-diagonalization", title: "Eigenvectors as coordinates" },
{ id: "spectral-theorem", title: "The spectral theorem" },
{ id: "pca-synthesis", title: "Goal: PCA and least-squares derivations" }
];
const LINEAR_ALGEBRA_EDGES: ReadonlyArray<{ from: string; to: string }> = [
{ from: "span-basis-dimension", to: "matrix-as-map" },
{ from: "matrix-as-map", to: "elimination-rank" },
{ from: "matrix-as-map", to: "determinant-volume" },
{ from: "matrix-as-map", to: "orthogonality-complements" },
{ from: "span-basis-dimension", to: "orthogonality-complements" },
{ from: "orthogonality-complements", to: "projection-theorem" },
{ from: "orthogonality-complements", to: "transpose-adjoint" },
{ from: "matrix-as-map", to: "transpose-adjoint" },
{ from: "projection-theorem", to: "normal-equations" },
{ from: "transpose-adjoint", to: "normal-equations" },
{ from: "matrix-as-map", to: "normal-equations" },
{ from: "determinant-volume", to: "eigen-diagonalization" },
{ from: "matrix-as-map", to: "eigen-diagonalization" },
{ from: "span-basis-dimension", to: "eigen-diagonalization" },
{ from: "eigen-diagonalization", to: "spectral-theorem" },
{ from: "transpose-adjoint", to: "spectral-theorem" },
{ from: "orthogonality-complements", to: "spectral-theorem" },
{ from: "spectral-theorem", to: "pca-synthesis" },
{ from: "normal-equations", to: "pca-synthesis" }
];
describe("blockLayout ink extent", () => {
it("covers every edge path's control points — arrows never clip (owner, 2026-10-10)", () => {
const L = blockLayout(LINEAR_ALGEBRA_NODES, LINEAR_ALGEBRA_EDGES);
// The viewBox extent must reach the furthest control point of
// every drawn edge (a bezier never leaves its control-point hull).
let ctrlMaxX = -Infinity;
for (const e of LINEAR_ALGEBRA_EDGES) {
const pts = L.edgePts.get(`${e.from}->${e.to}`);
if (!pts) continue;
const target = {
x: L.x.get(e.to)!,
y: L.y.get(e.to)!,
w: L.nodeW.get(e.to)!,
h: L.nodeH.get(e.to)!,
};
const nums = edgePathD(pts, target).match(/-?\d+(?:\.\d+)?/g) ?? [];
for (let i = 0; i + 1 < nums.length; i += 2)
ctrlMaxX = Math.max(ctrlMaxX, parseFloat(nums[i]!));
}
assert.ok(ctrlMaxX > -Infinity, "the fixture has edges");
assert.ok(
ctrlMaxX <= (L.originX ?? 0) + L.width + 0.5,
`control point ${ctrlMaxX} exceeds the canvas right edge ${(L.originX ?? 0) + L.width}`,
);
});
it("the real plan's overhang actually EXTENDS the canvas past the node extent", () => {
const L = blockLayout(LINEAR_ALGEBRA_NODES, LINEAR_ALGEBRA_EDGES);
let nodeMaxX = -Infinity;
for (const n of LINEAR_ALGEBRA_NODES)
nodeMaxX = Math.max(nodeMaxX, (L.x.get(n.id) ?? 0) + (L.nodeW.get(n.id) ?? 0));
// Pre-fix dagre width was 754 with ink at 776 — width must stay ≥
// ink, which here strictly exceeds the node extent.
assert.ok(L.width > nodeMaxX + 1, `layout width ${L.width} should exceed node extent ${nodeMaxX}`);
});
});
+5 -1
View File
@@ -274,6 +274,10 @@ describe("KeyboardSection (thin SSR)", () => {
const html = renderToStaticMarkup(<KeyboardSection />); const html = renderToStaticMarkup(<KeyboardSection />);
assert.ok(html.includes("Fixed — part of this screen")); assert.ok(html.includes("Fixed — part of this screen"));
assert.ok(!html.includes(`data-testid="kbd-rebind-question-next-row"`), "surface keys stay put"); 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"`)); // The model-menu entries are GONE entirely (owner, 2026-10-10):
// obvious in-field navigation, not settings material.
assert.ok(!html.includes("Model menu"), "model-menu rows removed");
assert.ok(!KEYBOARD_ACTIONS.some((a) => a.id as string === "model-menu-walk"));
assert.ok(!KEYBOARD_ACTIONS.some((a) => a.id as string === "model-menu-pick"));
}); });
}); });