diff --git a/CLAUDE.md b/CLAUDE.md
index a24806c..0e62ad0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -152,8 +152,9 @@ 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; pure resolver + global hook; editable/modifier guards) | 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; 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 | — |
| 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 | — | — |
| Business-narrative body + existence/cascade rules | markdown-driven-erd | noorm-modeling-skill | modeling-skill | entity-flow E9, templates (body sections) |
| Entity body wiki-links `[[Entity]]` | wiki-entity-links | wiki-entity-links, schema-lint-and-error-ux | folder-format | entity-flow E9 (body authoring) |
diff --git a/docs/design/graph-flow-search.md b/docs/design/graph-flow-search.md
new file mode 100644
index 0000000..0b626cb
--- /dev/null
+++ b/docs/design/graph-flow-search.md
@@ -0,0 +1,84 @@
+# Graph and Flow search
+
+
+## Problem
+
+
+The Graph view (DG) and the Flows view (DFD) have no way to find a node by name. On a model with hundreds of entities the graph is a visual scan; in flows it is worse — only one diagram renders at a time, so a process buried in a sub-DFD is invisible until you drill into the right diagram by luck. The Dictionary view already has search-as-you-type; the other two views have nothing. Issue #18 tracks the graph half ("Searchable highlight of matching entities in the Graph view"); this design covers both views.
+
+
+## Goals
+
+
+- A search bar on the Graph view: type a term, non-matching entities dim, matching entities stay lit with a visible highlight, a count readout shows how many matched, Enter pans to each match in turn.
+- A search bar on the Flows view: the term is matched across **all** diagrams including sub-DFDs; a results list shows every matching process, external, store, and diagram title with the diagram it lives in; clicking a result navigates to that diagram, where non-matches render dimmed.
+- Matching is by **title** by default — entity id; process id, label, and dotted number; external id and label; store name and display name; diagram id and title. Ids are included because models are authored as files: users know entities and flows by slug as much as by display label. A per-bar **toggle switch labeled "Include descriptions"** opts into also matching the markdown body text — a switch with a descriptive label, not a terse button, because "Body" alone doesn't tell a user what it does.
+- `/` focuses the active view's search input (all three views — the Dictionary's existing bar included).
+- Everything is client-side: the feature works identically in `ignatius serve` and the static `export` HTML.
+
+
+## Non-goals
+
+
+- The Dictionary view's search behavior is unchanged (it continues to always match columns and body).
+- No column, type, or predicate matching in the Graph search — title and body only. Columns can be added later if title+body proves insufficient.
+- No fuzzy matching. Case-insensitive substring, same as the Dictionary.
+- No search-term persistence (no URL hash param, no localStorage).
+- Search never removes nodes from the canvas, never mutates layout or saved positions, and never auto-zooms the DFD to a node.
+
+
+## Approach
+
+
+**Dim, don't filter.** Matching nodes keep full opacity plus a highlight; everything else drops to low opacity. Removing non-matches from the canvas would re-layout the graph and destroy the user's spatial memory — the whole point of search-as-highlight is that matches pop *in place*. This mirrors the graph's existing focus-tier dimming and the DFD's hover dim, so the visual language is already established.
+
+**Pure matchers, view-specific wiring.** The match logic lives in `src/app/logic/search.ts` next to the existing Dictionary matchers — new functions that take an `includeBody` flag, leaving the always-match-everything Dictionary helpers untouched. The shell (`App.tsx`) owns the search state per view (mirroring `dictSearchText`), computes the match set with the pure functions, and hands it to each view through its existing channel: a prop into `GraphView` (applied as cytoscape classes) and a prop threaded through `FlowsView` into `FlowDiagramSvg` (applied as node/edge opacity).
+
+**One shared bar component.** A `SearchBar` UI component (input, body toggle, count, results slot) rendered by the shell for the graph and flow views — the same composition pattern as `ZoomControl`. It reuses the Dictionary bar's visual styling so all three views read as one system.
+
+**Distinct graph classes.** The graph search uses its own `search-match` / `search-dim` cytoscape classes rather than reusing `.faded`. The hover focus tiers add and clear `.faded`/`.inherited-dim` aggressively (`clearFocusTiers` strips them on every mouseout); piggybacking on those classes would make hover permanently erase the search state. Separate classes make hover and search compose: hover tiers win transiently while the pointer is on a node, and the search dim is still there when the hover clears.
+
+**Flow search is cross-diagram.** The DFD surface shows one diagram at a time, so in-diagram dimming alone cannot answer "where is Validate Payment?". The pure flow matcher walks every non-synthetic diagram recursively (the synthesized context/Level-1 diagrams are excluded, as the Dictionary sidebar already does — their nodes are copies of leaf-diagram nodes and would duplicate every result). Results are surfaced as a dropdown under the bar; a click routes through the existing `selectDiagramById`, which already reconstructs the full breadcrumb path into any sub-DFD.
+
+
+## Per-view behavior
+
+
+### Graph (DG)
+
+- Term entered (debounced, like the Dictionary's 200 ms) → shell computes matched entity ids → `GraphView` adds `search-dim` to non-matching nodes and `search-match` to matches. Edges stay lit only when **both** endpoints match; every other edge dims.
+- Count readout in the bar: `n of N`.
+- Enter cycles through matches in id order via the existing `navigateToEntity` (center + select). No new navigation machinery.
+- Hover, shift-lineage, background taps behave as today; they must not permanently clear search dimming. Clearing the term removes both classes everywhere.
+- Search classes are ephemeral view state: they never enter the model, the layout fingerprint, saved positions, or the static export, and they are re-applied after SSE model refreshes and layout-mode switches.
+
+### Flows (DFD)
+
+- Term entered → shell computes matches across all non-synthetic diagrams: processes (id, label, dotted number), externals (id, label), stores (name, display name), and diagram titles. Body toggle adds each node's markdown body.
+- Dropdown under the bar lists results grouped by diagram: kind marker, label, dotted number for processes, diagram title. Clicking a row calls `selectDiagramById(diagramId)`; a diagram-title row navigates to that diagram. Enter opens the first row — the keyboard path to the top result. The list is capped for display with a "+N more" overflow line.
+- In the rendered diagram, nodes whose token is in the match set keep full opacity; others render at the existing `DIM_OPACITY`. Edges stay lit when **either** endpoint matches (a matched process's data flows are the information). Role-split layout copies (`--src`/`--snk`, `--read`/`--write` suffixes) match by their base token.
+- Hover dim wins while hovering (unchanged behavior); releasing hover restores the search dim.
+- The bar shares the top edge with the DFD breadcrumb chips and nav card; it must never overlap them, at any breadcrumb depth — the flow surface's chrome layout accounts for the bar the same way the graph surface accounts for the error banner.
+
+### Keyboard
+
+- `/` resolves to a new `{ type: 'search' }` shortcut action — after the editable guard (typing `/` in any input inserts the character), gated off ctrl/meta/alt. The shell focuses the active view's search input; for the Dictionary this reaches the existing bar through a new `focusSearch()` on `DictionaryViewHandle`.
+- **Cmd/Ctrl+K** resolves to the same `{ type: 'search' }` action on all three views — resolved before the editable guard like the zoom chords, so it focuses the search bar even while typing elsewhere. This is the industry-standard search chord; `/` remains as the lightweight alternative.
+- Escape in a search input clears the term and blurs.
+
+
+## Alternatives considered
+
+
+- **Filter (remove) non-matching nodes** — rejected: re-layout on every keystroke, destroys spatial memory, fights the position-persistence feature.
+- **Reusing `.faded` for graph search dim** — rejected: `clearFocusTiers` strips `.faded` on every hover exit, so search state would be erased by incidental mouse movement.
+- **One global search across views (command-palette style)** — rejected for now: each view has view-specific result semantics (pan vs navigate-and-dim), and the per-view bar matches the Dictionary precedent. A palette can compose on top later.
+- **Flow search scoped to the visible diagram only** — rejected: does not solve "which diagram is it in?", which is the actual pain.
+
+
+## References
+
+
+- Issue #18 — Searchable highlight of matching entities in the Graph view (the DG half of this design).
+- `docs/design/viewer-ux-polish.md` — deferred item #7 (graph search highlight) points at issue #18; this design supersedes that deferral.
+- `docs/design/dd-spotlight-grid.md` — the Dictionary search bar this bar's styling mirrors.
diff --git a/docs/guides/commands.md b/docs/guides/commands.md
index cd5ed75..dc6fdb2 100644
--- a/docs/guides/commands.md
+++ b/docs/guides/commands.md
@@ -122,9 +122,10 @@ The app responds to single-key shortcuts while no text field is focused and no m
| `f` | Switch to the Data Flows |
| `l` | Toggle graph layout (organic ↔ hierarchical) — Graph view |
| `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 |
-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).
+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.
### Help overlay
diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md
new file mode 100644
index 0000000..73bc92c
--- /dev/null
+++ b/docs/spec/graph-flow-search.md
@@ -0,0 +1,199 @@
+# Spec: Graph and Flow search
+
+
+## Goal
+
+
+Search bars on the Graph (DG) and Flows (DFD) views. Matching is by title by default; a per-bar toggle opts into also matching markdown body text. Graph: matches highlight in place, non-matches dim, Enter pans through matches. Flows: matches are found across all diagrams including sub-DFDs, listed in a results dropdown that navigates to the owning diagram, where non-matches render dimmed. `/` focuses the active view's search input on all three views. Closes issue #18 (the graph half).
+
+
+## Approach
+
+
+Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph-flow-search.md`.
+
+
+## Non-goals
+
+
+- Dictionary view search behavior is unchanged (still always matches columns and body). Its only change is a `focusSearch()` handle method for `/`.
+- No column/type/predicate matching in Graph search.
+- No fuzzy matching — case-insensitive substring on the trimmed term.
+- No persistence of the search term (no hash param, no localStorage).
+- No node removal, no layout mutation, no saved-position writes, no DFD auto-zoom/pan to a matched node.
+- No server or generator changes (bundle-only parity is pinned as SC11).
+
+
+## Success criteria
+
+
+- SC1 — Graph: with a term entered, non-matching nodes carry cytoscape class `search-dim`, matching nodes carry `search-match` and full opacity; an edge stays undimmed only when both endpoints match. Clearing the term removes both classes from every element.
+- SC2 — Graph: the bar shows a `n of N` count readout that tracks the match set.
+- SC3 — Graph: Enter cycles matches in ascending id order (wrapping), each press centering + selecting the next match via the existing `navigateToEntity`.
+- SC4 — Graph: hover dim, shift-lineage, background tap, layout-mode toggle, and an SSE model refresh none of them permanently clear active search dimming; after each, the search classes are present again without retyping.
+- SC5 — Both bars: with the description toggle off, a term that appears only in a node's markdown body does not match; toggling it on makes it match. The control is a toggle switch (`role="switch"`, `aria-checked`, visible label "Include descriptions"), not a bare button. Title fields per kind: entity `id`; process `id`/`label`/`dottedNumber`; external `id`/`label`; store `name`/`displayName`; diagram `id`/`title`. Body fields: entity `bodyHtml` stripped of tags; flow nodes `body`.
+- SC6 — Flows: the matcher walks every non-synthetic diagram recursively (sub-DFDs included; `SYNTHETIC_DIAGRAM_IDS` excluded). The dropdown lists each match with kind, label, dotted number (processes), and owning diagram title, grouped by diagram, display-capped with a `+N more` overflow line. Clicking a row (or pressing Enter for the first row) calls `selectDiagramById(diagramId)` and lands on that diagram — including a sub-DFD with its full breadcrumb path.
+- SC7 — Flows: in the rendered diagram, nodes whose base token (role-split `--src`/`--snk`/`--read`/`--write` suffixes stripped) is in the match set keep full opacity; all others render at `DIM_OPACITY`. An edge stays undimmed when either endpoint matches. While a pointer hover is active the existing hover dim wins; on hover exit the search dim returns.
+- SC8 — Two routes to `{ type: 'search' }`: `/` after the editable guard and gated off ctrl/meta/alt (typing `/` inside any input/textarea/contenteditable/modal inserts the character), and Cmd/Ctrl+K resolved BEFORE the editable guard (like the zoom chords) so it focuses search even while typing elsewhere, with the browser default prevented. Both work on all three views; the shell focuses the active view's search input (graph bar, flow bar, or the Dictionary's existing input via `DictionaryViewHandle.focusSearch()`). Escape in a search input clears the term and blurs.
+- SC9 — Search state never enters the model, the layout fingerprint, `layout-store` saved positions, the URL hash, or the static export payload.
+- SC10 — `bun run test` and `bunx tsc --noEmit` exit 0: all existing checks stay green and the new checks pass. New Playwright checks follow the existing skip-if-dist-absent pattern.
+- SC11 — Bundle-only: no file under `src/server/` or `src/generators/` changes, and the search code paths perform no network requests — live serve and static export share the identical code path by construction.
+- SC12 — Chrome non-collision on the Flows view: the search bar never overlaps the DFD breadcrumb chips or the diagram nav card, at any breadcrumb depth (proven against the 4-level `test/fixtures/flows-leveling` fixture) — same standing as the graph view's banner non-collision.
+
+
+## Checkpoints
+
+
+| # | Checkpoint | Files/areas | Verifies |
+|---|------------|-------------|----------|
+| CP1 | Pure matchers: per-kind title/body match functions taking `includeBody`, plus the recursive cross-diagram flow walker returning grouped results with tokens | `src/app/logic/search.ts`, `test/checks/test-viewer-search.ts` | Unit check green; SC5/SC6 matcher halves proven on fixture data |
+| CP2 | `SearchBar` component + shell graph wiring: search state, match computation, `searchMatches` prop into `GraphView`, `search-match`/`search-dim` styles, count readout, Enter cycle, `.viewer-search-bar` CSS | `src/app/components/ui/SearchBar.tsx`, `src/app/App.tsx`, `src/app/views/graph/GraphView.tsx`, `src/app/views/graph/styles.ts`, `src/app/styles.css`, `test/checks/test-graph-search.ts`, `test/visual/test-graph-search.ts` | SC1–SC4 asserted in the Playwright check against `models/key-inherited`; SC9 asserted there too (URL hash and persisted layout positions unchanged while searching) |
+| CP3 | Flow wiring: shell flow-search state, `searchTokens` threaded `FlowsView` → `FlowDiagramSvg` opacity rules, results dropdown + `selectDiagramById` navigation | `src/app/App.tsx`, `src/app/views/flow/FlowsView.tsx`, `src/flow-view/FlowDiagramSvg.tsx`, `src/app/components/flow/FlowSearchResults.tsx`, `src/app/styles.css`, `test/checks/test-flow-search.ts`, `test/visual/test-flow-search.ts` | SC6/SC7 asserted in the Playwright check, including a sub-DFD navigation; SC9's flow half asserted there (hash gains no search param; flow layout store unchanged) |
+| CP4 | `/` shortcut end-to-end: resolver action, `useKeyboardShortcuts` `onSearch`, shell focus routing, `DictionaryViewHandle.focusSearch()`, HelpModal search + `/` rows, guide/feature-map rows, keymap amendments to the shortcut and help-overlay specs (the repo requires those specs stay current with the keymap) | `src/app/logic/shortcuts.ts`, `src/app/hooks/useKeyboardShortcuts.ts`, `src/app/App.tsx`, `src/app/views/dict/DictionaryView.tsx`, `src/app/components/ui/HelpModal.tsx`, `test/checks/test-shortcuts.ts`, `docs/guides/commands.md`, `docs/spec/keyboard-nav-shortcuts.md`, `docs/spec/help-overlay.md`, `CLAUDE.md` | SC8 resolver cases green in `test-shortcuts.ts`; docs rows present; both keymap specs amended with change-log entries |
+| CP5 | Search-bar refinement: the description toggle becomes a labeled switch, the bar and dropdown get a visual tightening pass within the existing design language (theme tokens, radii, frosted chrome, focus ring, switch styling, count treatment, row hover states — verified by screenshots in both themes), Cmd/Ctrl+K joins `/` as a search-focus chord on all views, and the flow bar no longer collides with the DFD breadcrumb chrome | `src/app/components/ui/SearchBar.tsx`, `src/app/styles.css`, `src/app/logic/shortcuts.ts`, `src/app/components/ui/HelpModal.tsx`, `test/checks/test-shortcuts.ts`, `test/checks/test-graph-search.ts`, `test/checks/test-flow-search.ts`, `docs/guides/commands.md`, `docs/spec/keyboard-nav-shortcuts.md`, `CLAUDE.md` | SC5's switch semantics + SC8's Cmd/Ctrl+K cases asserted in the checks; SC12's non-collision asserted against the deep-nesting fixture; both-theme screenshots reviewed; docs rows updated |
+
+
+## Change tree
+
+
+```
+M src/app/logic/search.ts — title/body matchers + cross-diagram flow walker
+A src/app/components/ui/SearchBar.tsx — shared bar: input, body toggle, count, results slot
+M src/app/App.tsx — per-view search state, match computation, bar mounting, / focus routing
+M src/app/views/graph/GraphView.tsx — searchMatches prop → search-match/search-dim class application
+M src/app/views/graph/styles.ts — search-match / search-dim cytoscape styles
+M src/app/views/flow/FlowsView.tsx — searchTokens prop threaded into svgProps
+M src/flow-view/FlowDiagramSvg.tsx — searchTokens folded into nodeOpacity/edgeOpacity
+M src/app/styles.css — .viewer-search-bar + results dropdown styles (print-hidden)
+M src/app/logic/shortcuts.ts — '/' → { type: 'search' } action
+M src/app/hooks/useKeyboardShortcuts.ts — onSearch callback in KeyboardShortcutsConfig
+M src/app/views/dict/DictionaryView.tsx — DictionaryViewHandle.focusSearch()
+M src/app/components/ui/HelpModal.tsx — per-view search rows + '/' in Keyboard sections
+A test/checks/test-viewer-search.ts — unit: matchers + flow walker
+M test/checks/test-shortcuts.ts — '/' resolver cases
+A test/checks/test-graph-search.ts — Playwright: dim/highlight/count/Enter/body toggle/hover interplay
+A test/checks/test-flow-search.ts — Playwright: dropdown, sub-DFD navigation, in-diagram dim
+A test/visual/test-graph-search.ts — screenshot: graph search active
+A test/visual/test-flow-search.ts — screenshot: flow search active + dropdown
+M docs/guides/commands.md — '/' keyboard shortcut row
+M docs/spec/keyboard-nav-shortcuts.md — '/' in the keymap/resolver contract + change-log entry
+M docs/spec/help-overlay.md — search rows in the view branches + change-log entry
+M CLAUDE.md — feature-map row (and '/' added to the keyboard-shortcuts row's key list)
+```
+
+
+## Outline
+
+
+- `src/app/logic/search.ts`
+ - `entityMatches` — entity title match, body opt-in
+ - kind-specific flow node matchers — process/external/store title fields, body opt-in
+ - `searchFlowDiagrams` — recursive non-synthetic diagram walk producing the result list
+ - `FlowSearchResult` — one dropdown row: what matched, its token, and the owning diagram
+- `src/app/components/ui/SearchBar.tsx`
+ - `SearchBar` — debounced input (200 ms), body toggle (aria-pressed), count slot, Enter/Escape handling, results children slot, focusable via ref
+- `src/app/App.tsx`
+ - graph/flow search state pairs — term + includeBody per view, survive view switches
+ - match-set memos — entity id set (graph), token set + result list (flow)
+ - bar mounting — graph bar when view=graph, flow bar when view=flow and diagrams exist
+ - `onSearch` routing — focus active view's input
+- `src/app/views/graph/GraphView.tsx`
+ - `searchMatches` prop — match set applied as classes; reapplied after model refresh and layout-mode change
+- `src/app/views/graph/styles.ts`
+ - `node.search-dim` / `edge.search-dim` — low opacity; `node.search-match` — outline emphasis, mode-aware
+- `src/app/views/flow/FlowsView.tsx`
+ - `searchTokens` prop — pass-through into `svgProps`
+- `src/flow-view/FlowDiagramSvg.tsx`
+ - `searchTokens?` prop — base-token comparison inside `nodeOpacity`/`edgeOpacity`, hover wins while active
+- `src/app/logic/shortcuts.ts`
+ - `'/'` case — `{ type: 'search' }`, after editable guard, no ctrl/meta/alt
+- `src/app/hooks/useKeyboardShortcuts.ts`
+ - `onSearch` — new config callback + dispatch case
+- `src/app/views/dict/DictionaryView.tsx`
+ - `focusSearch()` — handle method focusing the existing dict input
+- `src/app/components/ui/HelpModal.tsx`
+ - search rows — graph + flow branch terms; `/` row in each Keyboard section
+- test files — one check per CP as listed in the change tree
+- docs — `commands.md` shortcut row; `CLAUDE.md` feature-map row
+
+
+## Flows
+
+
+1. **Graph search** — user switches to Graph → types `party` in the bar → after debounce the shell computes matching entity ids → GraphView adds `search-match` to matches, `search-dim` to the rest, edges dim unless both endpoints match → bar shows `4 of 214` → user presses Enter repeatedly → each press centers + selects the next match in id order, wrapping → user clears the input (or presses Escape) → all search classes removed, count hidden.
+2. **Body toggle** — user types a term that only appears in an entity's prose body → no match with the toggle off → user clicks the body toggle → the body text is included and the entity lights up; same mechanics on the flow bar.
+3. **Flow search + navigate** — user switches to Flows → types `refund` → dropdown lists matches grouped by diagram (process `3.2 Process Refund` under "Order To Cash", diagram "Refund") → user clicks the process row → `selectDiagramById` opens the owning sub-DFD with its breadcrumb path → the diagram renders with non-matching nodes at `DIM_OPACITY` → hovering any node shows the normal hover focus; leaving restores the search dim.
+4. **Keyboard focus** — user presses `/` on any view → the active view's search input focuses (Dictionary included) → typing goes into the input; pressing `/` while already typing in any editable inserts a literal `/`.
+
+
+## Risks
+
+
+| Risk | Likelihood | Mitigation |
+|------|------------|------------|
+| GraphView class lifecycle: hover tiers, lineage, relayout, and SSE refresh each manipulate element classes or rebuild elements, silently erasing search state | Medium | Dedicated `search-*` classes (never touched by `clearFocusTiers`) plus a reapply effect keyed on the match set and cy generation; SC4 pins the behavior under test |
+| FlowDiagramSvg token mismatch: layout node ids carry role-split suffixes while match tokens do not | Medium | SC7 pins suffix-stripped base-token comparison; the unit walker test pins token construction |
+| Playwright timing flakiness on the new checks | Low | Follow the existing check idioms (`test-keyboard-shortcuts.ts`, `test-dfd-edge-hover.ts`): explicit waits on rendered state, skip-if-dist-absent |
+
+
+## Change log
+
+
+### 2026-07-14 — CP5: switch control, visual tightening, Cmd/Ctrl+K
+
+**What changed:** SC5's body toggle is now contracted as a labeled toggle switch ("Include descriptions", `role="switch"`); SC8 gains Cmd/Ctrl+K resolved before the editable guard on all views; new SC12 pins flow-view chrome non-collision (search bar vs breadcrumb chips / nav card); new CP5 covers all of it plus a visual tightening pass on the bar and dropdown.
+
+**Why:** user feedback on the live branch — the bare "Body" button was unclear, the bar design needed polish, Cmd/Ctrl+K is the expected search chord, and the flow bar covered the DFD breadcrumbs.
+
+**Superseded:** the body control as a plain pill button labeled "Body"; `/` as the only search-focus shortcut; the flow bar's position being unconstrained relative to the breadcrumb chrome.
+
+### 2026-07-14 — Correction: checkpoint-table column schema
+
+**What changed:** The Checkpoints table's columns were restructured from `# | Scope | Proof` to the validator-required `# | Checkpoint | Files/areas | Verifies`. Content unchanged.
+
+**Why:** `atomic validate spec` (run at the finish-line verification gate) failed S5 on the column schema; the contract itself was already current.
+
+### 2026-07-14 — CP4 covers the keymap spec surfaces
+
+**What changed:** CP4's scope, proof, and the change tree now include amendments to `docs/spec/keyboard-nav-shortcuts.md` and `docs/spec/help-overlay.md`, and the `CLAUDE.md` keyboard-shortcuts row's key list.
+
+**Why:** the repo's shortcut-key convention (project signals, cross-cutting section) requires every new key to update the keymap spec, the help-overlay spec, and the shell wiring surfaces together; the initial change tree missed the two spec files.
+
+### 2026-07-14 — Initial spec
+
+**What changed:** Initial contract for title-first search on the Graph and Flows views with an opt-in body toggle, cross-diagram flow results, and the `/` focus shortcut.
+
+**Why:** Neither view is searchable; users cannot find entities or processes on large models. Issue #18 (graph half) plus the flows gap raised alongside it.
+
+
+## Implementation log
+
+### shipped — 2026-07-14
+
+Built across 5 iterations of the /autopilot subagent loop (the fifth from live user feedback mid-run). Commits (chronological):
+
+- `196c214` — design doc + spec
+- `82d1c84` — CP1 pure title/body matchers + cross-diagram flow walker (+12-assertion unit check)
+- `ffdf70d` — CP2 graph search bar: dim/highlight classes, count, Enter cycling, banner offset (+26-assertion Playwright check, visual)
+- `e41a241` — spec amendment: CP4 covers the keymap spec surfaces
+- `3c4d520` — CP3 flow cross-diagram search: results dropdown, token dimming, live renderer updates (+25-assertion Playwright check, visual)
+- `de396ab` — CP4 `/` shortcut + help overlay + guide/keymap-spec/feature-map rows
+- `8afa0bc` — spec correction: validator column schema for the checkpoints table
+- `10bda6a` — signals refresh (wiki) after CP1–CP4
+- `b135003` — spec amendment: CP5 from live user feedback
+- `0150807` — CP5 switch control, Cmd/Ctrl+K, flow-chrome clearance, visual tightening
+
+**Out-of-scope work performed during this build:**
+
+- none
+
+**Unforeseens — surprises that emerged during implementation:**
+
+- Global error banner (z-index 200) fully occluded the search bar on error-bearing models — fixed with a measured `--search-bar-top` offset plus a true-positive-proven regression check against `models/broken-demo`.
+- The `data-token` DOM attribute stamps externals as the bare id while every other kind is prefixed — search tokens therefore mirror the layout `node.id` scheme instead; recorded on the token type's doc comment.
+- The repo carries a systemic pre-existing typecheck debt (cytoscape `Core` typing, ~640 instances; CI runs typecheck `continue-on-error`) — the gate used throughout was "no new error categories," verified per iteration.
+- Checks hardcode ports (e.g. 3297) — a hung check process orphaned by a parallel suite run blocked `bun run test` until cleared; local runs contend where CI's sequential loop does not.
+
+**Deferred items still open:**
+
+- none — the FOLLOWUPS ledger closed empty (F-1 folded into CP3).
diff --git a/docs/spec/help-overlay.md b/docs/spec/help-overlay.md
index daec9d8..fe2d192 100644
--- a/docs/spec/help-overlay.md
+++ b/docs/spec/help-overlay.md
@@ -22,10 +22,10 @@ primitive, view-switched on `ViewName`, with static term→description content;
## Success criteria
- [x] `HelpModal` (`src/app/components/ui/HelpModal.tsx`) renders on the shared `Modal` primitive with `className="help-modal"`, switches body content on `view: ViewName`, and titles per view ("About the Graph/Dictionary/Flows").
-- [x] Graph body covers: an ER-diagram intro, the five entity types (Independent, Dependent, Subtype, Associative, Classifier), how-to-explore (layouts, Shift+hover lineage, click/drag/zoom), and the key-inherited vs surrogate distinction.
+- [x] Graph body covers: an ER-diagram intro, the five entity types (Independent, Dependent, Subtype, Associative, Classifier), how-to-explore (layouts, Shift+hover lineage, click/drag/zoom, search — term matching with a body-text toggle, Enter cycling through matches), and the key-inherited vs surrogate distinction.
- [x] Dictionary body covers: Read/Browse lenses, spotlight, Shift+hover lineage, search/focus.
-- [x] Flows body covers: a DFD intro, symbols (process/store/external), drill-down + inspect.
-- [x] A Keyboard section is present on every view and tailored to it (only the keys active there); Graph and Flows footnote a pointer to the Legend.
+- [x] Flows body covers: a DFD intro, symbols (process/store/external), drill-down + inspect, cross-diagram search (results grouped by diagram, non-matches dimmed in the rendered diagram).
+- [x] A Keyboard section is present on every view and tailored to it (only the keys active there), including `/` to focus that view's search input; Graph and Flows footnote a pointer to the Legend.
- [x] A `?` top-bar button sits just left of the theme toggle (shared chrome, all views), opens the overlay, and is hidden in `@media print`.
- [x] `resolveShortcut` returns `{ type: 'help' }` for `?`: resolved after the editable guard, before the bare-key modifier guard (Shift is inherent), and gated off ctrl/meta/alt. `useKeyboardShortcuts` carries an `onHelp` callback; the shell opens the overlay.
- [x] Editable guard holds: typing `?` in the search box inserts a literal `?` and does NOT open the overlay.
@@ -51,4 +51,8 @@ primitive, view-switched on `ViewName`, with static term→description content;
## Change log
-
+### 2026-07-14 — Search rows + `/` key added
+
+**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.
diff --git a/docs/spec/keyboard-nav-shortcuts.md b/docs/spec/keyboard-nav-shortcuts.md
index 1a028c0..c2e8cd7 100644
--- a/docs/spec/keyboard-nav-shortcuts.md
+++ b/docs/spec/keyboard-nav-shortcuts.md
@@ -31,25 +31,36 @@ distinct guard class from the bare keys (see Keymap).
| `f` | view → `flow` | any view |
| `l` | toggle DG layout `organic ↔ hierarchical` | `view === 'graph'` |
| `b` | toggle DD lens `read ↔ browse` | `view === 'dict'` |
+| `/` | focus the active view's search input (graph bar, flow bar, or the Dictionary's search box) | any view |
Bare keys bail (no action) when: `ctrlKey || metaKey || altKey || shiftKey`, OR
focus is an editable target (`input` / `textarea` / `select` /
`contenteditable` / inside an open `.modal`). `l` and `b` resolve to no action
-when their view is not active. View jumps are idempotent.
-
-**Modifier-gated zoom** (CP4) — resolved *before* the bare-key guards, so the
-editable guard does **not** block them (they are not typed characters), and
-they require `ctrl`/`meta` (the opposite of the bare-key modifier guard):
+when their view is not active. View jumps are idempotent. `/` needs no Shift
+(unlike `?`), so it resolves through the ordinary bare-key switch with no
+special guard slot; typing `/` inside any editable target inserts the literal
+character instead of firing the shortcut. `Cmd`/`Ctrl` + `k` is a second,
+always-on route to the same `{ type: 'search' }` action — see "Modifier-gated
+zoom + search" below. The search feature itself (bars, matching, dimming) is
+owned by `docs/spec/graph-flow-search.md`; this resolver owns only the `/`
+and `Cmd`/`Ctrl`+`k` key bindings and the shell's focus-routing dispatch.
+
+**Modifier-gated zoom + search** — resolved *before* the bare-key guards, so
+the editable guard does **not** block them (they are not typed characters),
+and they require `ctrl`/`meta` (the opposite of the bare-key modifier guard):
| Chord | Action | Routed to |
|-------|--------|-----------|
| `Cmd`/`Ctrl` + `=` or `+` | `zoomIn` | active canvas (graph cy / flow SVG); dict no-op |
| `Cmd`/`Ctrl` + `-` or `_` | `zoomOut` | active canvas; dict no-op |
| `Cmd`/`Ctrl` + `0` | `zoomReset` (fit) | active canvas; dict no-op |
+| `Cmd`/`Ctrl` + `k` | `search` | the active view's search input — same target as `/` |
Gated on `ctrl`/`meta` only — `alt` or `shift` held disqualifies (→ null). Bare
-`=`/`-`/`0` with no modifier → null (plain keystrokes are never hijacked). The
-hook `preventDefault`s on the matched action so the browser never page-zooms.
+`=`/`-`/`0`/`k` with no modifier → null (plain keystrokes are never hijacked;
+bare `k` is simply unmapped). The hook `preventDefault`s on the matched action
+so the browser never page-zooms or fires its own "focus search" behavior on
+`Cmd`/`Ctrl`+`k`.
**Help key** — resolved *after* the editable guard but *before* the bare-key
modifier guard, because the character itself requires Shift:
@@ -75,6 +86,7 @@ opens the overlay). The overlay content/component is owned by
- [ ] `bun run test` passes (all `test/checks/*.ts`, exit 0).
- [ ] 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.
## Approaches
@@ -128,3 +140,15 @@ real-browser Playwright check, per the project's "test the actual runtime" lesso
**Why:** First-time viewers need an in-app orientation; `?` is the conventional help key.
**Superseded:** the non-goal "A help / cheat-sheet overlay" — the resolver now owns the `?` binding; the overlay itself moved to its own spec.
+
+### 2026-07-14 — `/` search-focus key added to the resolver
+
+**What changed:** `resolveShortcut` now returns `{ type: 'search' }` for `/`, added to the `ShortcutAction` union and the bare-key keymap table. Unlike `?`, `/` needs no Shift, so it resolves through the ordinary bare-key switch (after both guards) rather than a special pre-modifier-guard slot. `useKeyboardShortcuts` carries an `onSearch` callback; the shell (`App.tsx`) routes it to the active view's search input — the graph/flow `SearchBar`'s focus handle, or `DictionaryViewHandle.focusSearch()` on the Dictionary. `test-shortcuts.ts` T17–T19 cover it (bare key on every view, ctrl/meta/alt → null, editable → null).
+
+**Why:** the graph-flow-search feature (`docs/spec/graph-flow-search.md`, SC8) adds search bars to all three views and needs a keyboard shortcut to focus them; `/` is the conventional search-focus key. The search feature itself (bars, matching, dimming, results) is owned by that spec — this resolver owns only the key binding.
+
+### 2026-07-14 — `Cmd`/`Ctrl`+`k` joins the modifier-gated slot as a second search-focus route
+
+**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.
diff --git a/docs/wiki/index.md b/docs/wiki/index.md
index 35950b0..aa25ed8 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
-bcc22d725e9c09493638033910b2cc413f3a7d48
+08b8eeaf632f7cc3e9d655f1b1b1b0369d94bb8c1
# Project signals
@@ -37,8 +37,8 @@ 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) — 84 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-edge-routes.ts` (176L) covers `absoluteNodeBoxes` and `extractRoutes` in [`src/edge-routes.ts`](../../src/edge-routes.ts). `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` (311L) — 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). `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.
-- [`test/visual/`](../../test/visual) — 65 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. `screenshot-polyline-elk.ts`, `screenshot-segments-elk.ts`, `screenshot-orthogonal-edges.ts` cover ELK edge rendering. `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).
+- [`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/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,9 +48,9 @@ No linter or formatter configured in package.json.
| Language | LOC | Files | % |
|----------|-----|-------|---|
-| TypeScript | 57779 | 264 | 70% |
-| Markdown | 20286 | 297 | 24% |
-| CSS | 2764 | 2 | 3% |
+| TypeScript | 59159 | 265 | 70% |
+| Markdown | 20461 | 298 | 24% |
+| CSS | 3004 | 2 | 3% |
| YAML | 1340 | 14 | 1% |
| Shell | 116 | 1 | 0% |
| JSON | 103 | 4 | 0% |
@@ -76,8 +76,8 @@ No linter or formatter configured in package.json.
| parser | [`src/model/parse.ts`](../../src/model/parse.ts), [`src/model/wikilink.ts`](../../src/model/wikilink.ts), [`src/model/model-index.ts`](../../src/model/model-index.ts) | `ignatius.yml` config loading → ParseResult: {model, globalErrors}; nodes, edges, cardinality + classification derivation; wiki-link inline rule + two-pass body rendering with bodyLinks; `buildModelIndex` — 13 O(1) lookup maps built once per Model on consume | (below) |
| validate | [`src/model/validate.ts`](../../src/model/validate.ts) | Pure model validator: RuleIds across 5 domains, two severity tiers (A=warn, B=omit); coerces invalid pk/columns to safe defaults in cleanedModel | (below) |
| flows | [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts), [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts), [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts), [`src/flows/flow-fingerprint.ts`](../../src/flows/flow-fingerprint.ts), [`src/flows/flow-usage-index.ts`](../../src/flows/flow-usage-index.ts), [`src/flows/titlelize.ts`](../../src/flows/titlelize.ts), [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) | SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`), `validateFlows` (11 `flow.*` rules), `buildFlowLayoutKeys`, `buildEntityUsageIndex`/`buildFlowNodeUsageIndex`; role-split node model (ext→max 2 copies, store→read/write, process→single); `ignatius flow` is a removal stub | (below) |
-| flow-view | [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts), [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts), [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx), [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx), [`src/edge-routes.ts`](../../src/edge-routes.ts) | ELK-driven DFD layout (5-band partitioning, orthogonal edge routing); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes; banded layout retained as fallback | (below) |
-| frontend | [`src/app/`](../../src/app) | React 19 unified SPA (Graph/Dictionary/Flows views) decomposed into a layered [`src/app/`](../../src/app) tree; shell (`App.tsx`) owns state + composition; views own cy/SVG lifecycle; components handle entity/process/flow-node rendering; hooks manage model data, hash routing, and theme; logic/ and dom/ are pure utilities | (below) |
+| flow-view | [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts), [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts), [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx), [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx), [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) | ELK-driven DFD layout (5-band partitioning, orthogonal edge routing); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming; banded layout retained as fallback | (below) |
+| frontend | [`src/app/`](../../src/app) | React 19 unified SPA (Graph/Dictionary/Flows views) decomposed into a layered [`src/app/`](../../src/app) tree; shell (`App.tsx`) owns state + composition, including cross-view search state (graph-flow-search); views own cy/SVG lifecycle; components handle entity/process/flow-node rendering; hooks manage model data, hash routing, and theme; logic/ and dom/ are pure utilities | (below) |
| generators | [`src/generators/`](../../src/generators) | Unified static HTML export via `generateApp` (single file — graph + dict + flows); [`src/generators/app.ts`](../../src/generators/app.ts) is the sole static generator; [`src/generators/embedded-bundle.ts`](../../src/generators/embedded-bundle.ts) loads the React bundle | (below) |
| theme | [`src/theme/`](../../src/theme) | ThemeConfig + Branding types, default palettes, dark/light merging | (below) |
| skill | [`skills/noorm-modeling/`](../../skills/noorm-modeling) | Project-scoped Claude skill: Q&A-driven entity authoring + model bootstrap, convention-aware, writes files + verifies with `ignatius validate` | (below) |
@@ -204,7 +204,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde
**DFD ELK layout pipeline** — introduced by the dfd-overhaul merge. Replaces the hand-rolled banded positioner as the primary layout source for DFD diagrams. The banded `computeFlowLayout` path in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) is retained only as the fallback when ELK layout fails; drag overrides win over both.
-[`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) (345L) — async ELK positions + edge routing module for DFD diagrams. Browser-safe and Bun-compatible; no Bun/Node-only APIs at module top level. Exports:
+[`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) (353L) — async ELK positions + edge routing module for DFD diagrams. Browser-safe and Bun-compatible; no Bun/Node-only APIs at module top level. Exports:
- `computeElkLayout(diagram, opts?): Promise` — runs elkjs Layered with 5-band partitioning (source-ext=0, input-store=1, process-row=2, output-store=3, sink-ext=4) + `ORTHOGONAL` edge routing. Returns `ElkLayoutResult = { positions, edgeRoutes }`. `positions` values are node **centers** (ELK top-left + half size), matching the renderer's center-based convention. `edgeRoutes` entries are the full routed polyline: `[startPoint, ...bendPoints, endPoint]` in the same absolute coordinate space as `positions`.
- `terminateQuietly(elk): void` — calls `elk.terminateWorker()` inside a try/catch. In the browser bundle ELK runs via the `web-worker` main-thread shim whose fake worker has no `terminate()`; swallowing the exception prevents `FlowsView.renderDiagram` from treating a successful layout as a failure and falling back to banded positioner (C18 regression guard). Exported for unit testing.
@@ -214,9 +214,9 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde
- `bandOf(n: NodeElement, srcSet: Set): number` — assigns band 0–4 by role. Split-store ids carry `--read`/`--write` suffix; external copy ids carry `--src`/`--snk` suffix (from `buildFlowData`). Exported for band-ordering checks in tests.
- `ElkLayoutResult` type, `ComputeElkLayoutOpts` type.
-[`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (481L) — 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/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) (1663L) — SVG renderer consuming ELK positions and routed edge geometry. New props added by dfd-overhaul:
+[`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:
- `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,9 +225,10 @@ 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) => void` and `onRegisterZoomControl?` props for shell ZoomControl integration (CP23).
+- `onZoomChange?: (scale: number, fitScale: number) => void` and `onRegisterZoomControl?` props for shell ZoomControl integration (CP23).
+- **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) (409L) — DFD minimap + toolbar chrome wrapper. `.flow-minimap-wrapper` left offset aligned to 16px (matching DG `.minimap` left offset — CP19).
+[`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.
[`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) (74L) — pure zoom/fit math for the DFD SVG viewer. No DOM, no React, no Bun/Node imports; browser-safe and unit-testable. Implements the native-1:1 zoom model (#3): `100%` means one diagram world-unit renders as one CSS pixel, NOT that the content fits the container. Exports:
@@ -236,37 +237,32 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde
- `percentToScreenScale(pct: number, fitScale: number): number` — inverse: the `internalScale` needed for `pct%` on-screen zoom. `setPercent(100)` → `1 / fitScale` (native 1:1).
- `Size = { width, height }`, `Box = { w, h }` types.
-[`src/edge-routes.ts`](../../src/edge-routes.ts) (146L) — pure, framework-free helpers for orthogonal edge rendering. No Bun/Node/DOM imports; browser-safe. Exports:
-
-- `absoluteNodeBoxes(roots: ElkNode[]): Record` — flattens ELK's parent-relative child tree into absolute `{ x, y, w, h }` boxes keyed by node id. Accumulates offsets recursively.
-- `extractRoutes(edges: ElkExtendedEdge[] | undefined): Record` — pulls `startPoint → bendPoints → endPoint` polylines off each edge's first section, keyed by edge id. Edges without a section are omitted.
-- `Point = { x, y }`, `NodeBox = { x, y, w, h }` types.
-- Used by `test-edge-routes.ts` and by `FlowDiagramSvg` coordinate math.
-
### 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) (480L). [`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) (771L). [`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) (593L): 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) (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`).
+
+ **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/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 `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 `pushEntityHash(id)` (→ `history.pushState`); closing calls `clearEntityHash()` (→ `history.replaceState`). `popstate` fires `onRestoreEntity(id | null)` 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, pushEntityHash, clearEntityHash }`.
- - [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) (30L) — exports `useThemeMode(themeConfig?)`. Seeds from `window.__THEME_MODE__` or localStorage. Calls `applyThemeCssVars` on change. Returns `{ themeMode, toggleTheme }`.
- - [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (88L) — registers exactly ONE global `keydown` listener for the unified SPA keyboard shortcuts (g/d/f/l/b/?). 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`. 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/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).
- **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.
- [`src/app/logic/flow-node-ids.ts`](../../src/app/logic/flow-node-ids.ts) (28L) — exports `buildAllFlowNodeIds(diagrams, entityModel?)`. Returns `ReadonlySet` merging all process/external/non-db-store ids with ERD entity ids.
- [`src/app/logic/color.ts`](../../src/app/logic/color.ts) — exports `hexToRgba` and `blendHex`.
- - [`src/app/logic/search.ts`](../../src/app/logic/search.ts) — DD CSS Custom Highlight search logic.
+ - [`src/app/logic/search.ts`](../../src/app/logic/search.ts) (272L) — search-matcher logic for all three surfaces; grown substantially for graph-flow-search. **Dictionary matchers (pre-existing, unchanged):** `nodeMatchesSearch(node, term, groupLabel)` — matches id, `groupLabel`, every column's name/type/desc, and `bodyHtml` (tags stripped) — no opt-in flag, body is always in scope. `processMatchesSearch`, `externalMatchesSearch`, `storeMatchesSearch` follow the same always-columns/body pattern for their respective flow-node kinds. `sortGroupNodes`, `parseDottedNumber`, `compareDottedProcesses` are shared sort helpers used by both the Dictionary and the flow search-results grouping. **Graph/Flows matchers (graph-flow-search CP1/CP2/CP3) — title-first, opt-in body**, a deliberately different search UX from the Dictionary's always-columns/body matchers: `entityMatches(node, term, includeBody)` matches only `node.id` by default (no columns, no group label), matching stripped `bodyHtml` only when `includeBody` is true. `flowProcessMatches(proc, term, includeBody)` matches `id`/`label`/`dottedNumber`, body opt-in. `flowExternalMatches`/`flowStoreMatches` match `id`/`label` (or `name`/`displayName`), body opt-in. `flowDiagramMatches(diagram, term)` matches `id`/`title` — no body field on `FlowDiagram`, so no opt-in variant. Exports `FlowSearchResultKind = 'process' | 'external' | 'store' | 'diagram'` and `FlowSearchResult` (`{ kind, token, label, dottedNumber?, diagramId, diagramTitle }`) — `token` is the `proc:`/`ext:`/`:`/`diagram:` scheme matching `FlowDiagramSvg`'s layout node ids (not the role-split `--src`/`--snk`/`--read`/`--write` suffix, which is a render-time concern). `searchFlowDiagrams(diagrams, term, includeBody)` recursively walks every diagram and its `subDfds`, collecting diagram-title/process/external/store matches in walk order (parent before children; within a diagram: title, then processes, externals, stores in authored order); diagrams in `SYNTHETIC_DIAGRAM_IDS` (the derived context/L1 wrapper diagrams, from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts)) are excluded from results but still walked so their user-authored leaf `subDfds` are reached.
- [`src/app/logic/finding-rows.ts`](../../src/app/logic/finding-rows.ts) — finding row formatting logic extracted from FindingsPanel.
- [`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) (111L) — 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' }`), and `ShortcutKeyEvent` interface. Keymap: g→graph, d→dict, f→flow (any view); l→toggleLayout (graph only); b→toggleLens (dict only); `?` → help (any view; Shift+/ on most layouts); Cmd/Ctrl + `=`/`+` → zoomIn, Cmd/Ctrl + `-`/`_` → zoomOut, Cmd/Ctrl + `0` → zoomReset (routed to canvas, not the browser). Guard order: (0) modifier-gated zoom resolved BEFORE both guards; (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. Key matched on `e.key.toLowerCase()` for capslock-insensitivity. Imported by `useKeyboardShortcuts` only.
+ - [`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/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).
@@ -278,7 +274,11 @@ 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) (134L) — 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 closes with a view-specific `Keyboard` shortcuts section from `shortcutRows(view)`. Distinct from `LegendModal` (symbol reference); this is the conceptual orientation map.
+ - [`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/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 `