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
+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,
formatBinding,
kbdEventForAction,
KEYBOARD_ACTIONS,
} from "../src/core/keyboard.ts";
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(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 assert from "node:assert/strict";
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";
// 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 />);
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-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"));
});
});