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..db1aa7f 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 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.
+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..7ac5027 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` (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 })`;
+`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 (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
@@ -152,3 +173,17 @@ 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.
+
+### 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/docs/wiki/index.md b/docs/wiki/index.md
index c6aadd1..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
---
repo
-f4d2d14b4db849f2c97d39176536d5f5c80d100d
+292105de474c67adcf473c6aed2d73ce14536dec1
# 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:`/`:` 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/.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 `` 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 ``. `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:`/`:` 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 | 20525 | 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:--src` in band 0 aggregates all source-role partners; sink copy `ext:--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` matching `ElkLayoutResult.positions`.
- `elkEdgeRoutes?: Record>` — 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 `` 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 `` 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 `
` per item) under a ` → ` 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 `` 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 | 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,11 +239,11 @@ 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.
@@ -251,7 +251,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde
- [`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) (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 `":"`; entity cards pass `"db:"`. otherCardId resolution: `db:` → bare entity id; anything else → raw `":"`. 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 = 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 `` 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`) 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 `` (the same debounce/echo pattern `DictionaryView`'s own search box uses), a `.viewer-search-switch` toggle (`role="switch"`, `aria-checked={includeBody}`, wrapped in a `