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