From e6e5311e601dc41c83cbd8a662907ad7caeb5703 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 20:40:56 -0400 Subject: [PATCH 1/4] feat(app): arrow-key canvas panning on Graph and Flows Arrows pan the active canvas 5px per keydown (25px with Shift) via a new {type:'pan'} resolver action and panBy(dx,dy) on both view handles; OS key-repeat gives continuous scrolling. Dict, editable targets, and ctrl/meta/alt chords keep their native arrow behavior. --- CLAUDE.md | 2 +- docs/guides/commands.md | 3 +- docs/spec/help-overlay.md | 6 ++ docs/spec/keyboard-nav-shortcuts.md | 27 +++++++ src/app/App.tsx | 9 +++ src/app/components/ui/HelpModal.tsx | 1 + src/app/hooks/useKeyboardShortcuts.ts | 12 +++- src/app/logic/shortcuts.ts | 37 +++++++++- src/app/views/flow/FlowsView.tsx | 9 +++ src/app/views/graph/GraphView.tsx | 8 +++ src/flow-view/FlowDiagramSvg.tsx | 19 ++++- test/checks/test-keyboard-shortcuts.ts | 97 ++++++++++++++++++++++++++ test/checks/test-shortcuts.ts | 92 ++++++++++++++++++++++++ 13 files changed, 314 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e62ad0..3207f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,7 +152,7 @@ Paths are relative to `docs/design/`, `docs/spec/`, `docs/guides/`, and `skills/ | Dict navigation + polish | dict-navigation | dict-navigation, dict-polish | — | — | | DD browse lens: spotlight grid (entity + flow-node cards, hover/pin spotlight; solid FK predicate lines + dashed cross-domain data-flow lines; hover-reveal labels; off-screen chips; facing-edge anchoring; focus/isolate mode for scale) | dd-spotlight-grid | dd-spotlight-grid | — | — | | Graph viewer FAB UX | viewer-fab-ux | viewer-fab-ux | — | — | -| Keyboard navigation shortcuts (g/d/f view switch, l DG layout, b DD lens, `/` or `Cmd`/`Ctrl`+`k` search-focus; pure resolver + global hook; editable/modifier guards, `Cmd`/`Ctrl`+`k` resolved in the same pre-editable slot as the zoom chords) | keyboard-nav-shortcuts | keyboard-nav-shortcuts | commands | — | +| Keyboard navigation shortcuts (g/d/f view switch, l DG layout, b DD lens, `/` or `Cmd`/`Ctrl`+`k` search-focus, arrow-key canvas pan on graph/flow — 5px per keydown, 25px with Shift, viewport-movement `{type:'pan',dx,dy}` action routed to `panBy(dx,dy)` on both view handles, dict/editable/ctrl-meta-alt → null; pure resolver + global hook; editable/modifier guards, `Cmd`/`Ctrl`+`k` resolved in the same pre-editable slot as the zoom chords, arrows in the same post-editable/pre-modifier slot as `?` since Shift is their step multiplier) | keyboard-nav-shortcuts | keyboard-nav-shortcuts | commands | — | | Help overlay (view-aware orientation modal — "what am I looking at?"; `HelpModal` on the shared `Modal`, switched on `ViewName`; concise term→desc rows: Graph = entity types + layouts + Shift lineage + key-inherited vs surrogate; Dict = lenses + spotlight + Shift lineage + search/focus; Flow = DFD symbols + drill-down/inspect; per-view Keyboard section; footnote to Legend on Graph/Flow. Opened by a top-bar `?` button left of the theme toggle AND the `?` key — `resolveShortcut` returns `{type:'help'}`, resolved after the editable guard but before the bare-key modifier guard since `?` needs Shift, gated off ctrl/meta/alt; `useKeyboardShortcuts` `onHelp`; editable guard keeps `?` inert while typing. Distinct from the symbol `LegendModal`. Tests: `test-shortcuts.ts` T16 + `test/checks/test-help-overlay.ts` Playwright) | help-overlay | help-overlay, keyboard-nav-shortcuts | commands | — | | Graph and Flows search (dim-don't-filter title-first matching with a per-bar body-text toggle; Graph: `search-match`/`search-dim` cytoscape classes, `n of N` count readout, Enter cycles ascending-id matches wrapping via `navigateToEntity`, survives hover/lineage/relayout/SSE refresh; Flows: `searchFlowDiagrams` walks every non-synthetic diagram incl. sub-DFDs, results dropdown grouped by diagram navigates via `selectDiagramById`, in-diagram dim keys off suffix-stripped base token; `/` focuses the active view's search bar (Dictionary via `DictionaryViewHandle.focusSearch()`, unchanged otherwise); bundle-only, no model/layout-store/hash/export-payload writes) | graph-flow-search | graph-flow-search | commands | — | | Graph node position persistence (drag-to-save, reset) | graph-position-persistence | graph-position-persistence | — | — | diff --git a/docs/guides/commands.md b/docs/guides/commands.md index dc6fdb2..c82ded6 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -124,8 +124,9 @@ The app responds to single-key shortcuts while no text field is focused and no m | `b` | Toggle dictionary lens (read ↔ browse) — Dictionary view | | `/` or `Cmd`/`Ctrl` + `K` | Focus the search bar — Graph, Dictionary, Flows | | `?` | Open the help overlay for the current view | +| `←` `→` `↑` `↓` | Scroll the canvas 5px — Graph and Flows views; hold `Shift` for 25px | -Shortcuts are ignored while typing in a search box or any other input, and when a modifier key is held. `?` is the one exception to the modifier rule — it needs Shift to type, so Shift does not suppress it (but it is still ignored while typing in a field). `Cmd`/`Ctrl` + `K` is a second exception: like the zoom chords below, it focuses search even while a text field is already focused elsewhere. +Shortcuts are ignored while typing in a search box or any other input, and when a modifier key is held. `?` is the one exception to the modifier rule — it needs Shift to type, so Shift does not suppress it (but it is still ignored while typing in a field). `Cmd`/`Ctrl` + `K` is a second exception: like the zoom chords below, it focuses search even while a text field is already focused elsewhere. The arrow keys are a third: `Shift` selects their faster stride instead of suppressing them, and holding an arrow scrolls continuously. Arrows keep their normal meaning while typing in a field, and `Cmd`/`Ctrl`/`Alt` arrow chords are left to the OS and browser. ### Help overlay diff --git a/docs/spec/help-overlay.md b/docs/spec/help-overlay.md index fe2d192..ab5ca4c 100644 --- a/docs/spec/help-overlay.md +++ b/docs/spec/help-overlay.md @@ -56,3 +56,9 @@ primitive, view-switched on `ViewName`, with static term→description content; **What changed:** the Graph and Flows "How to explore" sections each gained a Search row (Graph: term matching, body-text toggle, Enter cycling; Flows: cross-diagram results grouped by diagram, in-diagram dimming). Every view's Keyboard section now lists `/` — focus that view's search input. **Why:** the graph-flow-search feature (`docs/spec/graph-flow-search.md`, SC8) adds search bars to Graph, Dictionary, and Flows with a shared `/` focus shortcut; the help overlay must describe what a first-time viewer sees, including the new search affordance. + +### 2026-07-14 — Arrow-key pan row added to the Keyboard section + +**What changed:** the Graph and Flows Keyboard sections gained a `← ↑ ↓ →` row ("Scroll the canvas; hold Shift to scroll faster"), added inside the existing graph/flow-only block next to the zoom chord row. + +**Why:** arrow-key canvas panning shipped in the shortcut resolver (`docs/spec/keyboard-nav-shortcuts.md`, 2026-07-14 arrow-key entry); the overlay lists the keys that work on each view. diff --git a/docs/spec/keyboard-nav-shortcuts.md b/docs/spec/keyboard-nav-shortcuts.md index c2e8cd7..0fabfb3 100644 --- a/docs/spec/keyboard-nav-shortcuts.md +++ b/docs/spec/keyboard-nav-shortcuts.md @@ -74,6 +74,26 @@ Suppressed in editable context (typing a literal `?` in the search box never opens the overlay). The overlay content/component is owned by `docs/spec/help-overlay.md`; this resolver owns only the key binding. +**Arrow-key panning** — resolved in the same slot as `?` (after the editable +guard, before the bare-key modifier guard), because Shift is the step +multiplier here, not a suppressor: + +| Key | Action | Active when | +|-----|--------|-------------| +| `←` `→` `↑` `↓` | `{ type: 'pan', dx, dy }` — pan the active canvas by `PAN_STEP` (5) screen px in the arrow's direction; `PAN_STEP_FAST` (25) with Shift held | `view === 'graph'` or `view === 'flow'`, not while typing | + +`(dx, dy)` is the direction the **viewport** moves; consumers slide their +content the opposite way (`GraphViewHandle.panBy` → `cy.panBy({ x: -dx, y: -dy })`; +`FlowsViewHandle.panBy` → the flow SVG's registered zoom/pan control, which +converts screen px to viewBox units with the same mapping as the pointer +drag-pan). The action fires on every `keydown`, so OS auto-repeat gives +continuous scrolling while a key is held. Gated off `ctrl`/`meta`/`alt` +(OS text/history chords like `Cmd`+`←` pass through), suppressed in editable +context (the text cursor keeps moving in inputs), and `null` on the dict view +(the Dictionary is a native scroll container — hijacking arrows would break +page scroll). `PAN_STEP`/`PAN_STEP_FAST` are exported constants in +`src/app/logic/shortcuts.ts`. + ## Success criteria - [ ] `resolveShortcut(event, view, editable)` is a pure function in `src/app/logic/shortcuts.ts`, no DOM/React imports, exporting a `ShortcutAction` discriminated union. @@ -87,6 +107,7 @@ opens the overlay). The overlay content/component is owned by - [ ] Touched source files (`App.tsx`, `DictionaryView.tsx`, `FabMenu.tsx`, new `logic/shortcuts.ts`, new `hooks/useKeyboardShortcuts.ts`) introduce **zero** new `tsc --noEmit` errors vs. the baseline (`tmp/baseline-typecheck.log`; these files start at 0). - [ ] CLAUDE.md feature map gets a "Keyboard navigation shortcuts" row; a brief mention added to the relevant user guide (`docs/guides/commands.md` or controls guide). - [ ] `Cmd`/`Ctrl` + `k` resolves to `{ type: 'search' }` in the same pre-editable-guard slot as the zoom chords (gated on `ctrl`/`meta`, not `alt`/`shift`), so it focuses the active view's search input even while a text field is already focused elsewhere; `test-shortcuts.ts` covers Cmd+k/Ctrl+k → search, editable-bypass, alt/shift → null, and bare `k` (no modifier) → null. +- [ ] Arrow keys resolve to `{ type: 'pan', dx, dy }` on graph/flow per the keymap (5px, 25px with Shift; dict → null; editable → null; ctrl/meta/alt → null); both view handles expose `panBy(dx, dy)` and the shell routes to the active canvas. `test-shortcuts.ts` covers the resolver rows (T25–T29); `test-keyboard-shortcuts.ts` proves in the browser that ArrowRight moves `cy.pan()` by exactly −5px, Shift steps 25px, the flow SVG inner translate moves opposite the viewport with a 5× Shift ratio, and arrows stay inert while a search input is focused. ## Approaches @@ -152,3 +173,9 @@ real-browser Playwright check, per the project's "test the actual runtime" lesso **What changed:** `resolveShortcut` now also returns `{ type: 'search' }` for `Cmd`/`Ctrl` + `k`, resolved in the same pre-editable-guard slot as the zoom chords (gated on `ctrl`/`meta`, not `alt`/`shift`) — so it focuses the active view's search input even while typing elsewhere, unlike bare `/` which stays suppressed in editable context. The section formerly titled "Modifier-gated zoom" is now "Modifier-gated zoom + search" to reflect the added row. `test-shortcuts.ts` covers Cmd+k/Ctrl+k → search, editable-bypass, alt/shift → null, and bare `k` (no modifier) → null. **Why:** user feedback on the live graph-flow-search branch (`docs/spec/graph-flow-search.md` CP5, SC8) — `Cmd`/`Ctrl`+`K` is the conventional "focus search" chord in modern web apps and users expect it to work regardless of where focus currently is. + +### 2026-07-14 — Arrow-key canvas panning + +**What changed:** `resolveShortcut` now returns `{ type: 'pan', dx, dy }` for the arrow keys on the graph and flow views — `PAN_STEP` (5) screen px per keydown, `PAN_STEP_FAST` (25) with Shift, resolved in the same post-editable/pre-modifier-guard slot as `?` since Shift is the step multiplier rather than a suppressor; ctrl/meta/alt chords and the dict view fall through to null. The body gained an "Arrow-key panning" keymap section. `useKeyboardShortcuts` carries an `onPan(dx, dy)` callback; the shell routes it to a new `panBy(dx, dy)` on `GraphViewHandle` (→ `cy.panBy` negated) and `FlowsViewHandle` (→ the flow SVG's registered zoom/pan control, screen-px→viewBox conversion shared with the pointer drag-pan). `test-shortcuts.ts` T25–T29 cover the resolver; `test-keyboard-shortcuts.ts` tests 5–7 prove the browser behavior on both canvases plus the editable guard. + +**Why:** user request — the DG and DFD canvases were mouse-only for scrolling; arrow keys with keydown auto-repeat give continuous keyboard scrolling, and Shift gives a faster stride. diff --git a/src/app/App.tsx b/src/app/App.tsx index 58349fe..98ad855 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -290,6 +290,14 @@ export function App() { else if (view === 'dict') dictViewRef.current?.focusSearch(); } + // Keyboard arrow-key pan — route to the ACTIVE canvas. (dx, dy) is the + // viewport-movement delta in screen px; each view's panBy slides its content + // the opposite way. Dict never reaches here (resolver returns null). + function handleKeyboardPan(dx: number, dy: number) { + if (view === 'graph') graphViewRef.current?.panBy(dx, dy); + else if (view === 'flow') flowsViewRef.current?.panBy(dx, dy); + } + // Global keyboard shortcut handler — single window keydown listener. // Reads current `view`/callbacks via a ref inside the hook (no stale closures). useKeyboardShortcuts({ @@ -302,6 +310,7 @@ export function App() { onZoomReset: handleKeyboardZoomReset, onHelp: () => setShowHelp(true), onSearch: handleKeyboardSearchFocus, + onPan: handleKeyboardPan, }); // NOTE: cy-init effect, navigator toggle effect, and all cy-specific refs have been diff --git a/src/app/components/ui/HelpModal.tsx b/src/app/components/ui/HelpModal.tsx index 6f78186..548e0d5 100644 --- a/src/app/components/ui/HelpModal.tsx +++ b/src/app/components/ui/HelpModal.tsx @@ -79,6 +79,7 @@ function shortcutRows(view: ViewName): Row[] { } if (view === 'graph' || view === 'flow') { rows.push({ term: '⌘/Ctrl +/−/0', desc: 'Zoom the canvas in, out, or reset.' }); + rows.push({ term: '← ↑ ↓ →', desc: 'Scroll the canvas; hold Shift to scroll faster.' }); } rows.push({ term: '/ · ⌘/Ctrl K', desc: 'Focus the search bar.' }); rows.push({ term: '?', desc: 'Open this help.' }); diff --git a/src/app/hooks/useKeyboardShortcuts.ts b/src/app/hooks/useKeyboardShortcuts.ts index 830d432..1f969f7 100644 --- a/src/app/hooks/useKeyboardShortcuts.ts +++ b/src/app/hooks/useKeyboardShortcuts.ts @@ -17,6 +17,10 @@ interface KeyboardShortcutsConfig { onHelp: () => void; /** `/` — focus the active view's search input. */ onSearch: () => void; + /** Arrow keys — pan the active canvas by (dx, dy) screen px (viewport-movement + * delta; graph/flow only — the resolver returns null on dict). Fires on every + * keydown, so OS auto-repeat gives continuous scrolling while held. */ + onPan: (dx: number, dy: number) => void; } /** @@ -40,11 +44,12 @@ export function useKeyboardShortcuts({ onZoomReset, onHelp, onSearch, + onPan, }: KeyboardShortcutsConfig): void { // Latest config ref — updated synchronously on every render so the stable // listener closure never reads stale values. - const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch }); - configRef.current = { view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch }; + const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }); + configRef.current = { view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }; useEffect(() => { function handleKeyDown(e: KeyboardEvent): void { @@ -82,6 +87,9 @@ export function useKeyboardShortcuts({ case 'zoomReset': cfg.onZoomReset(); break; case 'help': cfg.onHelp(); break; case 'search': cfg.onSearch(); break; + // preventDefault above also stops the browser from scrolling the page + // on arrow keys while a canvas view is active. + case 'pan': cfg.onPan(action.dx, action.dy); break; } } diff --git a/src/app/logic/shortcuts.ts b/src/app/logic/shortcuts.ts index 207b136..580e9f7 100644 --- a/src/app/logic/shortcuts.ts +++ b/src/app/logic/shortcuts.ts @@ -15,6 +15,8 @@ * Cmd/Ctrl+k is a second, always-on route to the same * action — see "Modifier-gated zoom + search" below.) * ? → help (any view; needs Shift, so resolved before guard 2) + * arrows → pan (graph/flow only; Shift multiplies the step, so resolved + * before guard 2 like '?' — see "Arrow-key panning" below) * * Guards checked before the switch: * 1. editable === true → null @@ -23,6 +25,15 @@ * Exception: '?' (Shift+/) is resolved after guard 1 but before guard 2, since * the character itself requires Shift; it is still suppressed in editable context. * + * Arrow-key panning (graph/flow views): each keydown pans the active canvas by + * PAN_STEP screen px, or PAN_STEP_FAST with Shift held — keydown auto-repeat + * makes holding a key scroll continuously. Resolved after guard 1 (arrows must + * keep moving the text cursor in editable context) but before guard 2 (Shift is + * the step multiplier here, not a suppressor); ctrl/meta/alt chords fall through + * to null (Cmd/Alt+arrow is OS text/history navigation). The action's (dx, dy) + * is the direction the VIEWPORT moves — consumers slide the content the + * opposite way. Dict view → null, preserving native page scroll. + * * Key matching is done on e.key.toLowerCase() so capslock does not block * actions (shift is already guarded, preventing Shift+G etc.). * @@ -60,7 +71,21 @@ export type ShortcutAction = | { type: 'zoomOut' } | { type: 'zoomReset' } | { type: 'help' } - | { type: 'search' }; + | { type: 'search' } + | { type: 'pan'; dx: number; dy: number }; + +/** Arrow-key pan step per keydown, in screen px (viewport-movement delta). */ +export const PAN_STEP = 5; +/** Arrow-key pan step per keydown with Shift held, in screen px. */ +export const PAN_STEP_FAST = 25; + +/** Viewport-movement delta per arrow key, at unit step. */ +const PAN_DIRECTION: Record = { + arrowleft: { dx: -1, dy: 0 }, + arrowright: { dx: 1, dy: 0 }, + arrowup: { dx: 0, dy: -1 }, + arrowdown: { dx: 0, dy: 1 }, +}; // --------------------------------------------------------------------------- // resolveShortcut @@ -109,6 +134,16 @@ export function resolveShortcut( return { type: 'help' }; } + // Arrow-key panning: like '?', resolved after the editable guard but before + // the modifier guard — Shift selects the fast step instead of suppressing. + // Gated to the two canvas views (dict keeps native scroll) and off + // ctrl/meta/alt (OS text/history navigation chords pass through). + const panDir = PAN_DIRECTION[key]; + if (panDir && (view === 'graph' || view === 'flow') && !e.ctrlKey && !e.metaKey && !e.altKey) { + const step = e.shiftKey ? PAN_STEP_FAST : PAN_STEP; + return { type: 'pan', dx: panDir.dx * step, dy: panDir.dy * step }; + } + // Guard 2: modifier chords (bare keys only — any modifier suppresses) if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return null; diff --git a/src/app/views/flow/FlowsView.tsx b/src/app/views/flow/FlowsView.tsx index 4268320..105ed57 100644 --- a/src/app/views/flow/FlowsView.tsx +++ b/src/app/views/flow/FlowsView.tsx @@ -91,6 +91,7 @@ export interface FlowChromeCallbacks { onRegisterZoomControl?: (ctrl: { zoomTo(scale: number): void; resetFit(): void; + panBy(dxPx: number, dyPx: number): void; } | null) => void; } @@ -609,6 +610,9 @@ export interface FlowsViewHandle { zoomOut(): void; setPercent(pct: number): void; resetZoom(): void; + /** Pan the viewport by (dx, dy) screen px — viewport-movement delta, so the + * rendered diagram slides the opposite way. Keyboard arrow-key scrolling. */ + panBy(dx: number, dy: number): void; openFlowToken(token: string): void; } @@ -691,6 +695,7 @@ export const FlowsView = forwardRef( // Adapter refs wired by onRegisterZoomControl from FlowDiagramSvg. const flowZoomToRef = useRef<((scale: number) => void) | null>(null); const flowResetFitRef = useRef<(() => void) | null>(null); + const flowPanByRef = useRef<((dxPx: number, dyPx: number) => void) | null>(null); // Live-scale mirror so zoom operations always read the current scale, not a stale closure value. const flowScaleRef = useRef(1); // Live fitScale mirror (viewBox→container ratio). Drives percent↔scale @@ -727,6 +732,9 @@ export const FlowsView = forwardRef( resetZoom() { flowResetFitRef.current?.(); }, + panBy(dx: number, dy: number) { + flowPanByRef.current?.(dx, dy); + }, openFlowToken(token: string) { flowOpenRef.current?.(token); }, @@ -807,6 +815,7 @@ export const FlowsView = forwardRef( onRegisterZoomControl: (ctrl) => { flowZoomToRef.current = ctrl ? ctrl.zoomTo : null; flowResetFitRef.current = ctrl ? ctrl.resetFit : null; + flowPanByRef.current = ctrl ? ctrl.panBy : null; // The diagram mounts at fit (scale 1). The real readout is reported by // onZoomChange once the SVG measures its container; no forced 100 here. }, diff --git a/src/app/views/graph/GraphView.tsx b/src/app/views/graph/GraphView.tsx index 8b7b16f..8f99db3 100644 --- a/src/app/views/graph/GraphView.tsx +++ b/src/app/views/graph/GraphView.tsx @@ -80,6 +80,9 @@ export interface GraphViewHandle { zoomOut(): void; setPercent(pct: number): void; resetZoom(): void; + /** Pan the viewport by (dx, dy) screen px — viewport-movement delta, so the + * rendered graph slides the opposite way. Keyboard arrow-key scrolling. */ + panBy(dx: number, dy: number): void; retheme(mode: 'dark' | 'light', model: Model, entityErrors: EntityError[]): void; } @@ -245,6 +248,11 @@ export const GraphView = forwardRef( resetZoom() { cyZoomResetRef.current?.(); }, + panBy(dx: number, dy: number) { + // cy.panBy moves the RENDERED CONTENT by the given px; the viewport + // moving right means the content slides left, hence the negation. + cyRef.current?.panBy({ x: -dx, y: -dy }); + }, retheme(mode: 'dark' | 'light', m: Model, entityErrors: EntityError[]) { const cy = cyRef.current; const svg = svgRef.current; diff --git a/src/flow-view/FlowDiagramSvg.tsx b/src/flow-view/FlowDiagramSvg.tsx index 41adeb6..a25f7e5 100644 --- a/src/flow-view/FlowDiagramSvg.tsx +++ b/src/flow-view/FlowDiagramSvg.tsx @@ -832,13 +832,15 @@ export type FlowDiagramSvgProps = { */ onZoomChange?: (scale: number, fitScale: number) => void; /** - * Called once on mount with zoom-control imperative operations, and with null + * Called once on mount with zoom/pan imperative operations, and with null * on unmount. Allows app-level ZoomControl handlers to drive the SVG zoom - * without coupling the component to the control. + * without coupling the component to the control. `panBy` takes a + * viewport-movement delta in screen px (keyboard arrow-key scrolling). */ onRegisterZoomControl?: (ctrl: { zoomTo(scale: number): void; resetFit(): void; + panBy(dxPx: number, dyPx: number): void; } | null) => void; /** * Match-set for graph-flow-search CP3: base tokens (role-split --src/--snk/ @@ -1084,7 +1086,18 @@ export function FlowDiagramSvg({ setTy(0); setScale(1); } - onRegisterZoomControl({ zoomTo, resetFit }); + function panBy(dxPx: number, dyPx: number) { + // Screen px → vb units, same mapping as the pointer drag-pan above. + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0 || rect.height === 0) return; + const vbDx = (dxPx / rect.width) * vbW; + const vbDy = (dyPx / rect.height) * vbH; + // (dxPx, dyPx) is the direction the VIEWPORT moves — the content + // translate goes the opposite way. + setTx(txRef.current - vbDx); + setTy(tyRef.current - vbDy); + } + onRegisterZoomControl({ zoomTo, resetFit, panBy }); return () => { onRegisterZoomControl(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [onRegisterZoomControl, vbX, vbY, vbW, vbH]); diff --git a/test/checks/test-keyboard-shortcuts.ts b/test/checks/test-keyboard-shortcuts.ts index 097c60d..84f689d 100644 --- a/test/checks/test-keyboard-shortcuts.ts +++ b/test/checks/test-keyboard-shortcuts.ts @@ -186,6 +186,103 @@ try { `key g reached the search input (input value contains 'g', got: "${inputValue}")`, ); + // --------------------------------------------------------------------------- + // Test 5: keyboard-pan on the graph — ArrowRight moves the viewport right + // (cy pan.x decreases by exactly 5); Shift+ArrowDown moves it down by 25. + // --------------------------------------------------------------------------- + + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + }); + await page.keyboard.press('g'); + await page.waitForFunction(() => location.hash.includes('view=graph'), { timeout: 3000 }); + await new Promise(r => setTimeout(r, 800)); + await page.evaluate(() => document.body.focus()); + + const panBefore = await page.evaluate(() => window.__IGNATIUS_CY__!.pan()); + await page.keyboard.press('ArrowRight'); + await new Promise(r => setTimeout(r, 150)); + const panAfterRight = await page.evaluate(() => window.__IGNATIUS_CY__!.pan()); + assert( + panAfterRight.x === panBefore.x - 5 && panAfterRight.y === panBefore.y, + 'ArrowRight pans the graph viewport right by 5px (content slides left)', + `pan before: ${JSON.stringify(panBefore)} after: ${JSON.stringify(panAfterRight)}`, + ); + + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + await new Promise(r => setTimeout(r, 150)); + const panAfterShiftDown = await page.evaluate(() => window.__IGNATIUS_CY__!.pan()); + assert( + panAfterShiftDown.y === panAfterRight.y - 25 && panAfterShiftDown.x === panAfterRight.x, + 'Shift+ArrowDown pans the graph viewport down by 25px', + `pan before: ${JSON.stringify(panAfterRight)} after: ${JSON.stringify(panAfterShiftDown)}`, + ); + + // --------------------------------------------------------------------------- + // Test 6: keyboard-pan on the flow SVG — the inner translate moves + // opposite the viewport, and the Shift step is exactly 5× the bare step + // (both go through the same screen-px→viewBox conversion). + // --------------------------------------------------------------------------- + + await page.keyboard.press('f'); + await page.waitForFunction(() => location.hash.includes('view=flow'), { timeout: 3000 }); + await page.waitForSelector('[data-ignatius="flow-svg"] > g', { timeout: 10_000 }); + await new Promise(r => setTimeout(r, 800)); + await page.evaluate(() => document.body.focus()); + + function readFlowTranslate() { + return page.evaluate(() => { + const g = document.querySelector('[data-ignatius="flow-svg"] > g'); + const m = g?.getAttribute('transform')?.match(/translate\(([-\d.e]+),([-\d.e]+)\)/); + return m ? { tx: Number(m[1]), ty: Number(m[2]) } : null; + }); + } + + const flowBefore = await readFlowTranslate(); + await page.keyboard.press('ArrowRight'); + await new Promise(r => setTimeout(r, 150)); + const flowAfterRight = await readFlowTranslate(); + const bareDx = flowBefore && flowAfterRight ? flowBefore.tx - flowAfterRight.tx : NaN; + assert( + flowBefore !== null && flowAfterRight !== null + && bareDx > 0 && flowAfterRight.ty === flowBefore.ty, + 'ArrowRight pans the flow viewport right (inner translate tx decreases, ty unchanged)', + `translate before: ${JSON.stringify(flowBefore)} after: ${JSON.stringify(flowAfterRight)}`, + ); + + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.up('Shift'); + await new Promise(r => setTimeout(r, 150)); + const flowAfterShift = await readFlowTranslate(); + const shiftDx = flowAfterRight && flowAfterShift ? flowAfterRight.tx - flowAfterShift.tx : NaN; + assert( + Math.abs(shiftDx - bareDx * 5) < 1e-6, + 'Shift+ArrowRight flow pan step is exactly 5x the bare step (25px vs 5px)', + `bare dx: ${bareDx} shift dx: ${shiftDx}`, + ); + + // --------------------------------------------------------------------------- + // Test 7: arrows are inert while typing — focus the flow search input and + // press ArrowRight; the canvas must not move (editable guard). + // --------------------------------------------------------------------------- + + await page.focus('.viewer-search-bar--flow .viewer-search-input'); + await new Promise(r => setTimeout(r, 100)); + const flowBeforeEditable = await readFlowTranslate(); + await page.keyboard.press('ArrowRight'); + await new Promise(r => setTimeout(r, 150)); + const flowAfterEditable = await readFlowTranslate(); + assert( + flowBeforeEditable !== null && flowAfterEditable !== null + && flowAfterEditable.tx === flowBeforeEditable.tx + && flowAfterEditable.ty === flowBeforeEditable.ty, + 'ArrowRight does not pan while focus is in a search input (editable guard)', + `translate before: ${JSON.stringify(flowBeforeEditable)} after: ${JSON.stringify(flowAfterEditable)}`, + ); + } finally { await page.close(); await browser.close(); diff --git a/test/checks/test-shortcuts.ts b/test/checks/test-shortcuts.ts index 3f06dff..068ded5 100644 --- a/test/checks/test-shortcuts.ts +++ b/test/checks/test-shortcuts.ts @@ -8,6 +8,8 @@ import { resolveShortcut, + PAN_STEP, + PAN_STEP_FAST, type ShortcutKeyEvent, type ShortcutAction, } from '../../src/app/logic/shortcuts'; @@ -411,4 +413,94 @@ for (const key of ['g', 'd', 'f', 'l', 'b']) { console.log("PASS T24: Cmd+'k' action shape { type:'search' }"); } +// --------------------------------------------------------------------------- +// T25: arrow keys on graph/flow → { type:'pan' } with a 5px viewport-movement +// delta in the arrow's direction (keyboard-pan). +// --------------------------------------------------------------------------- +{ + const ARROW_DELTAS: Array<[string, number, number]> = [ + ['ArrowLeft', -PAN_STEP, 0], + ['ArrowRight', PAN_STEP, 0], + ['ArrowUp', 0, -PAN_STEP], + ['ArrowDown', 0, PAN_STEP], + ]; + for (const view of ['graph', 'flow'] as const) { + for (const [key, dx, dy] of ARROW_DELTAS) { + const result = resolveShortcut(ev(key), view, false); + assert( + result !== null && result.type === 'pan' && result.dx === dx && result.dy === dy, + `T25: ${key} on ${view} → { type:'pan', dx:${dx}, dy:${dy} }`, + ); + } + console.log(`PASS T25: arrows on ${view} → pan at ${PAN_STEP}px`); + } +} + +// --------------------------------------------------------------------------- +// T26: Shift + arrow → the fast 25px step (Shift is the step multiplier here, +// NOT a suppressor — arrows resolve before the modifier guard, like '?'). +// --------------------------------------------------------------------------- +{ + const result = resolveShortcut(ev('ArrowDown', { shiftKey: true }), 'graph', false); + assert( + result !== null && result.type === 'pan' && result.dx === 0 && result.dy === PAN_STEP_FAST, + `T26: shift+ArrowDown → { type:'pan', dy:${PAN_STEP_FAST} }`, + ); + const left = resolveShortcut(ev('ArrowLeft', { shiftKey: true }), 'flow', false); + assert( + left !== null && left.type === 'pan' && left.dx === -PAN_STEP_FAST && left.dy === 0, + `T26: shift+ArrowLeft on flow → { type:'pan', dx:${-PAN_STEP_FAST} }`, + ); + console.log(`PASS T26: shift+arrow → pan at ${PAN_STEP_FAST}px`); +} + +// --------------------------------------------------------------------------- +// T27: arrows on dict → null — the Dictionary is a native scroll container; +// hijacking arrows there would break page scrolling. +// --------------------------------------------------------------------------- +{ + for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) { + assert(resolveShortcut(ev(key), 'dict', false) === null, `T27: ${key} on dict → null`); + assert( + resolveShortcut(ev(key, { shiftKey: true }), 'dict', false) === null, + `T27: shift+${key} on dict → null`, + ); + } + console.log('PASS T27: arrows (bare and shifted) on dict → null'); +} + +// --------------------------------------------------------------------------- +// T28: editable guard suppresses arrows — the text cursor must keep moving +// inside inputs/textareas/modals. +// --------------------------------------------------------------------------- +{ + for (const view of ['graph', 'flow'] as const) { + assert( + resolveShortcut(ev('ArrowRight'), view, true) === null, + `T28: ArrowRight editable=true on ${view} → null`, + ); + assert( + resolveShortcut(ev('ArrowUp', { shiftKey: true }), view, true) === null, + `T28: shift+ArrowUp editable=true on ${view} → null`, + ); + } + console.log('PASS T28: arrows suppressed in editable context'); +} + +// --------------------------------------------------------------------------- +// T29: ctrl/meta/alt + arrow → null — OS text/history navigation chords +// (Cmd+Left = line start / browser back) pass through untouched. +// --------------------------------------------------------------------------- +{ + for (const mod of ['ctrlKey', 'metaKey', 'altKey'] as const) { + const result = resolveShortcut(ev('ArrowLeft', { [mod]: true }), 'graph', false); + assert(result === null, `T29: ${mod}+ArrowLeft → null`); + const shifted = resolveShortcut( + ev('ArrowLeft', { [mod]: true, shiftKey: true }), 'graph', false, + ); + assert(shifted === null, `T29: ${mod}+shift+ArrowLeft → null`); + } + console.log('PASS T29: ctrl/meta/alt arrow chords → null'); +} + console.log('\nAll tests passed.'); From 4d395e888a2048c380c290015b7264af20c16bb5 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 20:40:56 -0400 Subject: [PATCH 2/4] chore(signals): refresh after keyboard-pan --- docs/wiki/index.md | 34 +++++++++++++++++----------------- docs/wiki/scan.md | 28 ++++++++++++++-------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index c6aadd1..0fc8f95 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -4,7 +4,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif --- repo -f4d2d14b4db849f2c97d39176536d5f5c80d100d +3aede24fdace8e11e9acc3ad0f00a675b1d075d7 1 # Project signals @@ -37,7 +37,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif [`test/`](../../test) is organized into subdirectories — not a formal test-framework suite: -- [`test/checks/`](../../test/checks) — 86 raw assertion scripts (PASS/FAIL/throw). Run by `bun run test` and CI. Includes `test-validate-entity.ts` and `test-validate-refs.ts` which pin `key-inherited` as the clean baseline and `broken-demo` as the broken fixture. `test-validate-refs.ts` expects 4 global + 8 entity = 12 total findings (1 additional `body.unknown_link` from `broken-demo/Order.md`'s `[[Cart]]` link). `test-folder-model.ts` (4 assertions) verifies the `data/`+`groups/` folder model contract: entity under `data/` IS parsed; entity outside `data/` is NOT parsed; model with no `groups/` parses with zero groups and no throw; entity with a group defined in `groups/.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (414L) — unit test for `resolveShortcut`: g/d/f→view, l→toggleLayout (graph-only), b→toggleLens (dict-only), zoomIn/zoomOut/zoomReset (Cmd/Ctrl + modifier), modifier guard, editable guard, capslock-insensitivity, T16 covers `?` → `{type:'help'}` (resolved after editable guard, before modifier guard, gated off ctrl/meta/alt), T17 covers [`/`](../..) → `{type:'search'}` on every view (an ordinary bare key, unlike `?` needs no Shift, resolved in the normal switch), T20–T24 (CP5) cover Cmd/Ctrl+`k` → `{type:'search'}` in the same modifier-gated pre-editable-guard slot as the zoom chords: fires on every view, resolves even when `editable === true` (unlike bare [`/`](../..)), alt/shift held → null, bare `k` (no modifier) → null (unmapped, types normally), and the returned action carries only `{ type }`. `test-keyboard-shortcuts.ts` (200L) — Playwright browser check: g/d/f view switch, l layout toggle, b lens toggle, editable-guard typing-inert (input/textarea/contenteditable/.modal). `test-help-overlay.ts` (140L) — CI Playwright check: top-bar `?` button opens `HelpModal`; `?` key opens it; Escape closes; modal is view-aware (content changes per active view); editable guard suppresses `?` while typing. `test-edge-hover-data.ts` (169L) — unit test for `normalizeEdgeData`: string array passthrough, string split on `", "`, empty/undefined → `[]`, single-item string. `test-dfd-edge-hover.ts` (229L) — CI-runnable Playwright check (skip-if-dist-absent): hover a known gated `db:` edge in the proving model, assert styled tooltip appears with full column-list text, assert tooltip absent before hover and after leave, assert `data-contract` still present on edge ``. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP5, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (318L) — CI-runnable Playwright check (skip-if-dist-absent) for the Graph search bar: dim/highlight (`search-match`/`search-dim`) classes, `n of N` count readout, Enter-to-cycle, body-toggle opt-in, survival across hover/layout-mode changes, and the no-persistence guarantee (search state never touches layout-store/URL hash/model); CP5 adds SC5 assertions against the `role="switch"` "Include descriptions" control (`page.getByRole('switch', ...)`, `aria-checked` false→true) in place of the old `.viewer-search-body-toggle` button-click. Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (337L) — CI-runnable Playwright check for the Flows search bar: cross-diagram results dropdown, sub-DFD navigation via `selectDiagramById`, in-diagram dim/highlight via `searchTokens`, body-toggle opt-in, the results display cap's "+N more" overflow line, and no-persistence; CP5 adds an SC12 block against the [`test/fixtures/flows-leveling`](../../test/fixtures/flows-leveling) fixture deep-linked to `#view=flow&dfd=Login` — asserts the `.viewer-search-bar--flow` bounding box never intersects `[data-ignatius="flow-breadcrumbs"]` and sits fully below it at full (4-chip) drill depth, plus a click+type clickability proof. Serves [`models/key-inherited`](../../models/key-inherited)'s `order-to-cash` sub-DFD tree; expected match sets are computed live via `parseFlows` + `searchFlowDiagrams` rather than hardcoded. +- [`test/checks/`](../../test/checks) — 86 raw assertion scripts (PASS/FAIL/throw). Run by `bun run test` and CI. Includes `test-validate-entity.ts` and `test-validate-refs.ts` which pin `key-inherited` as the clean baseline and `broken-demo` as the broken fixture. `test-validate-refs.ts` expects 4 global + 8 entity = 12 total findings (1 additional `body.unknown_link` from `broken-demo/Order.md`'s `[[Cart]]` link). `test-folder-model.ts` (4 assertions) verifies the `data/`+`groups/` folder model contract: entity under `data/` IS parsed; entity outside `data/` is NOT parsed; model with no `groups/` parses with zero groups and no throw; entity with a group defined in `groups/<slug>.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (506L) — unit test for `resolveShortcut`: g/d/f→view, l→toggleLayout (graph-only), b→toggleLens (dict-only), zoomIn/zoomOut/zoomReset (Cmd/Ctrl + modifier), modifier guard, editable guard, capslock-insensitivity, T16 covers `?` → `{type:'help'}` (resolved after editable guard, before modifier guard, gated off ctrl/meta/alt), T17 covers [`/`](../..) → `{type:'search'}` on every view (an ordinary bare key, unlike `?` needs no Shift, resolved in the normal switch), T20–T24 (CP5) cover Cmd/Ctrl+`k` → `{type:'search'}` in the same modifier-gated pre-editable-guard slot as the zoom chords: fires on every view, resolves even when `editable === true` (unlike bare [`/`](../..)), alt/shift held → null, bare `k` (no modifier) → null (unmapped, types normally), and the returned action carries only `{ type }`. T25–T29 (keyboard-pan) cover the arrow keys on graph/flow: T25 asserts each arrow → `{ type:'pan', dx, dy }` at `PAN_STEP` (5px) in the arrow's direction; T26 asserts Shift+arrow steps at `PAN_STEP_FAST` (25px, Shift is a multiplier not a suppressor here); T27 asserts arrows on `dict` → null (bare and shifted); T28 asserts the editable guard suppresses arrows on graph/flow; T29 asserts ctrl/meta/alt+arrow → null (bare and shifted), leaving OS text/history chords untouched. `test-keyboard-shortcuts.ts` (297L) — Playwright browser check: g/d/f view switch, l layout toggle, b lens toggle, editable-guard typing-inert (input/textarea/contenteditable/.modal). Tests 5–7 (keyboard-pan) exercise the browser end of arrow-key panning: test 5 asserts ArrowRight moves the live graph's `cy.pan()` by exactly −5 on x (viewport right = content left) and Shift+ArrowDown moves `pan().y` by −25; test 6 asserts ArrowRight moves the flow SVG's inner `<g>` translate opposite the viewport and that the Shift step is exactly 5× the bare step; test 7 focuses the flow search input and asserts ArrowRight leaves the SVG translate unchanged (editable guard). `test-help-overlay.ts` (140L) — CI Playwright check: top-bar `?` button opens `HelpModal`; `?` key opens it; Escape closes; modal is view-aware (content changes per active view); editable guard suppresses `?` while typing. `test-edge-hover-data.ts` (169L) — unit test for `normalizeEdgeData`: string array passthrough, string split on `", "`, empty/undefined → `[]`, single-item string. `test-dfd-edge-hover.ts` (229L) — CI-runnable Playwright check (skip-if-dist-absent): hover a known gated `db:` edge in the proving model, assert styled tooltip appears with full column-list text, assert tooltip absent before hover and after leave, assert `data-contract` still present on edge `<g>`. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `<title>` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP5, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (318L) — CI-runnable Playwright check (skip-if-dist-absent) for the Graph search bar: dim/highlight (`search-match`/`search-dim`) classes, `n of N` count readout, Enter-to-cycle, body-toggle opt-in, survival across hover/layout-mode changes, and the no-persistence guarantee (search state never touches layout-store/URL hash/model); CP5 adds SC5 assertions against the `role="switch"` "Include descriptions" control (`page.getByRole('switch', ...)`, `aria-checked` false→true) in place of the old `.viewer-search-body-toggle` button-click. Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (337L) — CI-runnable Playwright check for the Flows search bar: cross-diagram results dropdown, sub-DFD navigation via `selectDiagramById`, in-diagram dim/highlight via `searchTokens`, body-toggle opt-in, the results display cap's "+N more" overflow line, and no-persistence; CP5 adds an SC12 block against the [`test/fixtures/flows-leveling`](../../test/fixtures/flows-leveling) fixture deep-linked to `#view=flow&dfd=Login` — asserts the `.viewer-search-bar--flow` bounding box never intersects `[data-ignatius="flow-breadcrumbs"]` and sits fully below it at full (4-chip) drill depth, plus a click+type clickability proof. Serves [`models/key-inherited`](../../models/key-inherited)'s `order-to-cash` sub-DFD tree; expected match sets are computed live via `parseFlows` + `searchFlowDiagrams` rather than hardcoded. - [`test/visual/`](../../test/visual) — 64 Playwright screenshot scripts for manual visual inspection. NOT run by `bun run test`. Includes CP1–CP26 visual test scripts. `test-cp2-dfd-edge-labels.ts` covers ELK-routed edge labels (channel-placed, length-gated, truncated `…` preview for long labels, C5/C13 assertions updated for truncated-preview behaviour). `test-dfd-edge-hover.ts` (168L) — visual screenshot capturing the styled edge hover tooltip over a dense diagram. `test-dd-spotlight-grid.ts` (5090L) — covers the DD browse lens spotlight grid; CP7 section reworked to three Shift states (CP7.a NO Shift → 0 inherited, FK solid lines render; CP7.b hold Shift → inherited dotted lines appear, FK persists; CP7.c release Shift → inherited gone, FK persists) plus CP7-TRANSITIVE Identity block (0 → >0 → 0 under Shift). `test-dd-sticky-search.ts` covers the fixed frosted search bar in the browse and read lenses. `test-deep-nesting.ts` (142L) — serves [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) and asserts the DD process list shows full-depth numbers `1.1.1.1` / `1.1.1.2`. `test-graph-inherited-lines.ts` — visual screenshot of dotted inherited identity-group edges drawn via shift+hover in GraphView; reads per-tier opacity and asserts direct > inherited > unrelated (direct=1.0, inherited=0.5, unrelated=0.2). **graph-flow-search visuals:** `test-graph-search.ts` (99L) — types "Party" into the Graph search bar; screenshots Party + PartyType highlighted (`search-match`), every other entity dimmed (`search-dim`), and the `n of N` count readout; CP5 adds a second capture after toggling to light theme, so both dark and light chrome (including the "Include descriptions" switch) get reviewed. `test-flow-search.ts` (98L) — types "Validate" into the Flows search bar; screenshots the results dropdown open (grouped under its parent diagram) while the currently-rendered diagram's own non-matching nodes render dimmed via `searchTokens`; CP5 adds a matching light-theme second capture. - [`test/fixtures/`](../../test/fixtures) — YAML fixtures and fixture model roots. All fixture model roots use the v0.11.0 folder layout (`data/`, `groups/`, `externals/`, `stores/`; no `_*` prefixes): `flows-leveling/` (servable, 4-level-deep DFD tree), `flows-model/`, `broken-flows-model/`, `broken-flow/`, `flows/`. Per-DFD `_externals`/`_stores` were collapsed to root registries (same-name collisions in `broken-flow` Shopper and `flows-leveling` User resolved to one global definition each). - [`test/notes/`](../../test/notes) — 2 markdown dev notes. @@ -48,8 +48,8 @@ No linter or formatter configured in package.json. | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 59201 | 265 | 70% | -| Markdown | 20483 | 298 | 24% | +| TypeScript | 59431 | 265 | 70% | +| Markdown | 20517 | 298 | 24% | | CSS | 3004 | 2 | 3% | | YAML | 1340 | 14 | 1% | | Shell | 116 | 1 | 0% | @@ -216,7 +216,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (603L) — renderer-agnostic DFD layout helpers. Exports `assignStoreNumbers`, `buildFlowData`, `computeFlowLayout`, `normalizeEdgeData`, `processNodeSize`, `FlowElementData`, `NodePos`. **Role-split node model (dfd-overhaul):** `buildFlowData` applies the role-split strategy — `buildExternalRouting` caps each external at at most TWO aggregated layout node copies (source copy `ext:<id>--src` in band 0 aggregates all source-role partners; sink copy `ext:<id>--snk` in band 4 aggregates all sink-role partners; single-role externals get one copy). `FlowElementData` node variant carries `extKind?: FlowKindKey` and `storeKind?: FlowKindKey` for kind-colored fills. **Edge-hover-data (CP1):** `FlowElementData` edge variant carries `dataLines: string[]` (structured data items for the hover tooltip), populated in `buildFlowData` via `normalizeEdgeData(edge.data)`. `normalizeEdgeData(data: string | string[] | undefined): string[]` — pure exported helper: `string[]` passes through; a non-empty string splits on the literal `", "` separator; empty/undefined → `[]`. The existing joined `label` string is unchanged. **Process node sizing (#5):** `processNodeSize(label: string): { lines: string[]; width: number; height: number }` — pure exported helper; wraps the label into lines (max width cap) and computes the node height from line count so long process names no longer overflow the fixed 120×68 rect. Consumed by `nodeSize` in [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and by the SVG renderer in [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). -[`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (1750L) — SVG renderer consuming ELK positions and routed edge geometry. Props added by dfd-overhaul and later work: +[`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (1763L) — SVG renderer consuming ELK positions and routed edge geometry. Props added by dfd-overhaul and later work: - `elkPositions?: ElkPositionMap` — ELK-computed node positions (from `computeElkLayout`). When provided, replaces banded positions as the base layout; `savedPositions` drag overrides still win. Shape is a plain `Record<string, { x, y }>` matching `ElkLayoutResult.positions`. - `elkEdgeRoutes?: Record<string, Array<{ x, y }>>` — ELK-routed edge polylines (from `computeElkLayout`). When provided for an edge and neither endpoint has been dragged, the renderer draws the ELK polyline instead of `orthogonalPath`. Absent → `orthogonalPath` used for all edges. @@ -225,7 +225,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - Background-colored edge casing rendered under each edge line for visual separation on dense diagrams. - Channel-placed inline labels: labels satisfying `isInlineLabel` (≤ 22 chars) render in full in the inter-band channel; longer labels render a truncated `first~22…` preview chip. The native SVG `<title>` reveal is removed; full data appears in the styled HTML tooltip instead. - **Edge-hover tooltip (CP2):** `edgeTooltip` state `{ edgeId, x, y }` separate from `hover` (avoids mousemove triggering dim/highlight on every pixel). On pointer enter over an edge `<g>` or its chip, `setEdgeTooltip` captures pointer `clientX/clientY`. A `position:fixed` HTML div (`.flow-edge-tooltip`) renders over the SVG listing every `dataLines` item (one `<li>` per item) under a `<sourceLabel> → <targetLabel>` header. Tooltip shown for any edge with non-empty `dataLines` (inline and gated alike). `tooltipClearTimer` ref (flicker guard) delays clearing by ~80ms so crossing from the edge path layer to the chip layer does not flash the tooltip off. `data-contract` and `data-contract-type` attributes on edge `<g>` are unchanged. Dim/highlight focus behaviour on `hover` preserved. `truncateLabel(label, maxChars): string` — module-private; returns label unchanged if ≤ maxChars, else first `maxChars - 1` chars + `…`. -- `onZoomChange?: (scale: number, fitScale: number) => void` and `onRegisterZoomControl?` props for shell ZoomControl integration (CP23). +- `onZoomChange?: (scale: number, fitScale: number) => void` and `onRegisterZoomControl?` props for shell ZoomControl integration (CP23). **Keyboard pan:** the `onRegisterZoomControl` callback object grew a third method, `panBy(dxPx, dyPx)` (keyboard-pan feature) — converts the screen-px delta to viewBox units using the same `rect.width`/`rect.height` ratio as the pointer drag-pan, then sets `tx`/`ty` to the negated delta (the content translate moves opposite the viewport direction the caller passed). - **Cross-diagram search highlight (graph-flow-search CP3, SC7):** `searchTokens?: ReadonlySet<string> | null` prop (default `null`) — base tokens that stay at full opacity when no hover is active; `null` means no active search (no dimming from this source). `nodeOpacity`/`edgeOpacity` check `hover`-driven focus first (unchanged precedence), then fall back to `searchTokens` — a pointer hover always wins over search dimming while it's active. Non-matching nodes/edges dim to `DIM_OPACITY` (0.3), the same constant hover-focus dimming already used. An edge counts as matching when either `baseToken(edge.source)` or `baseToken(edge.target)` is in `searchTokens`. `baseToken(id: string): string` — module-private; strips the role-split layout suffixes (`--src`/`--snk`/`--read`/`--write`, via `id.replace(/--(src|snk|read|write)$/, '')`) so a node/edge-endpoint id compares equal to the base token that [`src/app/logic/search.ts`](../../src/app/logic/search.ts)'s `searchFlowDiagrams` produces. [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (449L) — DFD minimap + toolbar chrome wrapper. `.flow-minimap-wrapper` left offset aligned to 16px (matching DG `.minimap` left offset — CP19). **graph-flow-search CP5 (SC12):** a `breadcrumbRef` on the breadcrumb chip row (`data-ignatius="flow-breadcrumbs"`) feeds a `useLayoutEffect` + `ResizeObserver` pair that writes the row's measured bottom edge (`rect.bottom + 12px` gap) into a `--flow-search-bar-top` CSS custom property on `document.documentElement`; the property is removed on unmount or when the ref detaches. `styles.css`'s `.viewer-search-bar.viewer-search-bar--flow` rule reads that var so the flow search bar (mounted in App.tsx) always clears the breadcrumb row, at any drill depth, mirroring App.tsx's own `--search-bar-top` banner-offset idiom for the graph bar. @@ -239,19 +239,19 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde ### frontend -`src/App.tsx` was decomposed from a 5241L monolith into a layered [`src/app/`](../../src/app) tree. The entry point is now [`src/app/App.tsx`](../../src/app/App.tsx) (771L). [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. Layer rule: shell → views → components → ui → logic/dom (downward only). +`src/App.tsx` was decomposed from a 5241L monolith into a layered [`src/app/`](../../src/app) tree. The entry point is now [`src/app/App.tsx`](../../src/app/App.tsx) (780L). [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. Layer rule: shell → views → components → ui → logic/dom (downward only). **Layer map:** -- **Shell** — [`src/app/App.tsx`](../../src/app/App.tsx) (771L): state, view-switch, modal hosting, composition. Owns `openEntityById` (with `fromFlow` flag), `modelIndex` + `modelIndexRef` useMemo/ref pair, `appErrorsByEntityId` Map, `appAllFlowNodeIds`, `entityUsageIndex` useMemo, and `pendingScrollProcessIdRef` for dict process-scroll. Adds `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` (shared between FAB and keyboard shortcut `l`). Adds `showHelp` boolean state wired to the `?` top-bar button (`.help-toggle`, positioned left of the theme toggle) and `onHelp` callback passed to `useKeyboardShortcuts`; renders `HelpModal` when `showHelp === true`. Calls `useKeyboardShortcuts`. Interacts with GraphView, DictionaryView, and FlowsView exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). +- **Shell** — [`src/app/App.tsx`](../../src/app/App.tsx) (780L): state, view-switch, modal hosting, composition. Owns `openEntityById` (with `fromFlow` flag), `modelIndex` + `modelIndexRef` useMemo/ref pair, `appErrorsByEntityId` Map, `appAllFlowNodeIds`, `entityUsageIndex` useMemo, and `pendingScrollProcessIdRef` for dict process-scroll. Adds `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` (shared between FAB and keyboard shortcut `l`). Adds `showHelp` boolean state wired to the `?` top-bar button (`.help-toggle`, positioned left of the theme toggle) and `onHelp` callback passed to `useKeyboardShortcuts`; renders `HelpModal` when `showHelp === true`. **Keyboard pan:** `handleKeyboardPan(dx, dy)` routes the resolver's `{type:'pan'}` action to the active canvas — `graphViewRef.current?.panBy(dx, dy)` when `view === 'graph'`, `flowsViewRef.current?.panBy(dx, dy)` when `view === 'flow'`, no-op on dict (the resolver never emits `pan` there) — wired as `onPan` on `useKeyboardShortcuts`. Calls `useKeyboardShortcuts`. Interacts with GraphView, DictionaryView, and FlowsView exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). **Cross-view search (graph-flow-search):** owns per-surface search state that survives view switches because GraphView and FlowsView stay mounted-but-inactive and DictionaryView is keep-mounted via CSS. Graph search: `graphSearchTerm`/`graphSearchIncludeBody` (`useState`) plus `graphSearchCursorRef` (Enter-to-cycle cursor, reset to `-1` whenever the term or body toggle changes). `graphSearchActive = graphSearchTerm.trim() !== ''`; `graphSearchMatchIds` (`useMemo`) runs `entityMatches` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts) over `model.nodes`, sorted ascending by id; `graphSearchMatches` is `null` when inactive or a `Set` (possibly empty — an active search with zero matches still dims everything) when active, matching the contract `GraphView`'s `searchMatches` prop expects. `handleGraphSearchEnter` advances `graphSearchCursorRef` modulo the match count, wrapping, and calls `graphViewRef.current?.navigateToEntity(nextId)`. Flow search: `flowSearchTerm`/`flowSearchIncludeBody`; `flowSearchActive`; `flowSearchResults` (`useMemo`) calls `searchFlowDiagrams` from the same module; `flowSearchTokens` is the `Set` of each result's `token` (or `null`) threaded into `FlowsView`'s `searchTokens` prop. `handleFlowSearchSelect(diagramId)` and `handleFlowSearchEnter()` (opens the first result) both route through `flowsViewRef.current?.selectDiagramById` — no new navigation machinery. Neither graph nor flow search state ever touches the model, layout fingerprint, layout-store, or URL hash. `graphSearchBarRef`/`flowSearchBarRef` (`SearchBarHandle`) let `handleKeyboardSearchFocus` (wired to `useKeyboardShortcuts`'s `onSearch`) focus the active view's `SearchBar` input; the Dictionary path instead calls `dictViewRef.current?.focusSearch()` since its search input lives inside `DictionaryView`, not a `SearchBar`. The Graph `SearchBar` gets a live `matchCount`/`totalCount`; the Flows `SearchBar` always passes `matchCount={null}` / `totalCount={0}` (no "n of N" readout — the results dropdown is the readout). `bannerRef` measures the global-error banner's real rendered height via `ResizeObserver` (in a `useLayoutEffect`) and writes it into the `--search-bar-top` CSS custom property so `SearchBar` (`.viewer-search-bar`) sits below the banner instead of being occluded by it; the property is removed when the banner isn't visible, letting `styles.css`'s 12px fallback apply. - **Hooks:** - - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (173L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set to `true` in the returned cleanup; every `setState` call inside the boot `Promise.all(...).then(...)` and the SSE `model-changed` handler's `Promise.all(...).then(...)` (and both `.catch()` handlers) is gated on `if (ignore) return` / `if (!ignore)`. Guards against React StrictMode's dev-mode mount→cleanup→mount double-invoke: without the flag, the first (stale) invocation's in-flight fetch still resolves after its own effect cleanup ran and calls `setModel`/`setFlowDiagrams` with a fresh reference, retriggering every effect keyed on `model`/`flowDiagrams` (including the flow renderer's mount effect in `FlowsView.tsx`) while the second (real) mount is still settling. + - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (158L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types. - [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) — exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. Seeds from `location.hash` on mount. Accepts an `onRestoreDfd` callback for DFD deep-link restore. **Entity modal history (#6/#8):** `entity=` in the hash is the single source of truth for the modal stack. Opening an entity calls `openEntity(id)` (→ `history.pushState`; deduped when the hash already carries this same entity — an FK hop back to the same entity or a re-open does not stack a duplicate history entry); closing calls `closeEntity()` (→ `history.replaceState`). `popstate` invokes the `onEntityChange(id | null)` option to reconcile the shell — the shell opens/closes the modal in response without pushing another history entry. GraphView no longer writes `entity=`. Returns `{ view, setView, openEntity, closeEntity }`. - [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) (36L) — exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage. Calls `applyThemeCssVars` on change (also re-fires on `model` identity change, so an SSE-delivered model with the same `themeConfig` identity still reapplies CSS vars). Returns `{ themeMode, toggleTheme }`. - - [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (93L) — registers exactly ONE global `keydown` listener for the unified SPA keyboard shortcuts (g/d/f/l/b/?/[`/`](../..)/Cmd-Ctrl-k). Stale-closure hazard avoided via `configRef` (latest `view`/callbacks stored in a ref, updated each render; listener reads from ref). Editable guard: returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. `KeyboardShortcutsConfig` interface carries: `view`, `onView`, `onToggleLayout`, `onToggleLens`, `onZoomIn`, `onZoomOut`, `onZoomReset`, `onHelp`, `onSearch` (**graph-flow-search**: fires on [`/`](../..) or Cmd/Ctrl+`k` — focuses the active view's search input; both resolve to the same `{type:'search'}` action in `resolveShortcut`, so the hook itself is agnostic to which key triggered it). Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported by [`src/app/App.tsx`](../../src/app/App.tsx). + - [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (101L) — registers exactly ONE global `keydown` listener for the unified SPA keyboard shortcuts (g/d/f/l/b/?/[`/`](../..)/Cmd-Ctrl-k/arrows). Stale-closure hazard avoided via `configRef` (latest `view`/callbacks stored in a ref, updated each render; listener reads from ref). Editable guard: returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. `KeyboardShortcutsConfig` interface carries: `view`, `onView`, `onToggleLayout`, `onToggleLens`, `onZoomIn`, `onZoomOut`, `onZoomReset`, `onHelp`, `onSearch`, `onPan` (**graph-flow-search**: `onSearch` fires on [`/`](../..) or Cmd/Ctrl+`k` — focuses the active view's search input; both resolve to the same `{type:'search'}` action in `resolveShortcut`, so the hook itself is agnostic to which key triggered it. **keyboard-pan:** `onPan(dx, dy)` fires on the `{type:'pan'}` action — arrow keys on graph/flow; the switch case calls `cfg.onPan(action.dx, action.dy)` and relies on the hook's existing `preventDefault` on every matched action to also stop page-scroll). Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported by [`src/app/App.tsx`](../../src/app/App.tsx). - **Logic (pure, no DOM/React):** - [`src/app/logic/doc-resolver.ts`](../../src/app/logic/doc-resolver.ts) (125L) — exports `buildFlowDocResolver(diagrams, getEntityModel)` and `splitDocToken(token)`. `FlowDocResult` discriminated union: `entity` / `node` / `doc`. Resolver is keyed by stable id/slug — `title:` overrides on externals/stores do not break `[[wiki-link]]` resolution. @@ -262,7 +262,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/logic/relationship-key.ts`](../../src/app/logic/relationship-key.ts) (21L) — exports `relationshipRowKey(edge: ModelEdge): string`. Stable, collision-free React key for relationship rows; encodes source, target, and sorted `on` FK pairs to handle dual-FK tables (e.g. `TrackingStatus_Allowed → TrackingStatus` via `from_status` and `to_status`). - [`src/app/logic/spotlight.ts`](../../src/app/logic/spotlight.ts) — pure `buildSpotlightConnections(index: ModelIndex, entityId: string): SpotlightConnection[]`. Exports `SpotlightConnection` (`{ otherId, direction: 'out'|'in'|'both', edges: SpotlightEdge[] }`) and `SpotlightEdge` (`{ direction, predicate, cardinality, identifying }`). Uses `edgesBySource` + `edgesByTarget` only (no `model.edges` scans). Invariants: self-edges excluded; all edges to the same otherId bundled into one connection (out-before-in); direction='both' when bundle contains edges from both sets; result sorted by otherId ascending; unknown entityId → []. - [`src/app/logic/flow-spotlight.ts`](../../src/app/logic/flow-spotlight.ts) — pure `buildFlowSpotlightConnections(diagrams: FlowDiagram[], activeToken: string): FlowSpotlightConnection[]`. Exports `FlowSpotlightConnection` (`{ otherCardId, direction: 'out'|'in'|'both', edges: FlowSpotlightEdge[] }`) and `FlowSpotlightEdge` (`{ direction, data: string }`). Token scheme: `activeToken` is always `"<kind>:<name>"`; entity cards pass `"db:<entityId>"`. otherCardId resolution: `db:<name>` → bare entity id; anything else → raw `"<kind>:<name>"`. Walks all diagrams + sub-DFDs recursively. Array data joined with `", "`. Result sorted by otherCardId ascending. - - [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) (124L) — pure keyboard-shortcut resolver. No DOM, no React, no Bun/Node imports; browser-safe and unit-testable. Exports `resolveShortcut(e: ShortcutKeyEvent, view: ViewName, editable: boolean): ShortcutAction | null`, `ShortcutAction` discriminated union (`{ type: 'view'; view: ViewName } | { type: 'toggleLayout' } | { type: 'toggleLens' } | { type: 'zoomIn' } | { type: 'zoomOut' } | { type: 'zoomReset' } | { type: 'help' } | { type: 'search' }`), and `ShortcutKeyEvent` interface. Keymap: g→graph, d→dict, f→flow (any view); l→toggleLayout (graph only); b→toggleLens (dict only); [`/`](../..) → search (any view — an ordinary bare key, resolved in the normal switch since, unlike `?`, it needs no Shift); `?` → help (any view; Shift+/ on most layouts); Cmd/Ctrl + `=`/`+` → zoomIn, Cmd/Ctrl + `-`/`_` → zoomOut, Cmd/Ctrl + `0` → zoomReset (routed to canvas, not the browser), Cmd/Ctrl + `k` → search (graph-flow-search CP5 — a second, always-on route to the same `{type:'search'}` action as [`/`](../..), the conventional "focus search" chord). Guard order: (0) modifier-gated zoom+search resolved BEFORE both guards, gated on ctrl/meta only (alt/shift held → null; bare `=`/`-`/`0`/`k` with no modifier → null, unmapped); (1) editable guard; (2) `?` resolved AFTER editable guard but BEFORE the modifier guard — because `?` inherently requires Shift, guard 2 would otherwise swallow it; gated off ctrl/meta/alt; (3) modifier guard; (4) bare-key switch ([`/`](../..) resolves here). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. Cmd/Ctrl+`k` resolves even when `editable === true` (unlike bare [`/`](../..)) — the hook's `preventDefault` on every matched action also stops the browser's own "focus search" behavior. Imported by `useKeyboardShortcuts` only. + - [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) (159L) — pure keyboard-shortcut resolver. No DOM, no React, no Bun/Node imports; browser-safe and unit-testable. Exports `resolveShortcut(e: ShortcutKeyEvent, view: ViewName, editable: boolean): ShortcutAction | null`, `ShortcutAction` discriminated union (`{ type: 'view'; view: ViewName } | { type: 'toggleLayout' } | { type: 'toggleLens' } | { type: 'zoomIn' } | { type: 'zoomOut' } | { type: 'zoomReset' } | { type: 'help' } | { type: 'search' } | { type: 'pan'; dx: number; dy: number }`), and `ShortcutKeyEvent` interface. Keymap: g→graph, d→dict, f→flow (any view); l→toggleLayout (graph only); b→toggleLens (dict only); [`/`](../..) → search (any view — an ordinary bare key, resolved in the normal switch since, unlike `?`, it needs no Shift); `?` → help (any view; Shift+/ on most layouts); Cmd/Ctrl + `=`/`+` → zoomIn, Cmd/Ctrl + `-`/`_` → zoomOut, Cmd/Ctrl + `0` → zoomReset (routed to canvas, not the browser), Cmd/Ctrl + `k` → search (graph-flow-search CP5 — a second, always-on route to the same `{type:'search'}` action as [`/`](../..), the conventional "focus search" chord); arrow keys → `{type:'pan', dx, dy}` on `view === 'graph'` or `view === 'flow'` only (keyboard-pan — dict returns null, preserving native page scroll). Exports two pan-step constants: `PAN_STEP = 5` (bare arrow, screen px) and `PAN_STEP_FAST = 25` (Shift+arrow). An internal `PAN_DIRECTION` map turns `arrowleft`/`arrowright`/`arrowup`/`arrowdown` into a unit `{dx, dy}` (the viewport-movement direction) multiplied by the step; the pan branch is also gated off `!e.ctrlKey && !e.metaKey && !e.altKey` so OS text/history chords (Cmd+Left, etc.) fall through. Guard order: (0) modifier-gated zoom+search resolved BEFORE both guards, gated on ctrl/meta only (alt/shift held → null; bare `=`/`-`/`0`/`k` with no modifier → null, unmapped); (1) editable guard; (2) `?` resolved AFTER editable guard but BEFORE the modifier guard — because `?` inherently requires Shift, guard 2 would otherwise swallow it; gated off ctrl/meta/alt; (2b) arrow-key pan resolved in the same post-editable/pre-modifier slot as `?`, immediately after the `?` check — because Shift is the pan step multiplier here, not a suppressor; gated to graph/flow views and off ctrl/meta/alt; (3) modifier guard; (4) bare-key switch ([`/`](../..) resolves here). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. Cmd/Ctrl+`k` resolves even when `editable === true` (unlike bare [`/`](../..)) — the hook's `preventDefault` on every matched action also stops the browser's own "focus search" behavior. Imported by `useKeyboardShortcuts` only. - [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) (100L) — pure geometry helper for separating overlapping spotlight overlay lines (#2). No DOM, no React, no Bun/Node imports. Exports `separateSpotlightLines(base: BaseAnchor, directions: readonly LineDirection[]): SpotlightLineSpec[]`, `BaseAnchor`, `SpotlightLineSpec`, `LineDirection`, `SPOTLIGHT_LINE_GAP = 14`. Algorithm: K=1 → base line unchanged; K>1 → symmetric perpendicular offset of `(i - (K-1)/2) × GAP` per line — horizontal anchor spreads y, vertical anchor spreads x, centre of mass stays on base anchor. Each spec carries exactly ONE direction so each rendered `<path>` has a single arrowhead. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx). - [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) (220L) — pure inherited-connection logic for the DD browse lens and DG graph (#9, key-inheritance-lineage). No DOM, no React, no Bun/Node imports. Exports `buildInheritedConnections(index: ModelIndex, entityId: string): InheritedConnection[]`, `InheritedConnection = { otherId, direction: 'out'|'in'|'both', via: string }`, `INHERITED_IDENTITY = 'identity'`. **`direction` is always `'out'`** — the lineage line points FROM the active card OUTWARD to the lineage member; the union retains `'in'`/`'both'` for shape-compatibility with `SpotlightConnection` but no returned entry ever carries those values. `SpotlightOverlay` renders an `'out'` connection as ONE line with a single arrowhead at the far (member) end. **Key-edge connected-component model:** lineage follows ONLY key edges — an edge where `Object.keys(edge.on)` (FK columns on the child/source side) are ALL ⊆ `pkByNode.get(edge.source)` (child's PK), a non-empty subset test implementing IDEF1X identifying semantics; empirically equivalent to `edge.identifying === true` on key-inherited models. Catches identifying-1:many (FK a proper subset of PK, e.g. `SalesInvoice→Party`) in addition to identifying-1:1 (FK = full PK). Never follows a secondary FK. Identity group = transitive connected component over key edges in BOTH directions, cycle-safe via visited Set; subtype-cluster membership falls out naturally (member→basetype is a key edge). For every group member M ≠ active: emit M as identity link (`via = INHERITED_IDENTITY`) and emit M's direct FK connections with otherId outside the group (via = M id), both de-duped against the active's own direct connections. Does NOT call `buildSpotlightConnections` for de-dup (removed). Bundles to one entry per otherId (first-seen wins); result sorted ascending by otherId; active in no group → []. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx). @@ -274,7 +274,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/components/ui/Modal.tsx`](../../src/app/components/ui/Modal.tsx) — shared modal primitive (title + onClose + children). - [`src/app/components/ui/ZoomControl.tsx`](../../src/app/components/ui/ZoomControl.tsx) (66L) — view-agnostic `ZoomControl` component. Props: `percent`, `onZoomIn`, `onZoomOut`, `onSetPercent(pct)`, `onReset`. Clicking the readout opens an inline text input (commit on Enter/blur, cancel on Escape). CSS class `.zoom-control`. - [`src/app/components/ui/FabMenu.tsx`](../../src/app/components/ui/FabMenu.tsx) — per-view FAB menus. Items are view-gated (graph/dict/flow-specific items not rendered for other views). `kbd-hint` badges (G/D/F/L) appear on the view-switch and layout-toggle items to surface keyboard shortcuts inline. - - [`src/app/components/ui/HelpModal.tsx`](../../src/app/components/ui/HelpModal.tsx) (137L) — view-aware orientation overlay. Answers "what am I looking at?" for the active view (graph/dict/flow). Built on the shared `Modal` primitive. Exports `HelpModal({ view, onClose })`. Three view branches: graph (entity types, explore tips, two modeling styles), dict (lens descriptions, explore tips), flow (DFD symbols, explore tips). Each branch's "How to explore" section documents the surface's search behavior (**graph-flow-search**: graph — "Type to highlight matches and dim the rest; flip Include descriptions to also match markdown text; Enter cycles through matches"; dict — "Search · focus" combined row; flow — "Type to find matches across every diagram, including sub-DFDs — flip Include descriptions to also match markdown text; results list by diagram, click one to navigate there. Non-matches dim in the diagram" — CP5 renamed every "Body" reference to "Include descriptions" to match the `SearchBar` switch's visible label). Each branch closes with a view-specific `Keyboard` shortcuts section from `shortcutRows(view)`, which always includes a `/ · ⌘/Ctrl K` → "Focus the search bar" row (in addition to `G · D · F` and `?`; CP5 added the Cmd/Ctrl K mention alongside [`/`](../..)). Distinct from `LegendModal` (symbol reference); this is the conceptual orientation map. + - [`src/app/components/ui/HelpModal.tsx`](../../src/app/components/ui/HelpModal.tsx) (138L) — view-aware orientation overlay. Answers "what am I looking at?" for the active view (graph/dict/flow). Built on the shared `Modal` primitive. Exports `HelpModal({ view, onClose })`. Three view branches: graph (entity types, explore tips, two modeling styles), dict (lens descriptions, explore tips), flow (DFD symbols, explore tips). Each branch's "How to explore" section documents the surface's search behavior (**graph-flow-search**: graph — "Type to highlight matches and dim the rest; flip Include descriptions to also match markdown text; Enter cycles through matches"; dict — "Search · focus" combined row; flow — "Type to find matches across every diagram, including sub-DFDs — flip Include descriptions to also match markdown text; results list by diagram, click one to navigate there. Non-matches dim in the diagram" — CP5 renamed every "Body" reference to "Include descriptions" to match the `SearchBar` switch's visible label). Each branch closes with a view-specific `Keyboard` shortcuts section from `shortcutRows(view)`, which always includes a `/ · ⌘/Ctrl K` → "Focus the search bar" row (in addition to `G · D · F` and `?`; CP5 added the Cmd/Ctrl K mention alongside [`/`](../..)). **Keyboard pan:** `shortcutRows` adds a `← ↑ ↓ →` → "Scroll the canvas; hold Shift to scroll faster." row on the graph/flow branches only, next to the `⌘/Ctrl +/−/0` zoom row. Distinct from `LegendModal` (symbol reference); this is the conceptual orientation map. - [`src/app/components/ui/SearchBar.tsx`](../../src/app/components/ui/SearchBar.tsx) (147L) — exports `SearchBar` (`forwardRef<SearchBarHandle, SearchBarProps>`) and `SearchBarHandle` (`{ focus(): void }`). Shared search-bar chrome for the Graph and Flows surfaces (graph-flow-search CP2/CP3) — renders `.viewer-search-bar` with a debounced (200ms), echo-guarded `<input type="search">` (the same debounce/echo pattern `DictionaryView`'s own search box uses), a `.viewer-search-switch` toggle (`role="switch"`, `aria-checked={includeBody}`, wrapped in a `<label>` so the visible "Include descriptions" text is its accessible name — CP5 replaced the old bare `.viewer-search-body-toggle` button with this labeled switch), a `matchCount`/`totalCount` readout ("n of N", hidden when `matchCount === null`), and a `children` slot for a results dropdown. Enter flushes any pending debounce then calls `onEnter` (the caller decides what "next" means — Graph cycles matches, Flows opens the first result row); Escape clears the term and blurs. Props: `term`, `onTermChange`, `includeBody`, `onIncludeBodyChange`, `matchCount`, `totalCount`, `onEnter`, `placeholder`, `ariaLabel`, `className?`, `children?`. `App.tsx` mounts two instances tagged `className="viewer-search-bar--graph"` / `"viewer-search-bar--flow"` as CSS hooks; `--flow` now carries its own rule in `styles.css` (top offset sourced from `--flow-search-bar-top`, CP5 SC12) while `--graph` still renders through the shared `.viewer-search-bar` styling only. - **Flow search components:** @@ -309,12 +309,12 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/components/findings/FindingsPanel.tsx`](../../src/app/components/findings/FindingsPanel.tsx) (93L) — `<FindingsPanel>` renders only when `totalFindings > 0`; collapses to badge; present across all three views. - **Views:** - - [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (1447L) — exports `GraphView` (forwardRef) and `GraphViewHandle` + `LayoutMode` types. Owns full cy lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes: `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `retheme`. Navigator lifecycle is gated on `isActive` to prevent the CP18 crash. `wheelSensitivity: 0.2` passed to cytoscape. **Key-inheritance-lineage (CP-B) — SHIFT+HOVER trigger:** lineage (dotted `edge.inherited` rays + 3-tier focus opacity) is revealed by **SHIFT+HOVER**, not click/select. `cy.on('mouseover', 'node')` branches on `evt.originalEvent?.shiftKey`: shift held → `enterLineageHover(nodeId)` draws ephemeral inherited cy edges (`INHERITED_EDGE_PREFIX + id`, class `INHERITED_EDGE_CLASS = 'inherited'`) and applies 3-tier focus opacity; no shift → plain direct-neighbour dim only (no rays drawn). `cy.on('mouseout', 'node')` → `exitLineageHover()` clears rays and restores opacity. A document-level `keydown`/`keyup` pair on `'Shift'` reads the live `hoveredNodeIdRef` so holding or releasing Shift WHILE already hovering a node enters/exits lineage without a new pointer event; both listeners removed in cy-init cleanup (no leak). A plain click does NOT draw lineage: `cy.on('tap', 'node')` checks `evt.originalEvent?.shiftKey` — shift held returns immediately, leaving cytoscape's automatic tap-select as a persistent highlight and never calling `onSelectEntity` (no modal, no lineage); no shift calls `evt.target.unselect()` then `onSelectEntity(node)`, opening the modal without leaving the node visually selected behind it. A separate `cy.on('tap', ...)` with no node selector handles background taps (`evt.target === cy`): clears inherited edges, focus tiers, and the lineage-active flag, then calls `onDeselectEntity()`. The navigate/panelNavigate/hash-restore paths call `.select()` explicitly and also leave lineage state untouched. Lineage state refs: `hoveredNodeIdRef`, `lineageActiveRef`, `enterLineageHoverRef`, `exitLineageHoverRef` (all `useRef` — stale-closure safe for the document listeners). Ephemeral edges stripped on mouseout, Shift-release, deselect, reselect, relayout, and teardown — never enter model / layoutFingerprint / layout-store / export / ELK. **3-tier focus opacity (DG 3-tier focus opacity):** `applyFocusTiers(focusNode)` splits the graph into three opacity tiers on shift+hover: direct (focused node + real graph neighbors via `connectedEdges().not('.inherited')` + lineage/joiners — no class, full opacity 1.0), inherited/ancestral (dotted-ray target nodes — `.inherited-dim` class at 0.5), unrelated (`.faded` class at 0.2). `clearFocusTiers()` removes all three classes; called on mouseout, Shift-release, deselect, reselect, relayout, and teardown. **Cross-view search (graph-flow-search CP2):** accepts a `searchMatches: ReadonlySet<string> | null` prop (entity ids; `null` = no active search — no `search-*` classes applied). Mirrored into `searchMatchesRef` so the cy-init closure's `applySearchClasses` (wired via `applySearchClassesRef`) always reads the live value without the cy-init effect re-running on every keystroke. A dedicated effect reapplies search classes on every `searchMatches` change AND whenever `cyReady` flips true — covering the cy-rebuild paths (SSE model refresh, initial mount) where a fresh cy instance starts with no `search-*` classes and must have the active term reapplied without the user retyping it. `applySearchClasses` clears then reapplies `.search-match`/`.search-dim`: matched nodes get `.search-match`; every other node gets `.search-dim`; an edge stays undimmed only when BOTH endpoints match. These are DEDICATED classes, distinct from the hover-tier classes (`.faded`/`.inherited-dim`) and the ephemeral `.inherited` lineage class — `clearFocusTiers`/`clearInheritedEdges` never touch them, so hover, Shift-lineage, background tap, and layout changes cannot erase active search dimming. + - [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (1455L) — exports `GraphView` (forwardRef) and `GraphViewHandle` + `LayoutMode` types. Owns full cy lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes: `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. **Keyboard pan:** `panBy(dx, dy)` calls `cyRef.current?.panBy({ x: -dx, y: -dy })` — `cy.panBy` moves the rendered content by the given px, so the (dx, dy) viewport-movement delta is negated before being handed to cytoscape. Navigator lifecycle is gated on `isActive` to prevent the CP18 crash. `wheelSensitivity: 0.2` passed to cytoscape. **Key-inheritance-lineage (CP-B) — SHIFT+HOVER trigger:** lineage (dotted `edge.inherited` rays + 3-tier focus opacity) is revealed by **SHIFT+HOVER**, not click/select. `cy.on('mouseover', 'node')` branches on `evt.originalEvent?.shiftKey`: shift held → `enterLineageHover(nodeId)` draws ephemeral inherited cy edges (`INHERITED_EDGE_PREFIX + id`, class `INHERITED_EDGE_CLASS = 'inherited'`) and applies 3-tier focus opacity; no shift → plain direct-neighbour dim only (no rays drawn). `cy.on('mouseout', 'node')` → `exitLineageHover()` clears rays and restores opacity. A document-level `keydown`/`keyup` pair on `'Shift'` reads the live `hoveredNodeIdRef` so holding or releasing Shift WHILE already hovering a node enters/exits lineage without a new pointer event; both listeners removed in cy-init cleanup (no leak). A plain click does NOT draw lineage: `cy.on('tap', 'node')` checks `evt.originalEvent?.shiftKey` — shift held returns immediately, leaving cytoscape's automatic tap-select as a persistent highlight and never calling `onSelectEntity` (no modal, no lineage); no shift calls `evt.target.unselect()` then `onSelectEntity(node)`, opening the modal without leaving the node visually selected behind it. A separate `cy.on('tap', ...)` with no node selector handles background taps (`evt.target === cy`): clears inherited edges, focus tiers, and the lineage-active flag, then calls `onDeselectEntity()`. The navigate/panelNavigate/hash-restore paths call `.select()` explicitly and also leave lineage state untouched. Lineage state refs: `hoveredNodeIdRef`, `lineageActiveRef`, `enterLineageHoverRef`, `exitLineageHoverRef` (all `useRef` — stale-closure safe for the document listeners). Ephemeral edges stripped on mouseout, Shift-release, deselect, reselect, relayout, and teardown — never enter model / layoutFingerprint / layout-store / export / ELK. **3-tier focus opacity (DG 3-tier focus opacity):** `applyFocusTiers(focusNode)` splits the graph into three opacity tiers on shift+hover: direct (focused node + real graph neighbors via `connectedEdges().not('.inherited')` + lineage/joiners — no class, full opacity 1.0), inherited/ancestral (dotted-ray target nodes — `.inherited-dim` class at 0.5), unrelated (`.faded` class at 0.2). `clearFocusTiers()` removes all three classes; called on mouseout, Shift-release, deselect, reselect, relayout, and teardown. **Cross-view search (graph-flow-search CP2):** accepts a `searchMatches: ReadonlySet<string> | null` prop (entity ids; `null` = no active search — no `search-*` classes applied). Mirrored into `searchMatchesRef` so the cy-init closure's `applySearchClasses` (wired via `applySearchClassesRef`) always reads the live value without the cy-init effect re-running on every keystroke. A dedicated effect reapplies search classes on every `searchMatches` change AND whenever `cyReady` flips true — covering the cy-rebuild paths (SSE model refresh, initial mount) where a fresh cy instance starts with no `search-*` classes and must have the active term reapplied without the user retyping it. `applySearchClasses` clears then reapplies `.search-match`/`.search-dim`: matched nodes get `.search-match`; every other node gets `.search-dim`; an edge stays undimmed only when BOTH endpoints match. These are DEDICATED classes, distinct from the hover-tier classes (`.faded`/`.inherited-dim`) and the ephemeral `.inherited` lineage class — `clearFocusTiers`/`clearInheritedEdges` never touch them, so hover, Shift-lineage, background tap, and layout changes cannot erase active search dimming. - [`src/app/views/graph/organic-layout.ts`](../../src/app/views/graph/organic-layout.ts) (648L) — organic (fCoSE-based) layout engine, rewritten around `cytoscape-fcose` in place of the old ELK-stress organic mode. `ORGANIC_FALLBACK_THRESHOLD = 500` — at or above this entity count, organic mode falls back to layered even when the user chose "organic" (raised from a historical 150 now that compound group regions make a ~400-entity organic layout tractable in ~14s). `LAYERED_THOROUGHNESS_TINY/SMALL/MEDIUM/LARGE` = 30/20/14/7 (ELK `elk.layered.thoroughness` by entity-count tier) and `ORGANIC_ITERS_TINY/SMALL/MEDIUM` (`OrganicIters = { clusterFan, leafFan, deoverlap }`) = `{80,80,90}`/`{40,40,45}`/`{20,20,22}` scale the local-pass iteration budgets; `organicIters(n)` derives the tier. `buildScratchCore(live, groupRegions = false)` builds a HEADLESS cytoscape mirror of the live graph (node dims baked in as `dw`/`dh` data so scratch geometry matches live boxes without label machinery) — every read/write against a painted live core pays renderer bookkeeping, so the entire multi-seed layout search runs on this mirror and only the winning positions touch the live core; `groupRegions: true` (passed by `GraphView` when entity count ≥ 150) wraps each colour family in an invisible compound parent (`_group_<name>`) so fCoSE, which is compound-aware, decomposes a large model into per-family sub-layouts plus one small region-vs-region problem instead of one flat hairball. `GROUP_PULL_SELECTOR = 'edge[groupPull = "true"]'` + `addGroupPullEdges(cy)` add EPHEMERAL same-group attraction springs (star topology, anchor = highest-degree groupmate, ties by id) so fCoSE pulls each colour family into one neighbourhood; they exist only inside the synchronous layout run and `arrangeOrganic` strips them first. The multi-seed search (driven from `GraphView.tsx`, not this file) scores each candidate via three fitness functions defined here: `countEdgeCrossings(cy)` (pairwise segment-intersection count, edges sharing an endpoint excluded), `groupScatter(cy)` (per-group summed distance-to-centroid, normalised by mean edge length), and `excessWireLength(cy)` (summed amount by which each edge exceeds 1.5× the median length, in median units — penalises a bridge node flung to one extreme with long tethers). `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline, run in order: strip group-pull edges → `expandCore` (uniform-scale inflate the non-satellite core about its centroid, satellites riding along rigidly — a similarity transform that cannot introduce a crossing) → `fanSubtypeClusters` → `separateClusterFans(iters.clusterFan)` → `dockLeaves` (pull far-flung degree-1 satellites back onto a size-aware ring around their hub) → `dockIsolates` (place degree-0 grouped nodes on their colour family's perimeter, since no force ever moves an edgeless node) → `separateLeafFan(iters.leafFan)` → `decollinearNodes` → `separateNodesFromEdges(iters.deoverlap)` (nudge a node off any non-incident edge that pierces its box) → `deoverlapNodes(iters.deoverlap)`. `gradeEdgeSpans(cy)` grades every rendered edge's `span` data (`'near'|'mid'|'far'`) by percentile within the layout's own length distribution (top decile + length/median ratio ≥1.5 → far, top quartile + ratio ≥1.2 → mid) for `styles.ts`'s length-graded de-emphasis; pure function of current positions, called after every layout run / drag release. - [`src/app/views/graph/navigator.ts`](../../src/app/views/graph/navigator.ts) — `mountNavigator` / `teardownNavigator` / `NavigatorInstance` — cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` BEFORE `nav.destroy()`: `destroy()` removes overlay/window listeners but leaks the cy `'resize'` + onRender listeners, which fire `Navigator.resize→boundingBox` on a destroyed core (`isHeadless` of null); removing them while cy is alive fixes that crash. `test-navigator-teardown.ts` pins this call-order contract. - [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts) (243L) — `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. **3-tier focus opacity:** `.faded` selector sets `opacity: 0.2` (unrelated tier, changed from 0.3); `.inherited-dim` selector sets `opacity: 0.5` (inherited/ancestral tier); `edge.inherited` opacity changed from 0.85 to 0.5 so dotted rays match their `.inherited-dim` target nodes. Includes `edge.inherited` selector: dotted line, `SPOTLIGHT_LINE_INHERITED[themeMode]` color from [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), thinner than direct edges. Length-graded de-emphasis (data set by `organic-layout.ts`'s `gradeEdgeSpans` after layout/drag): `edge[span = "mid"]` → `opacity: 0.5`; `edge[span = "far"]` → `opacity: 0.22, width: 1, z-index: -1` — placed after the identifying/dashed rules so the span grading wins the cascade; long-haul cross-domain wires de-emphasize at overview but stay hoverable/selectable and regain full presence as you zoom in or follow them. **Graph search (graph-flow-search CP2):** `SEARCH_MATCH_BORDER` (`Record<ThemeMode, string>` — dark `#fde047` / light `#ca8a04`), a distinct gold/yellow chosen so it never reads as the amber `spotlight-line-out` lineage color or a group's border color, mirroring the Dictionary view's `--dd-search-highlight` yellow so "search" reads as one visual language across surfaces. `.search-dim` sets `opacity: 0.2`; `node.search-match` sets `border-width: 3` / `border-color: SEARCH_MATCH_BORDER[mode]`. Both rules are pushed LAST in the stylesheet array so they win the cascade over the span-graded edge opacity and the per-group border-color rules. - [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (1452L) — `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(): void; focusSearch(): void }` (`toggleLens` wraps the existing `switchLens`; `focusSearch` — added for **graph-flow-search** — focuses `searchInputRef`, the dict search `<input>`, in response to the shell's [`/`](../..) shortcut). Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search (CP9), `beforeprint`/`afterprint` (CP10), DD sidebar process nesting with `compareDottedProcesses` (CP24). Its own search (`nodeMatchesSearch`/`processMatchesSearch`/`externalMatchesSearch`/`storeMatchesSearch` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts), always matching columns + body) is unchanged by graph-flow-search — the new title-first, body-opt-in matchers (`entityMatches`, `flowProcessMatches`, etc.) are exclusive to the Graph and Flows `SearchBar` surfaces. `kbd-hint` B badge present on the lens toggle control. **Browse lens (dd-spotlight-grid):** lens toggle (`'read' | 'browse'`) persisted to `localStorage`. Browse lens renders entity groups + Processes / External Entities / Data Stores sections. Spotlight state: `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId` (focus mode). FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`. `SpotlightOverlay` mounted when `activeId !== null`. Fixed frosted search bar (`.dict-search-bar`); search and focus are mutually exclusive. **DD inherited lines are SHIFT-GATED:** a `shiftHeld` boolean state is driven by a document-level `keydown`/`keyup` pair on `Shift` + a `window` `blur` reset (registered in a dependency-free `useEffect`); the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId` (deps include `shiftHeld`), and the inherited-id foldings into `spotlitIds`/`focusSet` are wrapped with `if (shiftHeld)` — so without Shift no inherited card is lit, extra-focused, or surfaced as an off-screen chip. FK (solid) and flow (dashed) spotlight lines are unchanged (show on plain hover/pin). `SpotlightOverlay` is unchanged. - - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (891L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `openFlowToken`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet<string> | null` prop (base tokens — `proc:`/`ext:`/`<kind>:<name>`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token. **`disposed` teardown guard (race fix):** `initFlowGraphCore` declares `let disposed = false` alongside its existing `renderGen` counter. The async `renderDiagram(diagram)` checks `if (disposed) return` on entry and `if (renderGen !== myGen || disposed) return` after every `await computeElkLayout(diagram)` resolution (both the success path and the `catch` block). The teardown function `initFlowGraphCore` returns sets `disposed = true` as its first statement, before unregistering chrome callbacks or unmounting `svgRoot`. Guards against an orphaned `renderDiagram` continuation resuming after its owning `initFlowGraphCore` instance was torn down while still awaiting ELK (React StrictMode's dev-mode mount→cleanup→mount, or navigating away and back before ELK finishes) — `svgRoot` was still `null` at teardown time so there was nothing to unmount, and without this flag the orphaned call would resume, find the shared `container` DOM node still connected (`App.tsx` keeps every view's container mounted across view switches), and inject a second live React root into it, racing the real instance for the same container. + - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (873L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. **Keyboard pan:** `panBy(dx, dy)` delegates to `flowPanByRef.current?.(dx, dy)` — a ref populated by the `onRegisterZoomControl` callback's `panBy` field (registered from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)), the same adapter-ref pattern already used for `flowZoomToRef`/`flowResetFitRef`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet<string> | null` prop (base tokens — `proc:`/`ext:`/`<kind>:<name>`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token. - [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) (204L) — `LegendModal` component. - **Globals:** [`src/app/globals.d.ts`](../../src/app/globals.d.ts) (46L) — `window.__MODEL__`, `window.__THEME_MODE__`, `window.__IGNATIUS_MODE__` ('live'|'static'), `window.__LAYOUT_KEY__`, `window.__FLOW_MODEL__`, `window.__FLOW_LAYOUT_KEYS__`, `window.__IGNATIUS_CY__`, `window.__IGNATIUS_CY_GEN__`, `window.__IGNATIUS_FLOW_READY__`, `window.__IGNATIUS_FLOW_GEN__`, `window.__IGNATIUS_ACTIVE_FLOW_DFD__`, `window.__IGNATIUS_PERF__`. @@ -422,11 +422,11 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (69L) — implementation contract for the arbitrary DFD nesting depth fix: success criteria pin `renumberDiagram` recursion + full-prefix invariant, `test-deep-nesting.ts` assertions, `flows-leveling` fixture servability, skill F8 recursion update, and `bun run test` + `build:cli` green gate. [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (155L) — implementation contract for the DFD viewer overhaul. Success criteria C1–C18 including: C4 (5-band ordering invariant), C6 (context diagram: 1 process, external-only flows), C7 (L1 overview: all leaf-activity processes + stores with degree ≥ 2, no degree=1 stores), C12 (dotted numbering), C13 (inline labels: length gate ≤ 22 chars), C14 (external role-split: at most 2 copies per external), C15 (ELK-routed edge geometry from `sections`), C16 (single-row bands), C17 (node centers in positions), C18 (ELK runs in browser — `terminateQuietly` guard). Checkpoints 4a–4e. [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) (83L) — implementation contract for DFD edge-hover data reveal (issue #14). CP1–CP4 build order. Success criteria: `normalizeEdgeData` pure helper; `dataLines: string[]` on `FlowElementData` edge variant; `position:fixed` HTML hover tooltip listing full data items under `source → target` header; long labels render truncated `…` preview inline (not suppressed); native `<title>` removed; `data-contract`/`data-contract-type` unchanged; dim/highlight focus preserved; CI Playwright check + visual screenshot; `bun run test` exit 0. -[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (154L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). 2026-07-14 addendum (CP5): Cmd/Ctrl+`k` joins the modifier-gated slot (section renamed "Modifier-gated zoom" → "Modifier-gated zoom + search") as a second, always-on `{type:'search'}` route resolved before the editable guard — focuses search even while typing elsewhere, unlike bare [`/`](../..); gated on ctrl/meta only, bare `k` unmapped; the success-criteria checklist gained a line pinning `test-shortcuts.ts`'s Cmd/Ctrl+k coverage (editable-bypass, alt/shift → null, bare `k` → null). -[`docs/spec/help-overlay.md`](../spec/help-overlay.md) (58L) — implementation contract for the help overlay: `HelpModal` component contract (view prop, Modal primitive, three view branches + shared Keyboard section), `{type:'help'}` `ShortcutAction` resolution order (after editable guard, before modifier guard, gated off ctrl/meta/alt), `showHelp` state in shell, `.help-toggle` top-bar button placement, `.help-*` CSS classes, test-plan (`test-help-overlay.ts`: `?` button + key open, Escape closes, view-aware content, editable guard). 2026-07-14 addendum: Graph and Flows "How to explore" sections gained a Search row, and every view's Keyboard section now lists [`/`](../..) (focus that view's search input). +[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (181L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). 2026-07-14 addendum (CP5): Cmd/Ctrl+`k` joins the modifier-gated slot (section renamed "Modifier-gated zoom" → "Modifier-gated zoom + search") as a second, always-on `{type:'search'}` route resolved before the editable guard — focuses search even while typing elsewhere, unlike bare [`/`](../..); gated on ctrl/meta only, bare `k` unmapped; the success-criteria checklist gained a line pinning `test-shortcuts.ts`'s Cmd/Ctrl+k coverage (editable-bypass, alt/shift → null, bare `k` → null). 2026-07-14 addendum (arrow-key canvas panning): `resolveShortcut` gains `{type:'pan', dx, dy}` for the arrow keys on graph/flow — `PAN_STEP` (5px) bare, `PAN_STEP_FAST` (25px) with Shift, resolved in the same post-editable/pre-modifier slot as `?` since Shift is a step multiplier not a suppressor; dict/editable/ctrl-meta-alt → null. Both view handles gain `panBy(dx, dy)`; success criteria gained a line pinning `test-shortcuts.ts` T25–T29 and `test-keyboard-shortcuts.ts` tests 5–7. +[`docs/spec/help-overlay.md`](../spec/help-overlay.md) (64L) — implementation contract for the help overlay: `HelpModal` component contract (view prop, Modal primitive, three view branches + shared Keyboard section), `{type:'help'}` `ShortcutAction` resolution order (after editable guard, before modifier guard, gated off ctrl/meta/alt), `showHelp` state in shell, `.help-toggle` top-bar button placement, `.help-*` CSS classes, test-plan (`test-help-overlay.ts`: `?` button + key open, Escape closes, view-aware content, editable guard). 2026-07-14 addendum: Graph and Flows "How to explore" sections gained a Search row, and every view's Keyboard section now lists [`/`](../..) (focus that view's search input). 2026-07-14 addendum: the Graph and Flows Keyboard sections gained a `← ↑ ↓ →` → "Scroll the canvas; hold Shift to scroll faster" row, next to the existing zoom-chord row. [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) (372L) — implementation contract for key-inheritance-lineage: `buildInheritedConnections` key-edge connected-component algorithm (FK ⊆ PK, both directions, cycle-safe, identifying-1:many covered), DG ephemeral `edge.inherited` lifecycle (SHIFT+HOVER trigger — not click/select), DD `SpotlightOverlay` dotted inherited lines SHIFT-GATED in `DictionaryView`, `SPOTLIGHT_LINE_INHERITED` constant placement, test-plan (`test-spotlight-inherited.ts`, `test-inherited-edges-no-leak.ts`, `test-graph-inherited-edges.ts`, `test-navigator-teardown.ts`). Design doc [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) also rewritten to the key-edge rule. [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) (199L) — implementation contract for Graph/Flows search: SC1–SC12 (Graph `search-match`/`search-dim` classes + `n of N` count + Enter-cycle via `navigateToEntity`, resilience across hover/lineage/relayout/SSE-refresh, per-bar toggle-switch (`role="switch"`, "Include descriptions" label) title-vs-body matching per kind, Flows cross-diagram matcher over non-synthetic diagrams + results dropdown + `selectDiagramById` navigation including sub-DFDs, base-token role-split-suffix matching, two routes to `{type:'search'}` — [`/`](../..) after the editable guard and Cmd/Ctrl+`k` before it, like the zoom chords — wired through `DictionaryViewHandle.focusSearch()`, no persistence/layout/network side effects, bundle-only parity pinned as SC11, SC12 pins flow-view chrome non-collision between the search bar and the DFD breadcrumb chips/nav card), CP1–CP5 build order (pure matchers, Graph wiring, Flows wiring, [`/`](../..) shortcut + docs, CP5 switch-control/Cmd-Ctrl-K/chrome-clearance/visual-tightening pass). Change log: checkpoint-table column-schema correction, CP4 scope extended to cover the keymap spec surfaces ([`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md), [`docs/spec/help-overlay.md`](../spec/help-overlay.md)), CP5 (switch control + visual tightening + Cmd/Ctrl+K + SC12 chrome non-collision) added from live user feedback mid-build. Implementation log: shipped 2026-07-14 across 5 /autopilot iterations (10 commits total including the CP5 iteration), closing issue #18. -[`docs/guides/commands.md`](../guides/commands.md) (155L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) or Cmd/Ctrl+`K` chord (focus the active view's search bar — Graph, Dictionary, Flows; CP5 added Cmd/Ctrl+K as a second route that works even while a text field is already focused elsewhere) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. +[`docs/guides/commands.md`](../guides/commands.md) (156L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) or Cmd/Ctrl+`K` chord (focus the active view's search bar — Graph, Dictionary, Flows; CP5 added Cmd/Ctrl+K as a second route that works even while a text field is already focused elsewhere) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. Also documents `← → ↑ ↓` (scroll the canvas 5px on Graph/Flows, 25px with Shift) as a third exception to the modifier-suppression rule — Shift selects the faster stride instead of suppressing the shortcut, arrows keep their normal meaning while typing in a field, and Cmd/Ctrl/Alt+arrow chords are left to the OS/browser. [`docs/glossary.md`](../glossary.md) (52L) — canonical vocabulary: DG, DD, DFD, DE, DS, EE, Process, Data Flow. [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (118L) — research notes on SSADM DFD rules. [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) (129L) — research notes on DFD layout engines and Yourdon leveling used in specifying the dfd-overhaul approach; primary source for the ELK algorithm selection and band-partitioning option names. diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index f4d2d14..3aede24 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -4,7 +4,7 @@ ├── .cursor/ (1) │ └── rules/ (1) -│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (7e4582d, 181L, 22826ch, 23053B) +│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (00686b2, 181L, 23122ch, 23353B) ├── .github/ (1) │ └── workflows/ (2) │ ├── ci.yml (8718b72, 56L, 1568ch, 1568B) @@ -42,7 +42,7 @@ │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) │ ├── guides/ (10) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (e11811d, 155L, 8666ch, 8704B) +│ │ ├── commands.md (fb4932a, 156L, 9019ch, 9067B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) │ │ ├── flows.md (ec7f7cd, 148L, 8533ch, 8577B) │ │ ├── folder-format.md (ec20419, 159L, 5499ch, 5517B) @@ -73,10 +73,10 @@ │ │ ├── folder-model.md (9221504, 117L, 11731ch, 11793B) │ │ ├── graph-flow-search.md (1080c63, 199L, 18217ch, 18435B) │ │ ├── graph-position-persistence.md (ae7bdcd, 106L, 8067ch, 8126B) -│ │ ├── help-overlay.md (605fbef, 58L, 4378ch, 4390B) +│ │ ├── help-overlay.md (e4faa50, 64L, 4840ch, 4862B) │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) -│ │ ├── keyboard-nav-shortcuts.md (279fe27, 154L, 13210ch, 13317B) +│ │ ├── keyboard-nav-shortcuts.md (e3b3297, 181L, 16116ch, 16270B) │ │ ├── noorm-flow-discovery.md (4b7f5a8, 83L, 10554ch, 10666B) │ │ ├── noorm-modeling-skill.md (947190e, 204L, 22457ch, 22582B) │ │ ├── process-flows.md (e5e6472, 682L, 83302ch, 83868B) @@ -222,8 +222,8 @@ │ │ │ └── theme-css-vars.ts (61ca750, 114L, 6255ch, 6267B) │ │ ├── hooks/ (4) │ │ │ ├── useHashRoute.ts (c50964c, 121L, 5142ch, 5150B) -│ │ │ ├── useKeyboardShortcuts.ts (5962b6b, 93L, 3529ch, 3545B) -│ │ │ ├── useModelData.ts (b582ec4, 173L, 6319ch, 6451B) +│ │ │ ├── useKeyboardShortcuts.ts (226e7b4, 101L, 4029ch, 4049B) +│ │ │ ├── useModelData.ts (4877d58, 158L, 5483ch, 5609B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) │ │ ├── logic/ (11) │ │ │ ├── color.ts (8006242, 39L, 1796ch, 1796B) @@ -233,7 +233,7 @@ │ │ │ ├── flow-spotlight.ts (0725869, 136L, 5096ch, 5114B) │ │ │ ├── relationship-key.ts (21bbb6e, 21L, 950ch, 956B) │ │ │ ├── search.ts (3c76b8b, 272L, 10130ch, 10138B) -│ │ │ ├── shortcuts.ts (f81008d, 124L, 5035ch, 5071B) +│ │ │ ├── shortcuts.ts (9aec123, 159L, 6892ch, 6940B) │ │ │ ├── spotlight-inherited.ts (8fa6183, 220L, 9495ch, 9873B) │ │ │ ├── spotlight-lines.ts (0138798, 100L, 3696ch, 3724B) │ │ │ └── spotlight.ts (d4aec20, 101L, 3534ch, 3540B) @@ -241,7 +241,7 @@ │ │ │ ├── dict/ (1 file, 0 dirs) │ │ │ ├── flow/ (2 files, 0 dirs) │ │ │ └── graph/ (7 files, 0 dirs) -│ │ ├── App.tsx (429a948, 771L, 35314ch, 35479B) +│ │ ├── App.tsx (0e33b0a, 780L, 35760ch, 35927B) │ │ ├── globals.d.ts (77489f4, 46L, 2556ch, 2562B) │ │ ├── hash-router.ts (04e4fa6, 95L, 2804ch, 2806B) │ │ ├── index.html (e560504, 14L, 396ch, 396B) @@ -257,7 +257,7 @@ │ │ └── version.ts (8ca92d2, 6L, 312ch, 314B) │ ├── flow-view/ (5) │ │ ├── FlowChrome.tsx (c81ca71, 449L, 17265ch, 17698B) -│ │ ├── FlowDiagramSvg.tsx (77a60a8, 1750L, 71330ch, 73341B) +│ │ ├── FlowDiagramSvg.tsx (113af44, 1763L, 72001ch, 74016B) │ │ ├── elk-flow-layout.ts (2c68f80, 353L, 14722ch, 15545B) │ │ ├── flow-layout.ts (57004f3, 603L, 24035ch, 24826B) │ │ └── zoom-scale.ts (5a575f2, 74L, 3068ch, 3092B) @@ -338,7 +338,7 @@ │ │ ├── test-hash-router.ts (46f7614, 219L, 6942ch, 6998B) │ │ ├── test-help-overlay.ts (8d52edc, 140L, 5469ch, 5697B) │ │ ├── test-inherited-edges-no-leak.ts (0c8a942, 120L, 5250ch, 5348B) -│ │ ├── test-keyboard-shortcuts.ts (f287a6b, 200L, 7321ch, 7343B) +│ │ ├── test-keyboard-shortcuts.ts (b03fd27, 297L, 12138ch, 12169B) │ │ ├── test-layout-fingerprint.ts (92d8eea, 255L, 8386ch, 8464B) │ │ ├── test-layout-key-injection.ts (1ff0306, 104L, 4296ch, 4300B) │ │ ├── test-layout-store.ts (9c4a003, 157L, 6200ch, 6236B) @@ -356,7 +356,7 @@ │ │ ├── test-relationship-row-key.ts (4642966, 81L, 4077ch, 4097B) │ │ ├── test-semantic-colors.ts (60b1f25, 71L, 2206ch, 2206B) │ │ ├── test-serve-port.ts (6e3e5fb, 52L, 1928ch, 2192B) -│ │ ├── test-shortcuts.ts (8e6d06c, 414L, 19280ch, 19484B) +│ │ ├── test-shortcuts.ts (40771b0, 506L, 23280ch, 23526B) │ │ ├── test-spotlight-connections.ts (0806e1c, 282L, 10879ch, 10931B) │ │ ├── test-spotlight-inherited.ts (8f7b2c4, 388L, 18139ch, 18383B) │ │ ├── test-spotlight-lines.ts (e0cb78a, 207L, 9741ch, 9775B) @@ -493,7 +493,7 @@ ├── .gitignore (6d3972b, 53L, 809ch, 811B) ├── .signalsignore (50ea1be, 28L, 1036ch, 1050B) ├── CHANGELOG.md (dd55a4d, 240L, 18854ch, 18860B) -├── CLAUDE.md (7e4582d, 181L, 22826ch, 23053B) +├── CLAUDE.md (00686b2, 181L, 23122ch, 23353B) ├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B) ├── README.md (727982d, 69L, 4028ch, 4036B) ├── bun-env.d.ts (4404f36, 16L, 284ch, 284B) @@ -511,8 +511,8 @@ ## Languages -- TypeScript: 59201 LOC (70%), 265 files (45%) -- Markdown: 20483 LOC (24%), 298 files (50%) +- TypeScript: 59431 LOC (70%), 265 files (45%) +- Markdown: 20517 LOC (24%), 298 files (50%) - CSS: 3004 LOC (3%), 2 files (0%) - YAML: 1340 LOC (1%), 14 files (2%) - Shell: 116 LOC (0%), 1 file (0%) From 00d6fff798ef7687fce4710afc9a02edcc5d3e6d Mon Sep 17 00:00:00 2001 From: Danilo Alonso <damusix@gmail.com> Date: Wed, 15 Jul 2026 00:00:38 -0400 Subject: [PATCH 3/4] feat(app): raise arrow-key pan steps to 10px/50px 5px felt too slow for practical scrolling; PAN_STEP/PAN_STEP_FAST move to 10/50 (ratio unchanged at 5x). Docs and tests updated to match. --- docs/guides/commands.md | 2 +- docs/spec/keyboard-nav-shortcuts.md | 12 ++++++++++-- src/app/logic/shortcuts.ts | 4 ++-- test/checks/test-keyboard-shortcuts.ts | 10 +++++----- test/checks/test-shortcuts.ts | 4 ++-- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/guides/commands.md b/docs/guides/commands.md index c82ded6..db1aa7f 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -124,7 +124,7 @@ The app responds to single-key shortcuts while no text field is focused and no m | `b` | Toggle dictionary lens (read ↔ browse) — Dictionary view | | `/` or `Cmd`/`Ctrl` + `K` | Focus the search bar — Graph, Dictionary, Flows | | `?` | Open the help overlay for the current view | -| `←` `→` `↑` `↓` | Scroll the canvas 5px — Graph and Flows views; hold `Shift` for 25px | +| `←` `→` `↑` `↓` | Scroll the canvas 10px — Graph and Flows views; hold `Shift` for 50px | Shortcuts are ignored while typing in a search box or any other input, and when a modifier key is held. `?` is the one exception to the modifier rule — it needs Shift to type, so Shift does not suppress it (but it is still ignored while typing in a field). `Cmd`/`Ctrl` + `K` is a second exception: like the zoom chords below, it focuses search even while a text field is already focused elsewhere. The arrow keys are a third: `Shift` selects their faster stride instead of suppressing them, and holding an arrow scrolls continuously. Arrows keep their normal meaning while typing in a field, and `Cmd`/`Ctrl`/`Alt` arrow chords are left to the OS and browser. diff --git a/docs/spec/keyboard-nav-shortcuts.md b/docs/spec/keyboard-nav-shortcuts.md index 0fabfb3..7ac5027 100644 --- a/docs/spec/keyboard-nav-shortcuts.md +++ b/docs/spec/keyboard-nav-shortcuts.md @@ -80,7 +80,7 @@ multiplier here, not a suppressor: | Key | Action | Active when | |-----|--------|-------------| -| `←` `→` `↑` `↓` | `{ type: 'pan', dx, dy }` — pan the active canvas by `PAN_STEP` (5) screen px in the arrow's direction; `PAN_STEP_FAST` (25) with Shift held | `view === 'graph'` or `view === 'flow'`, not while typing | +| `←` `→` `↑` `↓` | `{ type: 'pan', dx, dy }` — pan the active canvas by `PAN_STEP` (10) screen px in the arrow's direction; `PAN_STEP_FAST` (50) with Shift held | `view === 'graph'` or `view === 'flow'`, not while typing | `(dx, dy)` is the direction the **viewport** moves; consumers slide their content the opposite way (`GraphViewHandle.panBy` → `cy.panBy({ x: -dx, y: -dy })`; @@ -107,7 +107,7 @@ page scroll). `PAN_STEP`/`PAN_STEP_FAST` are exported constants in - [ ] Touched source files (`App.tsx`, `DictionaryView.tsx`, `FabMenu.tsx`, new `logic/shortcuts.ts`, new `hooks/useKeyboardShortcuts.ts`) introduce **zero** new `tsc --noEmit` errors vs. the baseline (`tmp/baseline-typecheck.log`; these files start at 0). - [ ] CLAUDE.md feature map gets a "Keyboard navigation shortcuts" row; a brief mention added to the relevant user guide (`docs/guides/commands.md` or controls guide). - [ ] `Cmd`/`Ctrl` + `k` resolves to `{ type: 'search' }` in the same pre-editable-guard slot as the zoom chords (gated on `ctrl`/`meta`, not `alt`/`shift`), so it focuses the active view's search input even while a text field is already focused elsewhere; `test-shortcuts.ts` covers Cmd+k/Ctrl+k → search, editable-bypass, alt/shift → null, and bare `k` (no modifier) → null. -- [ ] Arrow keys resolve to `{ type: 'pan', dx, dy }` on graph/flow per the keymap (5px, 25px with Shift; dict → null; editable → null; ctrl/meta/alt → null); both view handles expose `panBy(dx, dy)` and the shell routes to the active canvas. `test-shortcuts.ts` covers the resolver rows (T25–T29); `test-keyboard-shortcuts.ts` proves in the browser that ArrowRight moves `cy.pan()` by exactly −5px, Shift steps 25px, the flow SVG inner translate moves opposite the viewport with a 5× Shift ratio, and arrows stay inert while a search input is focused. +- [ ] Arrow keys resolve to `{ type: 'pan', dx, dy }` on graph/flow per the keymap (10px, 50px with Shift; dict → null; editable → null; ctrl/meta/alt → null); both view handles expose `panBy(dx, dy)` and the shell routes to the active canvas. `test-shortcuts.ts` covers the resolver rows (T25–T29); `test-keyboard-shortcuts.ts` proves in the browser that ArrowRight moves `cy.pan()` by exactly −10px, Shift steps 50px, the flow SVG inner translate moves opposite the viewport with a 5× Shift ratio, and arrows stay inert while a search input is focused. ## Approaches @@ -179,3 +179,11 @@ real-browser Playwright check, per the project's "test the actual runtime" lesso **What changed:** `resolveShortcut` now returns `{ type: 'pan', dx, dy }` for the arrow keys on the graph and flow views — `PAN_STEP` (5) screen px per keydown, `PAN_STEP_FAST` (25) with Shift, resolved in the same post-editable/pre-modifier-guard slot as `?` since Shift is the step multiplier rather than a suppressor; ctrl/meta/alt chords and the dict view fall through to null. The body gained an "Arrow-key panning" keymap section. `useKeyboardShortcuts` carries an `onPan(dx, dy)` callback; the shell routes it to a new `panBy(dx, dy)` on `GraphViewHandle` (→ `cy.panBy` negated) and `FlowsViewHandle` (→ the flow SVG's registered zoom/pan control, screen-px→viewBox conversion shared with the pointer drag-pan). `test-shortcuts.ts` T25–T29 cover the resolver; `test-keyboard-shortcuts.ts` tests 5–7 prove the browser behavior on both canvases plus the editable guard. **Why:** user request — the DG and DFD canvases were mouse-only for scrolling; arrow keys with keydown auto-repeat give continuous keyboard scrolling, and Shift gives a faster stride. + +### 2026-07-14 — Pan step values raised to 10px / 50px + +**What changed:** `PAN_STEP`/`PAN_STEP_FAST` changed from 5/25 to 10/50 screen px; the keymap table, the affected success criterion, and every doc/test wording that named the old values were updated to match (the shift-fast-to-bare ratio stays 5×, unaffected). + +**Why:** user feedback on the live branch — the original 5px step felt too slow for practical scrolling. + +**Superseded:** the "Arrow-key canvas panning" entry above introduced the feature at 5px/25px; that entry is left as-is as the historical record of that commit. diff --git a/src/app/logic/shortcuts.ts b/src/app/logic/shortcuts.ts index 580e9f7..9ef3f68 100644 --- a/src/app/logic/shortcuts.ts +++ b/src/app/logic/shortcuts.ts @@ -75,9 +75,9 @@ export type ShortcutAction = | { type: 'pan'; dx: number; dy: number }; /** Arrow-key pan step per keydown, in screen px (viewport-movement delta). */ -export const PAN_STEP = 5; +export const PAN_STEP = 10; /** Arrow-key pan step per keydown with Shift held, in screen px. */ -export const PAN_STEP_FAST = 25; +export const PAN_STEP_FAST = 50; /** Viewport-movement delta per arrow key, at unit step. */ const PAN_DIRECTION: Record<string, { dx: number; dy: number }> = { diff --git a/test/checks/test-keyboard-shortcuts.ts b/test/checks/test-keyboard-shortcuts.ts index 84f689d..2da918c 100644 --- a/test/checks/test-keyboard-shortcuts.ts +++ b/test/checks/test-keyboard-shortcuts.ts @@ -204,8 +204,8 @@ try { await new Promise<void>(r => setTimeout(r, 150)); const panAfterRight = await page.evaluate(() => window.__IGNATIUS_CY__!.pan()); assert( - panAfterRight.x === panBefore.x - 5 && panAfterRight.y === panBefore.y, - 'ArrowRight pans the graph viewport right by 5px (content slides left)', + panAfterRight.x === panBefore.x - 10 && panAfterRight.y === panBefore.y, + 'ArrowRight pans the graph viewport right by 10px (content slides left)', `pan before: ${JSON.stringify(panBefore)} after: ${JSON.stringify(panAfterRight)}`, ); @@ -215,8 +215,8 @@ try { await new Promise<void>(r => setTimeout(r, 150)); const panAfterShiftDown = await page.evaluate(() => window.__IGNATIUS_CY__!.pan()); assert( - panAfterShiftDown.y === panAfterRight.y - 25 && panAfterShiftDown.x === panAfterRight.x, - 'Shift+ArrowDown pans the graph viewport down by 25px', + panAfterShiftDown.y === panAfterRight.y - 50 && panAfterShiftDown.x === panAfterRight.x, + 'Shift+ArrowDown pans the graph viewport down by 50px', `pan before: ${JSON.stringify(panAfterRight)} after: ${JSON.stringify(panAfterShiftDown)}`, ); @@ -260,7 +260,7 @@ try { const shiftDx = flowAfterRight && flowAfterShift ? flowAfterRight.tx - flowAfterShift.tx : NaN; assert( Math.abs(shiftDx - bareDx * 5) < 1e-6, - 'Shift+ArrowRight flow pan step is exactly 5x the bare step (25px vs 5px)', + 'Shift+ArrowRight flow pan step is exactly 5x the bare step (50px vs 10px)', `bare dx: ${bareDx} shift dx: ${shiftDx}`, ); diff --git a/test/checks/test-shortcuts.ts b/test/checks/test-shortcuts.ts index 068ded5..7202594 100644 --- a/test/checks/test-shortcuts.ts +++ b/test/checks/test-shortcuts.ts @@ -414,7 +414,7 @@ for (const key of ['g', 'd', 'f', 'l', 'b']) { } // --------------------------------------------------------------------------- -// T25: arrow keys on graph/flow → { type:'pan' } with a 5px viewport-movement +// T25: arrow keys on graph/flow → { type:'pan' } with a 10px viewport-movement // delta in the arrow's direction (keyboard-pan). // --------------------------------------------------------------------------- { @@ -437,7 +437,7 @@ for (const key of ['g', 'd', 'f', 'l', 'b']) { } // --------------------------------------------------------------------------- -// T26: Shift + arrow → the fast 25px step (Shift is the step multiplier here, +// T26: Shift + arrow → the fast 50px step (Shift is the step multiplier here, // NOT a suppressor — arrows resolve before the modifier guard, like '?'). // --------------------------------------------------------------------------- { From a76a4151453b7cc3e1b59bff51f659310ef7be72 Mon Sep 17 00:00:00 2001 From: Danilo Alonso <damusix@gmail.com> Date: Wed, 15 Jul 2026 00:05:41 -0400 Subject: [PATCH 4/4] chore(signals): refresh after pan-step change --- docs/wiki/index.md | 16 ++++++++-------- docs/wiki/scan.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 0fc8f95..6d37e13 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -4,7 +4,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif --- <wiki-type>repo</wiki-type> -<scan-sha>3aede24fdace8e11e9acc3ad0f00a675b1d075d7</scan-sha> +<scan-sha>292105de474c67adcf473c6aed2d73ce14536dec</scan-sha> <wiki-schema>1</wiki-schema> # Project signals @@ -37,7 +37,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif [`test/`](../../test) is organized into subdirectories — not a formal test-framework suite: -- [`test/checks/`](../../test/checks) — 86 raw assertion scripts (PASS/FAIL/throw). Run by `bun run test` and CI. Includes `test-validate-entity.ts` and `test-validate-refs.ts` which pin `key-inherited` as the clean baseline and `broken-demo` as the broken fixture. `test-validate-refs.ts` expects 4 global + 8 entity = 12 total findings (1 additional `body.unknown_link` from `broken-demo/Order.md`'s `[[Cart]]` link). `test-folder-model.ts` (4 assertions) verifies the `data/`+`groups/` folder model contract: entity under `data/` IS parsed; entity outside `data/` is NOT parsed; model with no `groups/` parses with zero groups and no throw; entity with a group defined in `groups/<slug>.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (506L) — unit test for `resolveShortcut`: g/d/f→view, l→toggleLayout (graph-only), b→toggleLens (dict-only), zoomIn/zoomOut/zoomReset (Cmd/Ctrl + modifier), modifier guard, editable guard, capslock-insensitivity, T16 covers `?` → `{type:'help'}` (resolved after editable guard, before modifier guard, gated off ctrl/meta/alt), T17 covers [`/`](../..) → `{type:'search'}` on every view (an ordinary bare key, unlike `?` needs no Shift, resolved in the normal switch), T20–T24 (CP5) cover Cmd/Ctrl+`k` → `{type:'search'}` in the same modifier-gated pre-editable-guard slot as the zoom chords: fires on every view, resolves even when `editable === true` (unlike bare [`/`](../..)), alt/shift held → null, bare `k` (no modifier) → null (unmapped, types normally), and the returned action carries only `{ type }`. T25–T29 (keyboard-pan) cover the arrow keys on graph/flow: T25 asserts each arrow → `{ type:'pan', dx, dy }` at `PAN_STEP` (5px) in the arrow's direction; T26 asserts Shift+arrow steps at `PAN_STEP_FAST` (25px, Shift is a multiplier not a suppressor here); T27 asserts arrows on `dict` → null (bare and shifted); T28 asserts the editable guard suppresses arrows on graph/flow; T29 asserts ctrl/meta/alt+arrow → null (bare and shifted), leaving OS text/history chords untouched. `test-keyboard-shortcuts.ts` (297L) — Playwright browser check: g/d/f view switch, l layout toggle, b lens toggle, editable-guard typing-inert (input/textarea/contenteditable/.modal). Tests 5–7 (keyboard-pan) exercise the browser end of arrow-key panning: test 5 asserts ArrowRight moves the live graph's `cy.pan()` by exactly −5 on x (viewport right = content left) and Shift+ArrowDown moves `pan().y` by −25; test 6 asserts ArrowRight moves the flow SVG's inner `<g>` translate opposite the viewport and that the Shift step is exactly 5× the bare step; test 7 focuses the flow search input and asserts ArrowRight leaves the SVG translate unchanged (editable guard). `test-help-overlay.ts` (140L) — CI Playwright check: top-bar `?` button opens `HelpModal`; `?` key opens it; Escape closes; modal is view-aware (content changes per active view); editable guard suppresses `?` while typing. `test-edge-hover-data.ts` (169L) — unit test for `normalizeEdgeData`: string array passthrough, string split on `", "`, empty/undefined → `[]`, single-item string. `test-dfd-edge-hover.ts` (229L) — CI-runnable Playwright check (skip-if-dist-absent): hover a known gated `db:` edge in the proving model, assert styled tooltip appears with full column-list text, assert tooltip absent before hover and after leave, assert `data-contract` still present on edge `<g>`. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `<title>` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP5, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (318L) — CI-runnable Playwright check (skip-if-dist-absent) for the Graph search bar: dim/highlight (`search-match`/`search-dim`) classes, `n of N` count readout, Enter-to-cycle, body-toggle opt-in, survival across hover/layout-mode changes, and the no-persistence guarantee (search state never touches layout-store/URL hash/model); CP5 adds SC5 assertions against the `role="switch"` "Include descriptions" control (`page.getByRole('switch', ...)`, `aria-checked` false→true) in place of the old `.viewer-search-body-toggle` button-click. Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (337L) — CI-runnable Playwright check for the Flows search bar: cross-diagram results dropdown, sub-DFD navigation via `selectDiagramById`, in-diagram dim/highlight via `searchTokens`, body-toggle opt-in, the results display cap's "+N more" overflow line, and no-persistence; CP5 adds an SC12 block against the [`test/fixtures/flows-leveling`](../../test/fixtures/flows-leveling) fixture deep-linked to `#view=flow&dfd=Login` — asserts the `.viewer-search-bar--flow` bounding box never intersects `[data-ignatius="flow-breadcrumbs"]` and sits fully below it at full (4-chip) drill depth, plus a click+type clickability proof. Serves [`models/key-inherited`](../../models/key-inherited)'s `order-to-cash` sub-DFD tree; expected match sets are computed live via `parseFlows` + `searchFlowDiagrams` rather than hardcoded. +- [`test/checks/`](../../test/checks) — 86 raw assertion scripts (PASS/FAIL/throw). Run by `bun run test` and CI. Includes `test-validate-entity.ts` and `test-validate-refs.ts` which pin `key-inherited` as the clean baseline and `broken-demo` as the broken fixture. `test-validate-refs.ts` expects 4 global + 8 entity = 12 total findings (1 additional `body.unknown_link` from `broken-demo/Order.md`'s `[[Cart]]` link). `test-folder-model.ts` (4 assertions) verifies the `data/`+`groups/` folder model contract: entity under `data/` IS parsed; entity outside `data/` is NOT parsed; model with no `groups/` parses with zero groups and no throw; entity with a group defined in `groups/<slug>.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (506L) — unit test for `resolveShortcut`: g/d/f→view, l→toggleLayout (graph-only), b→toggleLens (dict-only), zoomIn/zoomOut/zoomReset (Cmd/Ctrl + modifier), modifier guard, editable guard, capslock-insensitivity, T16 covers `?` → `{type:'help'}` (resolved after editable guard, before modifier guard, gated off ctrl/meta/alt), T17 covers [`/`](../..) → `{type:'search'}` on every view (an ordinary bare key, unlike `?` needs no Shift, resolved in the normal switch), T20–T24 (CP5) cover Cmd/Ctrl+`k` → `{type:'search'}` in the same modifier-gated pre-editable-guard slot as the zoom chords: fires on every view, resolves even when `editable === true` (unlike bare [`/`](../..)), alt/shift held → null, bare `k` (no modifier) → null (unmapped, types normally), and the returned action carries only `{ type }`. T25–T29 (keyboard-pan) cover the arrow keys on graph/flow: T25 asserts each arrow → `{ type:'pan', dx, dy }` at `PAN_STEP` (10px) in the arrow's direction; T26 asserts Shift+arrow steps at `PAN_STEP_FAST` (50px, Shift is a multiplier not a suppressor here); T27 asserts arrows on `dict` → null (bare and shifted); T28 asserts the editable guard suppresses arrows on graph/flow; T29 asserts ctrl/meta/alt+arrow → null (bare and shifted), leaving OS text/history chords untouched. `test-keyboard-shortcuts.ts` (297L) — Playwright browser check: g/d/f view switch, l layout toggle, b lens toggle, editable-guard typing-inert (input/textarea/contenteditable/.modal). Tests 5–7 (keyboard-pan) exercise the browser end of arrow-key panning: test 5 asserts ArrowRight moves the live graph's `cy.pan()` by exactly −10 on x (viewport right = content left) and Shift+ArrowDown moves `pan().y` by −50; test 6 asserts ArrowRight moves the flow SVG's inner `<g>` translate opposite the viewport and that the Shift step is exactly 5× the bare step; test 7 focuses the flow search input and asserts ArrowRight leaves the SVG translate unchanged (editable guard). `test-help-overlay.ts` (140L) — CI Playwright check: top-bar `?` button opens `HelpModal`; `?` key opens it; Escape closes; modal is view-aware (content changes per active view); editable guard suppresses `?` while typing. `test-edge-hover-data.ts` (169L) — unit test for `normalizeEdgeData`: string array passthrough, string split on `", "`, empty/undefined → `[]`, single-item string. `test-dfd-edge-hover.ts` (229L) — CI-runnable Playwright check (skip-if-dist-absent): hover a known gated `db:` edge in the proving model, assert styled tooltip appears with full column-list text, assert tooltip absent before hover and after leave, assert `data-contract` still present on edge `<g>`. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `<title>` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP5, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (318L) — CI-runnable Playwright check (skip-if-dist-absent) for the Graph search bar: dim/highlight (`search-match`/`search-dim`) classes, `n of N` count readout, Enter-to-cycle, body-toggle opt-in, survival across hover/layout-mode changes, and the no-persistence guarantee (search state never touches layout-store/URL hash/model); CP5 adds SC5 assertions against the `role="switch"` "Include descriptions" control (`page.getByRole('switch', ...)`, `aria-checked` false→true) in place of the old `.viewer-search-body-toggle` button-click. Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (337L) — CI-runnable Playwright check for the Flows search bar: cross-diagram results dropdown, sub-DFD navigation via `selectDiagramById`, in-diagram dim/highlight via `searchTokens`, body-toggle opt-in, the results display cap's "+N more" overflow line, and no-persistence; CP5 adds an SC12 block against the [`test/fixtures/flows-leveling`](../../test/fixtures/flows-leveling) fixture deep-linked to `#view=flow&dfd=Login` — asserts the `.viewer-search-bar--flow` bounding box never intersects `[data-ignatius="flow-breadcrumbs"]` and sits fully below it at full (4-chip) drill depth, plus a click+type clickability proof. Serves [`models/key-inherited`](../../models/key-inherited)'s `order-to-cash` sub-DFD tree; expected match sets are computed live via `parseFlows` + `searchFlowDiagrams` rather than hardcoded. - [`test/visual/`](../../test/visual) — 64 Playwright screenshot scripts for manual visual inspection. NOT run by `bun run test`. Includes CP1–CP26 visual test scripts. `test-cp2-dfd-edge-labels.ts` covers ELK-routed edge labels (channel-placed, length-gated, truncated `…` preview for long labels, C5/C13 assertions updated for truncated-preview behaviour). `test-dfd-edge-hover.ts` (168L) — visual screenshot capturing the styled edge hover tooltip over a dense diagram. `test-dd-spotlight-grid.ts` (5090L) — covers the DD browse lens spotlight grid; CP7 section reworked to three Shift states (CP7.a NO Shift → 0 inherited, FK solid lines render; CP7.b hold Shift → inherited dotted lines appear, FK persists; CP7.c release Shift → inherited gone, FK persists) plus CP7-TRANSITIVE Identity block (0 → >0 → 0 under Shift). `test-dd-sticky-search.ts` covers the fixed frosted search bar in the browse and read lenses. `test-deep-nesting.ts` (142L) — serves [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) and asserts the DD process list shows full-depth numbers `1.1.1.1` / `1.1.1.2`. `test-graph-inherited-lines.ts` — visual screenshot of dotted inherited identity-group edges drawn via shift+hover in GraphView; reads per-tier opacity and asserts direct > inherited > unrelated (direct=1.0, inherited=0.5, unrelated=0.2). **graph-flow-search visuals:** `test-graph-search.ts` (99L) — types "Party" into the Graph search bar; screenshots Party + PartyType highlighted (`search-match`), every other entity dimmed (`search-dim`), and the `n of N` count readout; CP5 adds a second capture after toggling to light theme, so both dark and light chrome (including the "Include descriptions" switch) get reviewed. `test-flow-search.ts` (98L) — types "Validate" into the Flows search bar; screenshots the results dropdown open (grouped under its parent diagram) while the currently-rendered diagram's own non-matching nodes render dimmed via `searchTokens`; CP5 adds a matching light-theme second capture. - [`test/fixtures/`](../../test/fixtures) — YAML fixtures and fixture model roots. All fixture model roots use the v0.11.0 folder layout (`data/`, `groups/`, `externals/`, `stores/`; no `_*` prefixes): `flows-leveling/` (servable, 4-level-deep DFD tree), `flows-model/`, `broken-flows-model/`, `broken-flow/`, `flows/`. Per-DFD `_externals`/`_stores` were collapsed to root registries (same-name collisions in `broken-flow` Shopper and `flows-leveling` User resolved to one global definition each). - [`test/notes/`](../../test/notes) — 2 markdown dev notes. @@ -49,7 +49,7 @@ No linter or formatter configured in package.json. | Language | LOC | Files | % | |----------|-----|-------|---| | TypeScript | 59431 | 265 | 70% | -| Markdown | 20517 | 298 | 24% | +| Markdown | 20525 | 298 | 24% | | CSS | 3004 | 2 | 3% | | YAML | 1340 | 14 | 1% | | Shell | 116 | 1 | 0% | @@ -248,7 +248,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde **Cross-view search (graph-flow-search):** owns per-surface search state that survives view switches because GraphView and FlowsView stay mounted-but-inactive and DictionaryView is keep-mounted via CSS. Graph search: `graphSearchTerm`/`graphSearchIncludeBody` (`useState`) plus `graphSearchCursorRef` (Enter-to-cycle cursor, reset to `-1` whenever the term or body toggle changes). `graphSearchActive = graphSearchTerm.trim() !== ''`; `graphSearchMatchIds` (`useMemo`) runs `entityMatches` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts) over `model.nodes`, sorted ascending by id; `graphSearchMatches` is `null` when inactive or a `Set` (possibly empty — an active search with zero matches still dims everything) when active, matching the contract `GraphView`'s `searchMatches` prop expects. `handleGraphSearchEnter` advances `graphSearchCursorRef` modulo the match count, wrapping, and calls `graphViewRef.current?.navigateToEntity(nextId)`. Flow search: `flowSearchTerm`/`flowSearchIncludeBody`; `flowSearchActive`; `flowSearchResults` (`useMemo`) calls `searchFlowDiagrams` from the same module; `flowSearchTokens` is the `Set` of each result's `token` (or `null`) threaded into `FlowsView`'s `searchTokens` prop. `handleFlowSearchSelect(diagramId)` and `handleFlowSearchEnter()` (opens the first result) both route through `flowsViewRef.current?.selectDiagramById` — no new navigation machinery. Neither graph nor flow search state ever touches the model, layout fingerprint, layout-store, or URL hash. `graphSearchBarRef`/`flowSearchBarRef` (`SearchBarHandle`) let `handleKeyboardSearchFocus` (wired to `useKeyboardShortcuts`'s `onSearch`) focus the active view's `SearchBar` input; the Dictionary path instead calls `dictViewRef.current?.focusSearch()` since its search input lives inside `DictionaryView`, not a `SearchBar`. The Graph `SearchBar` gets a live `matchCount`/`totalCount`; the Flows `SearchBar` always passes `matchCount={null}` / `totalCount={0}` (no "n of N" readout — the results dropdown is the readout). `bannerRef` measures the global-error banner's real rendered height via `ResizeObserver` (in a `useLayoutEffect`) and writes it into the `--search-bar-top` CSS custom property so `SearchBar` (`.viewer-search-bar`) sits below the banner instead of being occluded by it; the property is removed when the banner isn't visible, letting `styles.css`'s 12px fallback apply. - **Hooks:** - - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (158L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types. + - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (173L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set to `true` in the returned cleanup; every `setState` call inside the boot `Promise.all(...).then(...)` and the SSE `model-changed` handler's `Promise.all(...).then(...)` (and both `.catch()` handlers) is gated on `if (ignore) return` / `if (!ignore)`. Guards against React StrictMode's dev-mode mount→cleanup→mount double-invoke: without the flag, the first (stale) invocation's in-flight fetch still resolves after its own effect cleanup ran and calls `setModel`/`setFlowDiagrams` with a fresh reference, retriggering every effect keyed on `model`/`flowDiagrams` (including the flow renderer's mount effect in `FlowsView.tsx`) while the second (real) mount is still settling. - [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) — exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. Seeds from `location.hash` on mount. Accepts an `onRestoreDfd` callback for DFD deep-link restore. **Entity modal history (#6/#8):** `entity=` in the hash is the single source of truth for the modal stack. Opening an entity calls `openEntity(id)` (→ `history.pushState`; deduped when the hash already carries this same entity — an FK hop back to the same entity or a re-open does not stack a duplicate history entry); closing calls `closeEntity()` (→ `history.replaceState`). `popstate` invokes the `onEntityChange(id | null)` option to reconcile the shell — the shell opens/closes the modal in response without pushing another history entry. GraphView no longer writes `entity=`. Returns `{ view, setView, openEntity, closeEntity }`. - [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) (36L) — exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage. Calls `applyThemeCssVars` on change (also re-fires on `model` identity change, so an SSE-delivered model with the same `themeConfig` identity still reapplies CSS vars). Returns `{ themeMode, toggleTheme }`. - [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (101L) — registers exactly ONE global `keydown` listener for the unified SPA keyboard shortcuts (g/d/f/l/b/?/[`/`](../..)/Cmd-Ctrl-k/arrows). Stale-closure hazard avoided via `configRef` (latest `view`/callbacks stored in a ref, updated each render; listener reads from ref). Editable guard: returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. `KeyboardShortcutsConfig` interface carries: `view`, `onView`, `onToggleLayout`, `onToggleLens`, `onZoomIn`, `onZoomOut`, `onZoomReset`, `onHelp`, `onSearch`, `onPan` (**graph-flow-search**: `onSearch` fires on [`/`](../..) or Cmd/Ctrl+`k` — focuses the active view's search input; both resolve to the same `{type:'search'}` action in `resolveShortcut`, so the hook itself is agnostic to which key triggered it. **keyboard-pan:** `onPan(dx, dy)` fires on the `{type:'pan'}` action — arrow keys on graph/flow; the switch case calls `cfg.onPan(action.dx, action.dy)` and relies on the hook's existing `preventDefault` on every matched action to also stop page-scroll). Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported by [`src/app/App.tsx`](../../src/app/App.tsx). @@ -262,7 +262,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/logic/relationship-key.ts`](../../src/app/logic/relationship-key.ts) (21L) — exports `relationshipRowKey(edge: ModelEdge): string`. Stable, collision-free React key for relationship rows; encodes source, target, and sorted `on` FK pairs to handle dual-FK tables (e.g. `TrackingStatus_Allowed → TrackingStatus` via `from_status` and `to_status`). - [`src/app/logic/spotlight.ts`](../../src/app/logic/spotlight.ts) — pure `buildSpotlightConnections(index: ModelIndex, entityId: string): SpotlightConnection[]`. Exports `SpotlightConnection` (`{ otherId, direction: 'out'|'in'|'both', edges: SpotlightEdge[] }`) and `SpotlightEdge` (`{ direction, predicate, cardinality, identifying }`). Uses `edgesBySource` + `edgesByTarget` only (no `model.edges` scans). Invariants: self-edges excluded; all edges to the same otherId bundled into one connection (out-before-in); direction='both' when bundle contains edges from both sets; result sorted by otherId ascending; unknown entityId → []. - [`src/app/logic/flow-spotlight.ts`](../../src/app/logic/flow-spotlight.ts) — pure `buildFlowSpotlightConnections(diagrams: FlowDiagram[], activeToken: string): FlowSpotlightConnection[]`. Exports `FlowSpotlightConnection` (`{ otherCardId, direction: 'out'|'in'|'both', edges: FlowSpotlightEdge[] }`) and `FlowSpotlightEdge` (`{ direction, data: string }`). Token scheme: `activeToken` is always `"<kind>:<name>"`; entity cards pass `"db:<entityId>"`. otherCardId resolution: `db:<name>` → bare entity id; anything else → raw `"<kind>:<name>"`. Walks all diagrams + sub-DFDs recursively. Array data joined with `", "`. Result sorted by otherCardId ascending. - - [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) (159L) — pure keyboard-shortcut resolver. No DOM, no React, no Bun/Node imports; browser-safe and unit-testable. Exports `resolveShortcut(e: ShortcutKeyEvent, view: ViewName, editable: boolean): ShortcutAction | null`, `ShortcutAction` discriminated union (`{ type: 'view'; view: ViewName } | { type: 'toggleLayout' } | { type: 'toggleLens' } | { type: 'zoomIn' } | { type: 'zoomOut' } | { type: 'zoomReset' } | { type: 'help' } | { type: 'search' } | { type: 'pan'; dx: number; dy: number }`), and `ShortcutKeyEvent` interface. Keymap: g→graph, d→dict, f→flow (any view); l→toggleLayout (graph only); b→toggleLens (dict only); [`/`](../..) → search (any view — an ordinary bare key, resolved in the normal switch since, unlike `?`, it needs no Shift); `?` → help (any view; Shift+/ on most layouts); Cmd/Ctrl + `=`/`+` → zoomIn, Cmd/Ctrl + `-`/`_` → zoomOut, Cmd/Ctrl + `0` → zoomReset (routed to canvas, not the browser), Cmd/Ctrl + `k` → search (graph-flow-search CP5 — a second, always-on route to the same `{type:'search'}` action as [`/`](../..), the conventional "focus search" chord); arrow keys → `{type:'pan', dx, dy}` on `view === 'graph'` or `view === 'flow'` only (keyboard-pan — dict returns null, preserving native page scroll). Exports two pan-step constants: `PAN_STEP = 5` (bare arrow, screen px) and `PAN_STEP_FAST = 25` (Shift+arrow). An internal `PAN_DIRECTION` map turns `arrowleft`/`arrowright`/`arrowup`/`arrowdown` into a unit `{dx, dy}` (the viewport-movement direction) multiplied by the step; the pan branch is also gated off `!e.ctrlKey && !e.metaKey && !e.altKey` so OS text/history chords (Cmd+Left, etc.) fall through. Guard order: (0) modifier-gated zoom+search resolved BEFORE both guards, gated on ctrl/meta only (alt/shift held → null; bare `=`/`-`/`0`/`k` with no modifier → null, unmapped); (1) editable guard; (2) `?` resolved AFTER editable guard but BEFORE the modifier guard — because `?` inherently requires Shift, guard 2 would otherwise swallow it; gated off ctrl/meta/alt; (2b) arrow-key pan resolved in the same post-editable/pre-modifier slot as `?`, immediately after the `?` check — because Shift is the pan step multiplier here, not a suppressor; gated to graph/flow views and off ctrl/meta/alt; (3) modifier guard; (4) bare-key switch ([`/`](../..) resolves here). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. Cmd/Ctrl+`k` resolves even when `editable === true` (unlike bare [`/`](../..)) — the hook's `preventDefault` on every matched action also stops the browser's own "focus search" behavior. Imported by `useKeyboardShortcuts` only. + - [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) (159L) — pure keyboard-shortcut resolver. No DOM, no React, no Bun/Node imports; browser-safe and unit-testable. Exports `resolveShortcut(e: ShortcutKeyEvent, view: ViewName, editable: boolean): ShortcutAction | null`, `ShortcutAction` discriminated union (`{ type: 'view'; view: ViewName } | { type: 'toggleLayout' } | { type: 'toggleLens' } | { type: 'zoomIn' } | { type: 'zoomOut' } | { type: 'zoomReset' } | { type: 'help' } | { type: 'search' } | { type: 'pan'; dx: number; dy: number }`), and `ShortcutKeyEvent` interface. Keymap: g→graph, d→dict, f→flow (any view); l→toggleLayout (graph only); b→toggleLens (dict only); [`/`](../..) → search (any view — an ordinary bare key, resolved in the normal switch since, unlike `?`, it needs no Shift); `?` → help (any view; Shift+/ on most layouts); Cmd/Ctrl + `=`/`+` → zoomIn, Cmd/Ctrl + `-`/`_` → zoomOut, Cmd/Ctrl + `0` → zoomReset (routed to canvas, not the browser), Cmd/Ctrl + `k` → search (graph-flow-search CP5 — a second, always-on route to the same `{type:'search'}` action as [`/`](../..), the conventional "focus search" chord); arrow keys → `{type:'pan', dx, dy}` on `view === 'graph'` or `view === 'flow'` only (keyboard-pan — dict returns null, preserving native page scroll). Exports two pan-step constants: `PAN_STEP = 10` (bare arrow, screen px) and `PAN_STEP_FAST = 50` (Shift+arrow). An internal `PAN_DIRECTION` map turns `arrowleft`/`arrowright`/`arrowup`/`arrowdown` into a unit `{dx, dy}` (the viewport-movement direction) multiplied by the step; the pan branch is also gated off `!e.ctrlKey && !e.metaKey && !e.altKey` so OS text/history chords (Cmd+Left, etc.) fall through. Guard order: (0) modifier-gated zoom+search resolved BEFORE both guards, gated on ctrl/meta only (alt/shift held → null; bare `=`/`-`/`0`/`k` with no modifier → null, unmapped); (1) editable guard; (2) `?` resolved AFTER editable guard but BEFORE the modifier guard — because `?` inherently requires Shift, guard 2 would otherwise swallow it; gated off ctrl/meta/alt; (2b) arrow-key pan resolved in the same post-editable/pre-modifier slot as `?`, immediately after the `?` check — because Shift is the pan step multiplier here, not a suppressor; gated to graph/flow views and off ctrl/meta/alt; (3) modifier guard; (4) bare-key switch ([`/`](../..) resolves here). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. Cmd/Ctrl+`k` resolves even when `editable === true` (unlike bare [`/`](../..)) — the hook's `preventDefault` on every matched action also stops the browser's own "focus search" behavior. Imported by `useKeyboardShortcuts` only. - [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) (100L) — pure geometry helper for separating overlapping spotlight overlay lines (#2). No DOM, no React, no Bun/Node imports. Exports `separateSpotlightLines(base: BaseAnchor, directions: readonly LineDirection[]): SpotlightLineSpec[]`, `BaseAnchor`, `SpotlightLineSpec`, `LineDirection`, `SPOTLIGHT_LINE_GAP = 14`. Algorithm: K=1 → base line unchanged; K>1 → symmetric perpendicular offset of `(i - (K-1)/2) × GAP` per line — horizontal anchor spreads y, vertical anchor spreads x, centre of mass stays on base anchor. Each spec carries exactly ONE direction so each rendered `<path>` has a single arrowhead. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx). - [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) (220L) — pure inherited-connection logic for the DD browse lens and DG graph (#9, key-inheritance-lineage). No DOM, no React, no Bun/Node imports. Exports `buildInheritedConnections(index: ModelIndex, entityId: string): InheritedConnection[]`, `InheritedConnection = { otherId, direction: 'out'|'in'|'both', via: string }`, `INHERITED_IDENTITY = 'identity'`. **`direction` is always `'out'`** — the lineage line points FROM the active card OUTWARD to the lineage member; the union retains `'in'`/`'both'` for shape-compatibility with `SpotlightConnection` but no returned entry ever carries those values. `SpotlightOverlay` renders an `'out'` connection as ONE line with a single arrowhead at the far (member) end. **Key-edge connected-component model:** lineage follows ONLY key edges — an edge where `Object.keys(edge.on)` (FK columns on the child/source side) are ALL ⊆ `pkByNode.get(edge.source)` (child's PK), a non-empty subset test implementing IDEF1X identifying semantics; empirically equivalent to `edge.identifying === true` on key-inherited models. Catches identifying-1:many (FK a proper subset of PK, e.g. `SalesInvoice→Party`) in addition to identifying-1:1 (FK = full PK). Never follows a secondary FK. Identity group = transitive connected component over key edges in BOTH directions, cycle-safe via visited Set; subtype-cluster membership falls out naturally (member→basetype is a key edge). For every group member M ≠ active: emit M as identity link (`via = INHERITED_IDENTITY`) and emit M's direct FK connections with otherId outside the group (via = M id), both de-duped against the active's own direct connections. Does NOT call `buildSpotlightConnections` for de-dup (removed). Bundles to one entry per otherId (first-seen wins); result sorted ascending by otherId; active in no group → []. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx). @@ -314,7 +314,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/views/graph/navigator.ts`](../../src/app/views/graph/navigator.ts) — `mountNavigator` / `teardownNavigator` / `NavigatorInstance` — cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` BEFORE `nav.destroy()`: `destroy()` removes overlay/window listeners but leaks the cy `'resize'` + onRender listeners, which fire `Navigator.resize→boundingBox` on a destroyed core (`isHeadless` of null); removing them while cy is alive fixes that crash. `test-navigator-teardown.ts` pins this call-order contract. - [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts) (243L) — `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. **3-tier focus opacity:** `.faded` selector sets `opacity: 0.2` (unrelated tier, changed from 0.3); `.inherited-dim` selector sets `opacity: 0.5` (inherited/ancestral tier); `edge.inherited` opacity changed from 0.85 to 0.5 so dotted rays match their `.inherited-dim` target nodes. Includes `edge.inherited` selector: dotted line, `SPOTLIGHT_LINE_INHERITED[themeMode]` color from [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), thinner than direct edges. Length-graded de-emphasis (data set by `organic-layout.ts`'s `gradeEdgeSpans` after layout/drag): `edge[span = "mid"]` → `opacity: 0.5`; `edge[span = "far"]` → `opacity: 0.22, width: 1, z-index: -1` — placed after the identifying/dashed rules so the span grading wins the cascade; long-haul cross-domain wires de-emphasize at overview but stay hoverable/selectable and regain full presence as you zoom in or follow them. **Graph search (graph-flow-search CP2):** `SEARCH_MATCH_BORDER` (`Record<ThemeMode, string>` — dark `#fde047` / light `#ca8a04`), a distinct gold/yellow chosen so it never reads as the amber `spotlight-line-out` lineage color or a group's border color, mirroring the Dictionary view's `--dd-search-highlight` yellow so "search" reads as one visual language across surfaces. `.search-dim` sets `opacity: 0.2`; `node.search-match` sets `border-width: 3` / `border-color: SEARCH_MATCH_BORDER[mode]`. Both rules are pushed LAST in the stylesheet array so they win the cascade over the span-graded edge opacity and the per-group border-color rules. - [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (1452L) — `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(): void; focusSearch(): void }` (`toggleLens` wraps the existing `switchLens`; `focusSearch` — added for **graph-flow-search** — focuses `searchInputRef`, the dict search `<input>`, in response to the shell's [`/`](../..) shortcut). Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search (CP9), `beforeprint`/`afterprint` (CP10), DD sidebar process nesting with `compareDottedProcesses` (CP24). Its own search (`nodeMatchesSearch`/`processMatchesSearch`/`externalMatchesSearch`/`storeMatchesSearch` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts), always matching columns + body) is unchanged by graph-flow-search — the new title-first, body-opt-in matchers (`entityMatches`, `flowProcessMatches`, etc.) are exclusive to the Graph and Flows `SearchBar` surfaces. `kbd-hint` B badge present on the lens toggle control. **Browse lens (dd-spotlight-grid):** lens toggle (`'read' | 'browse'`) persisted to `localStorage`. Browse lens renders entity groups + Processes / External Entities / Data Stores sections. Spotlight state: `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId` (focus mode). FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`. `SpotlightOverlay` mounted when `activeId !== null`. Fixed frosted search bar (`.dict-search-bar`); search and focus are mutually exclusive. **DD inherited lines are SHIFT-GATED:** a `shiftHeld` boolean state is driven by a document-level `keydown`/`keyup` pair on `Shift` + a `window` `blur` reset (registered in a dependency-free `useEffect`); the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId` (deps include `shiftHeld`), and the inherited-id foldings into `spotlitIds`/`focusSet` are wrapped with `if (shiftHeld)` — so without Shift no inherited card is lit, extra-focused, or surfaced as an off-screen chip. FK (solid) and flow (dashed) spotlight lines are unchanged (show on plain hover/pin). `SpotlightOverlay` is unchanged. - - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (873L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. **Keyboard pan:** `panBy(dx, dy)` delegates to `flowPanByRef.current?.(dx, dy)` — a ref populated by the `onRegisterZoomControl` callback's `panBy` field (registered from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)), the same adapter-ref pattern already used for `flowZoomToRef`/`flowResetFitRef`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet<string> | null` prop (base tokens — `proc:`/`ext:`/`<kind>:<name>`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token. + - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (900L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. **Keyboard pan:** `panBy(dx, dy)` delegates to `flowPanByRef.current?.(dx, dy)` — a ref populated by the `onRegisterZoomControl` callback's `panBy` field (registered from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)), the same adapter-ref pattern already used for `flowZoomToRef`/`flowResetFitRef`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet<string> | null` prop (base tokens — `proc:`/`ext:`/`<kind>:<name>`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token. **`disposed` teardown guard (race fix):** `initFlowGraphCore` declares `let disposed = false` alongside its existing `renderGen` counter. The async `renderDiagram(diagram)` checks `if (disposed) return` on entry and `if (renderGen !== myGen || disposed) return` after every `await computeElkLayout(diagram)` resolution (both the success path and the `catch` block). The teardown function `initFlowGraphCore` returns sets `disposed = true` as its first statement, before unregistering chrome callbacks or unmounting `svgRoot`. Guards against an orphaned `renderDiagram` continuation resuming after its owning `initFlowGraphCore` instance was torn down while still awaiting ELK (React StrictMode's dev-mode mount→cleanup→mount, or navigating away and back before ELK finishes) — `svgRoot` was still `null` at teardown time so there was nothing to unmount, and without this flag the orphaned call would resume, find the shared `container` DOM node still connected (`App.tsx` keeps every view's container mounted across view switches), and inject a second live React root into it, racing the real instance for the same container. - [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) (204L) — `LegendModal` component. - **Globals:** [`src/app/globals.d.ts`](../../src/app/globals.d.ts) (46L) — `window.__MODEL__`, `window.__THEME_MODE__`, `window.__IGNATIUS_MODE__` ('live'|'static'), `window.__LAYOUT_KEY__`, `window.__FLOW_MODEL__`, `window.__FLOW_LAYOUT_KEYS__`, `window.__IGNATIUS_CY__`, `window.__IGNATIUS_CY_GEN__`, `window.__IGNATIUS_FLOW_READY__`, `window.__IGNATIUS_FLOW_GEN__`, `window.__IGNATIUS_ACTIVE_FLOW_DFD__`, `window.__IGNATIUS_PERF__`. @@ -422,11 +422,11 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (69L) — implementation contract for the arbitrary DFD nesting depth fix: success criteria pin `renumberDiagram` recursion + full-prefix invariant, `test-deep-nesting.ts` assertions, `flows-leveling` fixture servability, skill F8 recursion update, and `bun run test` + `build:cli` green gate. [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (155L) — implementation contract for the DFD viewer overhaul. Success criteria C1–C18 including: C4 (5-band ordering invariant), C6 (context diagram: 1 process, external-only flows), C7 (L1 overview: all leaf-activity processes + stores with degree ≥ 2, no degree=1 stores), C12 (dotted numbering), C13 (inline labels: length gate ≤ 22 chars), C14 (external role-split: at most 2 copies per external), C15 (ELK-routed edge geometry from `sections`), C16 (single-row bands), C17 (node centers in positions), C18 (ELK runs in browser — `terminateQuietly` guard). Checkpoints 4a–4e. [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) (83L) — implementation contract for DFD edge-hover data reveal (issue #14). CP1–CP4 build order. Success criteria: `normalizeEdgeData` pure helper; `dataLines: string[]` on `FlowElementData` edge variant; `position:fixed` HTML hover tooltip listing full data items under `source → target` header; long labels render truncated `…` preview inline (not suppressed); native `<title>` removed; `data-contract`/`data-contract-type` unchanged; dim/highlight focus preserved; CI Playwright check + visual screenshot; `bun run test` exit 0. -[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (181L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). 2026-07-14 addendum (CP5): Cmd/Ctrl+`k` joins the modifier-gated slot (section renamed "Modifier-gated zoom" → "Modifier-gated zoom + search") as a second, always-on `{type:'search'}` route resolved before the editable guard — focuses search even while typing elsewhere, unlike bare [`/`](../..); gated on ctrl/meta only, bare `k` unmapped; the success-criteria checklist gained a line pinning `test-shortcuts.ts`'s Cmd/Ctrl+k coverage (editable-bypass, alt/shift → null, bare `k` → null). 2026-07-14 addendum (arrow-key canvas panning): `resolveShortcut` gains `{type:'pan', dx, dy}` for the arrow keys on graph/flow — `PAN_STEP` (5px) bare, `PAN_STEP_FAST` (25px) with Shift, resolved in the same post-editable/pre-modifier slot as `?` since Shift is a step multiplier not a suppressor; dict/editable/ctrl-meta-alt → null. Both view handles gain `panBy(dx, dy)`; success criteria gained a line pinning `test-shortcuts.ts` T25–T29 and `test-keyboard-shortcuts.ts` tests 5–7. +[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (189L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). 2026-07-14 addendum (CP5): Cmd/Ctrl+`k` joins the modifier-gated slot (section renamed "Modifier-gated zoom" → "Modifier-gated zoom + search") as a second, always-on `{type:'search'}` route resolved before the editable guard — focuses search even while typing elsewhere, unlike bare [`/`](../..); gated on ctrl/meta only, bare `k` unmapped; the success-criteria checklist gained a line pinning `test-shortcuts.ts`'s Cmd/Ctrl+k coverage (editable-bypass, alt/shift → null, bare `k` → null). 2026-07-14 addendum (arrow-key canvas panning): `resolveShortcut` gains `{type:'pan', dx, dy}` for the arrow keys on graph/flow — resolved in the same post-editable/pre-modifier slot as `?` since Shift is a step multiplier not a suppressor; dict/editable/ctrl-meta-alt → null. Both view handles gain `panBy(dx, dy)`; success criteria gained a line pinning `test-shortcuts.ts` T25–T29 and `test-keyboard-shortcuts.ts` tests 5–7. 2026-07-14 addendum (pan step values raised): `PAN_STEP`/`PAN_STEP_FAST` changed from 5/25 to `PAN_STEP` (10px) bare, `PAN_STEP_FAST` (50px) with Shift — ratio unchanged at 5×; the keymap table, the affected success criterion, and the pan-related doc/test wording were updated to match; the original 5px/25px addendum above is left as-is as the historical record of that commit. [`docs/spec/help-overlay.md`](../spec/help-overlay.md) (64L) — implementation contract for the help overlay: `HelpModal` component contract (view prop, Modal primitive, three view branches + shared Keyboard section), `{type:'help'}` `ShortcutAction` resolution order (after editable guard, before modifier guard, gated off ctrl/meta/alt), `showHelp` state in shell, `.help-toggle` top-bar button placement, `.help-*` CSS classes, test-plan (`test-help-overlay.ts`: `?` button + key open, Escape closes, view-aware content, editable guard). 2026-07-14 addendum: Graph and Flows "How to explore" sections gained a Search row, and every view's Keyboard section now lists [`/`](../..) (focus that view's search input). 2026-07-14 addendum: the Graph and Flows Keyboard sections gained a `← ↑ ↓ →` → "Scroll the canvas; hold Shift to scroll faster" row, next to the existing zoom-chord row. [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) (372L) — implementation contract for key-inheritance-lineage: `buildInheritedConnections` key-edge connected-component algorithm (FK ⊆ PK, both directions, cycle-safe, identifying-1:many covered), DG ephemeral `edge.inherited` lifecycle (SHIFT+HOVER trigger — not click/select), DD `SpotlightOverlay` dotted inherited lines SHIFT-GATED in `DictionaryView`, `SPOTLIGHT_LINE_INHERITED` constant placement, test-plan (`test-spotlight-inherited.ts`, `test-inherited-edges-no-leak.ts`, `test-graph-inherited-edges.ts`, `test-navigator-teardown.ts`). Design doc [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) also rewritten to the key-edge rule. [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) (199L) — implementation contract for Graph/Flows search: SC1–SC12 (Graph `search-match`/`search-dim` classes + `n of N` count + Enter-cycle via `navigateToEntity`, resilience across hover/lineage/relayout/SSE-refresh, per-bar toggle-switch (`role="switch"`, "Include descriptions" label) title-vs-body matching per kind, Flows cross-diagram matcher over non-synthetic diagrams + results dropdown + `selectDiagramById` navigation including sub-DFDs, base-token role-split-suffix matching, two routes to `{type:'search'}` — [`/`](../..) after the editable guard and Cmd/Ctrl+`k` before it, like the zoom chords — wired through `DictionaryViewHandle.focusSearch()`, no persistence/layout/network side effects, bundle-only parity pinned as SC11, SC12 pins flow-view chrome non-collision between the search bar and the DFD breadcrumb chips/nav card), CP1–CP5 build order (pure matchers, Graph wiring, Flows wiring, [`/`](../..) shortcut + docs, CP5 switch-control/Cmd-Ctrl-K/chrome-clearance/visual-tightening pass). Change log: checkpoint-table column-schema correction, CP4 scope extended to cover the keymap spec surfaces ([`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md), [`docs/spec/help-overlay.md`](../spec/help-overlay.md)), CP5 (switch control + visual tightening + Cmd/Ctrl+K + SC12 chrome non-collision) added from live user feedback mid-build. Implementation log: shipped 2026-07-14 across 5 /autopilot iterations (10 commits total including the CP5 iteration), closing issue #18. -[`docs/guides/commands.md`](../guides/commands.md) (156L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) or Cmd/Ctrl+`K` chord (focus the active view's search bar — Graph, Dictionary, Flows; CP5 added Cmd/Ctrl+K as a second route that works even while a text field is already focused elsewhere) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. Also documents `← → ↑ ↓` (scroll the canvas 5px on Graph/Flows, 25px with Shift) as a third exception to the modifier-suppression rule — Shift selects the faster stride instead of suppressing the shortcut, arrows keep their normal meaning while typing in a field, and Cmd/Ctrl/Alt+arrow chords are left to the OS/browser. +[`docs/guides/commands.md`](../guides/commands.md) (156L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) or Cmd/Ctrl+`K` chord (focus the active view's search bar — Graph, Dictionary, Flows; CP5 added Cmd/Ctrl+K as a second route that works even while a text field is already focused elsewhere) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. Also documents `← → ↑ ↓` (scroll the canvas 10px on Graph/Flows, 50px with Shift) as a third exception to the modifier-suppression rule — Shift selects the faster stride instead of suppressing the shortcut, arrows keep their normal meaning while typing in a field, and Cmd/Ctrl/Alt+arrow chords are left to the OS/browser. [`docs/glossary.md`](../glossary.md) (52L) — canonical vocabulary: DG, DD, DFD, DE, DS, EE, Process, Data Flow. [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (118L) — research notes on SSADM DFD rules. [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) (129L) — research notes on DFD layout engines and Yourdon leveling used in specifying the dfd-overhaul approach; primary source for the ELK algorithm selection and band-partitioning option names. diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 3aede24..292105d 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -42,7 +42,7 @@ │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) │ ├── guides/ (10) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (fb4932a, 156L, 9019ch, 9067B) +│ │ ├── commands.md (617de4d, 156L, 9020ch, 9068B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) │ │ ├── flows.md (ec7f7cd, 148L, 8533ch, 8577B) │ │ ├── folder-format.md (ec20419, 159L, 5499ch, 5517B) @@ -76,7 +76,7 @@ │ │ ├── help-overlay.md (e4faa50, 64L, 4840ch, 4862B) │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) -│ │ ├── keyboard-nav-shortcuts.md (e3b3297, 181L, 16116ch, 16270B) +│ │ ├── keyboard-nav-shortcuts.md (0c00b77, 189L, 16705ch, 16864B) │ │ ├── noorm-flow-discovery.md (4b7f5a8, 83L, 10554ch, 10666B) │ │ ├── noorm-modeling-skill.md (947190e, 204L, 22457ch, 22582B) │ │ ├── process-flows.md (e5e6472, 682L, 83302ch, 83868B) @@ -223,7 +223,7 @@ │ │ ├── hooks/ (4) │ │ │ ├── useHashRoute.ts (c50964c, 121L, 5142ch, 5150B) │ │ │ ├── useKeyboardShortcuts.ts (226e7b4, 101L, 4029ch, 4049B) -│ │ │ ├── useModelData.ts (4877d58, 158L, 5483ch, 5609B) +│ │ │ ├── useModelData.ts (b582ec4, 173L, 6319ch, 6451B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) │ │ ├── logic/ (11) │ │ │ ├── color.ts (8006242, 39L, 1796ch, 1796B) @@ -233,7 +233,7 @@ │ │ │ ├── flow-spotlight.ts (0725869, 136L, 5096ch, 5114B) │ │ │ ├── relationship-key.ts (21bbb6e, 21L, 950ch, 956B) │ │ │ ├── search.ts (3c76b8b, 272L, 10130ch, 10138B) -│ │ │ ├── shortcuts.ts (9aec123, 159L, 6892ch, 6940B) +│ │ │ ├── shortcuts.ts (b246af3, 159L, 6893ch, 6941B) │ │ │ ├── spotlight-inherited.ts (8fa6183, 220L, 9495ch, 9873B) │ │ │ ├── spotlight-lines.ts (0138798, 100L, 3696ch, 3724B) │ │ │ └── spotlight.ts (d4aec20, 101L, 3534ch, 3540B) @@ -338,7 +338,7 @@ │ │ ├── test-hash-router.ts (46f7614, 219L, 6942ch, 6998B) │ │ ├── test-help-overlay.ts (8d52edc, 140L, 5469ch, 5697B) │ │ ├── test-inherited-edges-no-leak.ts (0c8a942, 120L, 5250ch, 5348B) -│ │ ├── test-keyboard-shortcuts.ts (b03fd27, 297L, 12138ch, 12169B) +│ │ ├── test-keyboard-shortcuts.ts (28cb588, 297L, 12141ch, 12172B) │ │ ├── test-layout-fingerprint.ts (92d8eea, 255L, 8386ch, 8464B) │ │ ├── test-layout-key-injection.ts (1ff0306, 104L, 4296ch, 4300B) │ │ ├── test-layout-store.ts (9c4a003, 157L, 6200ch, 6236B) @@ -356,7 +356,7 @@ │ │ ├── test-relationship-row-key.ts (4642966, 81L, 4077ch, 4097B) │ │ ├── test-semantic-colors.ts (60b1f25, 71L, 2206ch, 2206B) │ │ ├── test-serve-port.ts (6e3e5fb, 52L, 1928ch, 2192B) -│ │ ├── test-shortcuts.ts (40771b0, 506L, 23280ch, 23526B) +│ │ ├── test-shortcuts.ts (1edcc27, 506L, 23281ch, 23527B) │ │ ├── test-spotlight-connections.ts (0806e1c, 282L, 10879ch, 10931B) │ │ ├── test-spotlight-inherited.ts (8f7b2c4, 388L, 18139ch, 18383B) │ │ ├── test-spotlight-lines.ts (e0cb78a, 207L, 9741ch, 9775B) @@ -511,8 +511,8 @@ ## Languages -- TypeScript: 59431 LOC (70%), 265 files (45%) -- Markdown: 20517 LOC (24%), 298 files (50%) +- TypeScript: 59473 LOC (70%), 265 files (45%) +- Markdown: 20525 LOC (24%), 298 files (50%) - CSS: 3004 LOC (3%), 2 files (0%) - YAML: 1340 LOC (1%), 14 files (2%) - Shell: 116 LOC (0%), 1 file (0%)