From 196c214ff0310f345d47d767191f1436fef51850 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 03:23:17 -0400 Subject: [PATCH 01/13] docs: design + spec for graph and flow search (#18) --- docs/design/graph-flow-search.md | 82 ++++++++++++++++++ docs/spec/graph-flow-search.md | 142 +++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 docs/design/graph-flow-search.md create mode 100644 docs/spec/graph-flow-search.md diff --git a/docs/design/graph-flow-search.md b/docs/design/graph-flow-search.md new file mode 100644 index 0000000..5c49aa5 --- /dev/null +++ b/docs/design/graph-flow-search.md @@ -0,0 +1,82 @@ +# 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 **body toggle** opts into also matching the markdown body text. +- `/` 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. + +### 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`. +- 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/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md new file mode 100644 index 0000000..6444d30 --- /dev/null +++ b/docs/spec/graph-flow-search.md @@ -0,0 +1,142 @@ +# 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 body toggle off, a term that appears only in a node's markdown body does not match; toggling body on makes it match. 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 — `/` resolves to `{ type: 'search' }` after the editable guard and gated off ctrl/meta/alt; the shell focuses the active view's search input (graph bar, flow bar, or the Dictionary's existing input via `DictionaryViewHandle.focusSearch()`). Typing `/` inside any input/textarea/contenteditable/modal inserts the character. 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. + + +## Checkpoints + + +| # | Scope | Proof | +|---|-------|-------| +| CP1 | Pure matchers in `src/app/logic/search.ts`: per-kind title/body match functions taking `includeBody`, plus the recursive cross-diagram flow walker returning grouped results with tokens. Unit check `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 in `App.tsx`, match computation, `searchMatches` prop into `GraphView`, `search-match`/`search-dim` styles in `styles.ts`, count readout, Enter cycle, `.viewer-search-bar` CSS. Playwright check `test/checks/test-graph-search.ts` + visual `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. Playwright check `test/checks/test-flow-search.ts` + visual `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, `docs/guides/commands.md` shortcut row, `CLAUDE.md` feature-map row. Extend `test/checks/test-shortcuts.ts`. | SC8 resolver cases green in `test-shortcuts.ts`; docs rows present. | + + +## 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 CLAUDE.md — feature-map row +``` + + +## 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 — 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. From 82d1c843ce3e8efd26e1bc0602929b8c1b914576 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 03:43:35 -0400 Subject: [PATCH 02/13] feat(search): title/body matchers + flow walker Pure per-kind matchers with an includeBody opt-in and a recursive non-synthetic diagram walker producing token-keyed results (CP1 of docs/spec/graph-flow-search.md, groundwork for #18). --- src/app/logic/search.ts | 150 +++++++++++++- test/checks/test-viewer-search.ts | 324 ++++++++++++++++++++++++++++++ 2 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 test/checks/test-viewer-search.ts diff --git a/src/app/logic/search.ts b/src/app/logic/search.ts index 5376609..3696e84 100644 --- a/src/app/logic/search.ts +++ b/src/app/logic/search.ts @@ -1,5 +1,6 @@ import type { ModelNode, SubtypeCluster } from '../../model/parse'; -import type { FlowProcess, FlowExternal, FlowStoreRef } from '../../flows/flow-parse'; +import type { FlowDiagram, FlowProcess, FlowExternal, FlowStoreRef } from '../../flows/flow-parse'; +import { SYNTHETIC_DIAGRAM_IDS } from '../../flows/flow-derive-levels'; // Dictionary group/entity hierarchy sort (pure logic, no I/O). // Hierarchy ordering: independent basetype-clusters first, dependent second; @@ -122,3 +123,150 @@ export function compareDottedProcesses(a: FlowProcess, b: FlowProcess): number { } return 0; } + +// --------------------------------------------------------------------------- +// Graph / Flows search (title-first, opt-in body) — distinct from the +// Dictionary matchers above, which always match columns + body. Title field +// set per kind is pinned by SC5 in docs/spec/graph-flow-search.md. +// --------------------------------------------------------------------------- + +// Graph (DG) search: entity title match (id only — no columns, no group +// label), body opt-in via bodyHtml stripped of tags. +export function entityMatches(node: ModelNode, term: string, includeBody: boolean): boolean { + const t = term.toLowerCase(); + if (node.id.toLowerCase().includes(t)) return true; + if (includeBody && node.bodyHtml.replace(/<[^>]+>/g, ' ').toLowerCase().includes(t)) return true; + return false; +} + +// Flows (DFD) search: process title match (id/label/dottedNumber), body opt-in. +export function flowProcessMatches(proc: FlowProcess, term: string, includeBody: boolean): boolean { + const t = term.toLowerCase(); + if (proc.id.toLowerCase().includes(t)) return true; + if (proc.label.toLowerCase().includes(t)) return true; + if (proc.dottedNumber.toLowerCase().includes(t)) return true; + if (includeBody && proc.body.toLowerCase().includes(t)) return true; + return false; +} + +// Flows (DFD) search: external title match (id/label), body opt-in. +export function flowExternalMatches(ext: FlowExternal, term: string, includeBody: boolean): boolean { + const t = term.toLowerCase(); + if (ext.id.toLowerCase().includes(t)) return true; + if (ext.label.toLowerCase().includes(t)) return true; + if (includeBody && ext.body.toLowerCase().includes(t)) return true; + return false; +} + +// Flows (DFD) search: store title match (name/displayName), body opt-in. +export function flowStoreMatches(store: FlowStoreRef, term: string, includeBody: boolean): boolean { + const t = term.toLowerCase(); + if (store.name.toLowerCase().includes(t)) return true; + if (store.displayName.toLowerCase().includes(t)) return true; + if (includeBody && store.body?.toLowerCase().includes(t)) return true; + return false; +} + +// Flows (DFD) search: diagram title match (id/title). No body field on +// FlowDiagram itself, so there is no includeBody variant. +export function flowDiagramMatches(diagram: FlowDiagram, term: string): boolean { + const t = term.toLowerCase(); + if (diagram.id.toLowerCase().includes(t)) return true; + if (diagram.title.toLowerCase().includes(t)) return true; + return false; +} + +export type FlowSearchResultKind = 'process' | 'external' | 'store' | 'diagram'; + +/** One dropdown row: what matched, its token, and the diagram it lives in. */ +export type FlowSearchResult = { + kind: FlowSearchResultKind; + /** Base token in the proc:/ext:/: scheme of FlowDiagramSvg's layout + * node.id — the key nodeOpacity/edgeOpacity compare against, NOT the + * data-token DOM attribute (which stamps externals as the bare id). + * Role-split --src/--snk/--read/--write suffixes are a render-time layout + * concern, never part of this token. */ + token: string; + label: string; + /** Only present for kind 'process'. */ + dottedNumber?: string; + diagramId: string; + diagramTitle: string; +}; + +/** + * Recursively walk every non-synthetic diagram (sub-DFDs included) and collect + * every process / external / store / diagram-title match, grouped in walk + * order (parent before children; within a diagram: diagram title, then + * processes, externals, stores in their authored order). + * + * Synthetic diagrams (SYNTHETIC_DIAGRAM_IDS — the derived context/L1 wrapper + * diagrams) are excluded from results but still walked through so their + * user-authored leaf subDfds are reached. + * + * Pure; no I/O. + */ +export function searchFlowDiagrams( + diagrams: FlowDiagram[], + term: string, + includeBody: boolean, +): FlowSearchResult[] { + const results: FlowSearchResult[] = []; + + function walk(diagram: FlowDiagram): void { + if (!SYNTHETIC_DIAGRAM_IDS.has(diagram.id)) { + if (flowDiagramMatches(diagram, term)) { + results.push({ + kind: 'diagram', + token: `diagram:${diagram.id}`, + label: diagram.title, + diagramId: diagram.id, + diagramTitle: diagram.title, + }); + } + for (const proc of diagram.processes) { + if (flowProcessMatches(proc, term, includeBody)) { + results.push({ + kind: 'process', + token: `proc:${proc.id}`, + label: proc.label, + dottedNumber: proc.dottedNumber, + diagramId: diagram.id, + diagramTitle: diagram.title, + }); + } + } + for (const ext of diagram.externals) { + if (flowExternalMatches(ext, term, includeBody)) { + results.push({ + kind: 'external', + token: `ext:${ext.id}`, + label: ext.label, + diagramId: diagram.id, + diagramTitle: diagram.title, + }); + } + } + for (const store of diagram.storeRefs) { + if (flowStoreMatches(store, term, includeBody)) { + results.push({ + kind: 'store', + token: `${store.kind}:${store.name}`, + label: store.displayName, + diagramId: diagram.id, + diagramTitle: diagram.title, + }); + } + } + } + for (const sub of diagram.subDfds) { + walk(sub); + } + } + + for (const diagram of diagrams) { + walk(diagram); + } + + return results; +} diff --git a/test/checks/test-viewer-search.ts b/test/checks/test-viewer-search.ts new file mode 100644 index 0000000..edaaad4 --- /dev/null +++ b/test/checks/test-viewer-search.ts @@ -0,0 +1,324 @@ +/** + * test-viewer-search.ts — unit tests for the Graph/Flows search matchers and + * the recursive cross-diagram flow walker (CP1 of docs/spec/graph-flow-search.md). + * + * CI assertion script (PASS/FAIL/exit-1 style), same idiom as + * test-flow-spotlight-connections.ts. + * + * Covers: + * T1 - entityMatches: id title match, case-insensitive substring + * T2 - entityMatches: body opt-in (off → no match, on → match), tags stripped + * T3 - flowProcessMatches: id/label/dottedNumber title fields + * T4 - flowProcessMatches: body opt-in + * T5 - flowExternalMatches: id/label title fields + body opt-in + * T6 - flowStoreMatches: name/displayName title fields + body opt-in (incl. missing body) + * T7 - flowDiagramMatches: id/title fields + * T8 - searchFlowDiagrams: recursive sub-DFD coverage + * T9 - searchFlowDiagrams: synthetic diagram exclusion (still walks through to leaves) + * T10 - searchFlowDiagrams: token construction (proc:/ext:/: scheme) + * T11 - searchFlowDiagrams: per-diagram grouping/ordering (parent before child; + * diagram title, then processes, externals, stores within a diagram) + * T12 - searchFlowDiagrams: process result carries dottedNumber; external/store do not + */ + +import { + entityMatches, + flowProcessMatches, + flowExternalMatches, + flowStoreMatches, + flowDiagramMatches, + searchFlowDiagrams, + type FlowSearchResult, +} from '../../src/app/logic/search'; +import type { ModelNode } from '../../src/model/parse'; +import type { + FlowDiagram, + FlowProcess, + FlowExternal, + FlowStoreRef, +} from '../../src/flows/flow-parse'; +import { SYNTHETIC_DIAGRAM_IDS, CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID } from '../../src/flows/flow-derive-levels'; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) { + console.error('FAIL:', msg); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function makeNode(id: string, bodyHtml = ''): ModelNode { + return { + id, + classification: 'Independent', + pk: ['id'], + columns: {}, + alternateKeys: [], + bodyHtml, + }; +} + +function makeProcess(overrides: Partial & { id: string }): FlowProcess { + return { + label: overrides.id, + dottedNumber: '1', + inputs: [], + outputs: [], + body: '', + bodyHtml: '', + hasSubDfd: false, + flowId: 'dfd', + ...overrides, + }; +} + +function makeExternal(overrides: Partial & { id: string }): FlowExternal { + return { + label: overrides.id, + body: '', + bodyHtml: '', + flowId: 'dfd', + ...overrides, + }; +} + +function makeStore(overrides: Partial & { name: string }): FlowStoreRef { + return { + kind: 'db', + displayName: overrides.name, + flowId: 'dfd', + ...overrides, + }; +} + +function makeDiagram(overrides: Partial & { id: string }): FlowDiagram { + return { + title: overrides.id, + processes: [], + externals: [], + storeRefs: [], + edges: [], + subDfds: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// T1: entityMatches — id title match, case-insensitive substring +// --------------------------------------------------------------------------- +{ + const node = makeNode('Customer'); + assert(entityMatches(node, 'custom', false) === true, 'T1: substring match on id'); + assert(entityMatches(node, 'CUSTOM', false) === true, 'T1: case-insensitive'); + assert(entityMatches(node, 'zzz', false) === false, 'T1: no match → false'); + console.log('PASS T1: entityMatches id title match'); +} + +// --------------------------------------------------------------------------- +// T2: entityMatches — body opt-in, tags stripped +// --------------------------------------------------------------------------- +{ + const node = makeNode('Order', '

contains a refund clause

'); + assert(entityMatches(node, 'refund', false) === false, 'T2: body off → no match'); + assert(entityMatches(node, 'refund', true) === true, 'T2: body on → match'); + assert(entityMatches(node, '', true) === false, 'T2: html tags stripped before matching'); + console.log('PASS T2: entityMatches body opt-in, tags stripped'); +} + +// --------------------------------------------------------------------------- +// T3: flowProcessMatches — id/label/dottedNumber title fields +// --------------------------------------------------------------------------- +{ + const byId = makeProcess({ id: 'ValidateOrder', label: 'Validate the Order', dottedNumber: '3.2' }); + assert(flowProcessMatches(byId, 'validateorder', false) === true, 'T3: id match'); + assert(flowProcessMatches(byId, 'validate the', false) === true, 'T3: label match'); + assert(flowProcessMatches(byId, '3.2', false) === true, 'T3: dottedNumber match'); + assert(flowProcessMatches(byId, 'nope', false) === false, 'T3: no match → false'); + console.log('PASS T3: flowProcessMatches title fields'); +} + +// --------------------------------------------------------------------------- +// T4: flowProcessMatches — body opt-in +// --------------------------------------------------------------------------- +{ + const proc = makeProcess({ id: 'ProcessRefund', body: 'issues a refund to the customer' }); + assert(flowProcessMatches(proc, 'issues a refund', false) === false, 'T4: body off → no match'); + assert(flowProcessMatches(proc, 'issues a refund', true) === true, 'T4: body on → match'); + console.log('PASS T4: flowProcessMatches body opt-in'); +} + +// --------------------------------------------------------------------------- +// T5: flowExternalMatches — id/label title fields + body opt-in +// --------------------------------------------------------------------------- +{ + const ext = makeExternal({ id: 'PaymentGateway', label: 'Payment Gateway', body: 'charges the card' }); + assert(flowExternalMatches(ext, 'paymentgateway', false) === true, 'T5: id match'); + assert(flowExternalMatches(ext, 'gateway', false) === true, 'T5: label match'); + assert(flowExternalMatches(ext, 'charges', false) === false, 'T5: body off → no match'); + assert(flowExternalMatches(ext, 'charges', true) === true, 'T5: body on → match'); + console.log('PASS T5: flowExternalMatches title + body'); +} + +// --------------------------------------------------------------------------- +// T6: flowStoreMatches — name/displayName title fields + body opt-in (incl. missing body) +// --------------------------------------------------------------------------- +{ + const store = makeStore({ name: 'orders-db', displayName: 'Orders Database', body: 'stores order rows' }); + assert(flowStoreMatches(store, 'orders-db', false) === true, 'T6: name match'); + assert(flowStoreMatches(store, 'orders database', false) === true, 'T6: displayName match'); + assert(flowStoreMatches(store, 'order rows', false) === false, 'T6: body off → no match'); + assert(flowStoreMatches(store, 'order rows', true) === true, 'T6: body on → match'); + + const storeNoBody = makeStore({ name: 'cache-1' }); + assert(flowStoreMatches(storeNoBody, 'anything', true) === false, 'T6: missing body never throws / never matches'); + console.log('PASS T6: flowStoreMatches title + body, missing body safe'); +} + +// --------------------------------------------------------------------------- +// T7: flowDiagramMatches — id/title fields +// --------------------------------------------------------------------------- +{ + const diagram = makeDiagram({ id: 'order-to-cash', title: 'Order To Cash' }); + assert(flowDiagramMatches(diagram, 'order-to-cash') === true, 'T7: id match'); + assert(flowDiagramMatches(diagram, 'to cash') === true, 'T7: title match'); + assert(flowDiagramMatches(diagram, 'nope') === false, 'T7: no match → false'); + console.log('PASS T7: flowDiagramMatches id/title'); +} + +// --------------------------------------------------------------------------- +// T8: searchFlowDiagrams — recursive sub-DFD coverage +// --------------------------------------------------------------------------- +{ + const subDfd = makeDiagram({ + id: 'create-invoice', + title: 'Create Invoice', + processes: [makeProcess({ id: 'SendInvoice', label: 'Send Invoice', dottedNumber: '3.1.1' })], + }); + const parent = makeDiagram({ + id: 'order-to-cash', + title: 'Order To Cash', + processes: [makeProcess({ id: 'CreateInvoice', label: 'Create Invoice', dottedNumber: '3.1', hasSubDfd: true })], + subDfds: [subDfd], + }); + const results: FlowSearchResult[] = searchFlowDiagrams([parent], 'invoice', false); + const labels = results.map(r => r.label); + assert(labels.includes('Create Invoice'), 'T8: parent-level process found'); + assert(labels.includes('Send Invoice'), 'T8: sub-DFD process found (recursive walk)'); + console.log('PASS T8: searchFlowDiagrams recursive sub-DFD coverage'); +} + +// --------------------------------------------------------------------------- +// T9: searchFlowDiagrams — synthetic diagram exclusion (still walks through) +// --------------------------------------------------------------------------- +{ + const leaf = makeDiagram({ + id: 'order-to-cash', + title: 'Order To Cash', + processes: [makeProcess({ id: 'ValidateOrder', label: 'Validate Order', dottedNumber: '1' })], + }); + const l1 = makeDiagram({ + id: SYSTEM_PROCESS_ID, + title: 'System', + processes: [makeProcess({ id: 'order-to-cash', label: 'Validate Order copy', dottedNumber: '1' })], + subDfds: [leaf], + }); + const context = makeDiagram({ + id: CONTEXT_DIAGRAM_ID, + title: 'Context', + processes: [makeProcess({ id: SYSTEM_PROCESS_ID, label: 'Validate Order copy', dottedNumber: '0' })], + subDfds: [l1], + }); + assert(SYNTHETIC_DIAGRAM_IDS.has(CONTEXT_DIAGRAM_ID) && SYNTHETIC_DIAGRAM_IDS.has(SYSTEM_PROCESS_ID), 'T9: sanity — ids are synthetic'); + + const results = searchFlowDiagrams([context], 'validate order', false); + assert(results.length === 1, `T9: only the leaf-diagram match survives (got ${results.length})`); + assert(results[0]?.diagramId === 'order-to-cash', 'T9: surviving match is owned by the leaf diagram, not context/L1'); + console.log('PASS T9: searchFlowDiagrams synthetic exclusion, still walks to leaves'); +} + +// --------------------------------------------------------------------------- +// T10: searchFlowDiagrams — token construction (proc:/ext:/: scheme) +// --------------------------------------------------------------------------- +{ + const diagram = makeDiagram({ + id: 'order-to-cash', + title: 'Order To Cash', + processes: [makeProcess({ id: 'ValidateOrder', label: 'Validate Order' })], + externals: [makeExternal({ id: 'Customer', label: 'Customer' })], + storeRefs: [makeStore({ kind: 'cache', name: 'orders-cache', displayName: 'Orders Cache' })], + }); + const all = searchFlowDiagrams([diagram], '', false); // empty term → substring-matches everything + const proc = all.find(r => r.kind === 'process'); + const ext = all.find(r => r.kind === 'external'); + const store = all.find(r => r.kind === 'store'); + assert(proc?.token === 'proc:ValidateOrder', `T10: process token is proc: (got ${proc?.token})`); + assert(ext?.token === 'ext:Customer', `T10: external token is ext: (got ${ext?.token})`); + assert(store?.token === 'cache:orders-cache', `T10: store token is : (got ${store?.token})`); + console.log('PASS T10: searchFlowDiagrams token construction'); +} + +// --------------------------------------------------------------------------- +// T11: searchFlowDiagrams — per-diagram grouping/ordering +// Parent diagram (with a title match, a process match, an external match, a +// store match) precedes its sub-DFD in the result list; within the parent +// diagram, the diagram-title row precedes process rows, which precede +// external rows, which precede store rows — the diagram's authored order. +// --------------------------------------------------------------------------- +{ + const subDfd = makeDiagram({ + id: 'sub-flow', + title: 'Sub Flow', + processes: [makeProcess({ id: 'InnerProcess', label: 'Inner Process' })], + }); + const parent = makeDiagram({ + id: 'main-flow', + title: 'Main Flow', + processes: [makeProcess({ id: 'OuterProcess', label: 'Outer Process' })], + externals: [makeExternal({ id: 'OuterExternal', label: 'Outer External' })], + storeRefs: [makeStore({ name: 'outer-store', displayName: 'Outer Store' })], + subDfds: [subDfd], + }); + const results = searchFlowDiagrams([parent], 'outer', false); + // All 4 "outer" matches (diagram title itself is "Main Flow" — no "outer" — + // so only process/external/store match here) must precede the sub-DFD walk. + assert(results.length === 3, `T11: 3 "outer" matches in the parent diagram (got ${results.length})`); + assert(results[0]?.kind === 'process', 'T11: process rows precede external rows'); + assert(results[1]?.kind === 'external', 'T11: external rows precede store rows'); + assert(results[2]?.kind === 'store', 'T11: store rows last within a diagram'); + assert(results.every(r => r.diagramId === 'main-flow'), 'T11: all rows owned by the parent diagram'); + + const titleResults = searchFlowDiagrams([parent], 'main flow', false); + assert(titleResults.length === 1 && titleResults[0]?.kind === 'diagram', 'T11: diagram-title match produces a diagram-kind row'); + + const grouped = searchFlowDiagrams([parent], 'process', false); + assert(grouped.length === 2, `T11: parent + sub-DFD process both match "process" (got ${grouped.length})`); + assert(grouped[0]?.diagramId === 'main-flow', 'T11: parent diagram group precedes sub-DFD group'); + assert(grouped[1]?.diagramId === 'sub-flow', 'T11: sub-DFD group follows parent group'); + console.log('PASS T11: searchFlowDiagrams per-diagram grouping/ordering'); +} + +// --------------------------------------------------------------------------- +// T12: searchFlowDiagrams — dottedNumber only on process results +// --------------------------------------------------------------------------- +{ + const diagram = makeDiagram({ + id: 'order-to-cash', + title: 'Order To Cash', + processes: [makeProcess({ id: 'MatchTerm', label: 'Match Term', dottedNumber: '2.4' })], + externals: [makeExternal({ id: 'MatchTermExt', label: 'Match Term Ext' })], + storeRefs: [makeStore({ name: 'match-term-store', displayName: 'Match Term Store' })], + }); + const results = searchFlowDiagrams([diagram], 'match term', false); + const proc = results.find(r => r.kind === 'process'); + const ext = results.find(r => r.kind === 'external'); + const store = results.find(r => r.kind === 'store'); + assert(proc?.dottedNumber === '2.4', 'T12: process result carries dottedNumber'); + assert(ext?.dottedNumber === undefined, 'T12: external result has no dottedNumber'); + assert(store?.dottedNumber === undefined, 'T12: store result has no dottedNumber'); + console.log('PASS T12: dottedNumber only present on process results'); +} + +console.log('\nAll viewer search assertions passed.'); From ffdf70d0bfdca35f708963ada1cec7a363364426 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 04:53:01 -0400 Subject: [PATCH 03/13] feat(graph): search bar with dim + Enter cycling Title-first matching with an opt-in body toggle; matches keep full opacity with a mode-aware outline while the rest of the graph dims via dedicated search-* classes that survive hover tiers, lineage, relayout, and SSE refreshes. Enter pans through matches in id order. Bar offsets below the global error banner via a measured --search-bar-top var. CP2 of docs/spec/graph-flow-search.md (#18). --- src/app/App.tsx | 105 +++++++++- src/app/components/ui/SearchBar.tsx | 140 +++++++++++++ src/app/styles.css | 80 +++++++ src/app/views/graph/GraphView.tsx | 52 +++++ src/app/views/graph/styles.ts | 30 +++ test/checks/test-graph-search.ts | 310 ++++++++++++++++++++++++++++ test/visual/test-graph-search.ts | 88 ++++++++ 7 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 src/app/components/ui/SearchBar.tsx create mode 100644 test/checks/test-graph-search.ts create mode 100644 test/visual/test-graph-search.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index af66c85..c2d4b79 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { semanticColors, resolveFlowKindPalette, type FlowKindKey, type FlowKindEntry } from '../theme/theme-defaults'; import { parseHash } from './hash-router'; @@ -14,9 +14,11 @@ import { buildModelIndex } from '../model/model-index'; import type { ModelIndex } from '../model/model-index'; import { hexToRgba } from './logic/color'; import { buildAllFlowNodeIds } from './logic/flow-node-ids'; +import { entityMatches } from './logic/search'; import { Modal } from './components/ui/Modal'; import { HelpModal } from './components/ui/HelpModal'; import { ZoomControl } from './components/ui/ZoomControl'; +import { SearchBar } from './components/ui/SearchBar'; import { EntityModal } from './components/entity/EntityModal'; import { FindingsPanel } from './components/findings/FindingsPanel'; import { LegendModal } from './views/flow/LegendModal'; @@ -39,6 +41,10 @@ export function App() { const graphViewRef = useRef(null); // Minimap container: rendered in shell JSX, ref passed down to GraphView. const minimapRef = useRef(null); + // Global-error banner: ref used ONLY to measure its rendered height (see the + // layout effect below) so the search bar can offset below it — the banner's + // height is dynamic (row count varies with error count / text wrap width). + const bannerRef = useRef(null); const [selected, setSelected] = useState(null); @@ -102,6 +108,14 @@ export function App() { // Dictionary state — keep-mounted so search text + scroll survive view switches. const [dictSearchText, setDictSearchText] = useState(''); const [dictNavOpen, setDictNavOpen] = useState(false); + // Graph search state (graph-flow-search CP2) — term + includeBody, survive + // view switches the same way dictSearchText does (shell-owned useState, not + // reset on unmount since GraphView stays mounted across view switches). + const [graphSearchTerm, setGraphSearchTerm] = useState(''); + const [graphSearchIncludeBody, setGraphSearchIncludeBody] = useState(false); + // Ascending-id cursor for Enter-to-cycle (SC3) — reset whenever the term or + // body toggle changes so the next Enter always starts from the first match. + const graphSearchCursorRef = useRef(-1); // Pending scroll target: set by onNavigateToProcess, consumed by the view-switch // useEffect once the dict container is visible and the process anchor exists. const pendingScrollProcessIdRef = useRef(null); @@ -186,6 +200,36 @@ export function App() { document.title = model?._meta?.name ?? 'Ignatius'; }, [model]); + // Offset the graph search bar below the global-error banner (fixes the bar + // being fully occluded by the full-width, higher-z-index banner). Measures + // the banner's REAL rendered height via ResizeObserver rather than a fixed + // constant because it can wrap into multiple rows (many/long global errors, + // narrow viewport) — the fixed offset must track that and the bar must + // snap back to its default top when the banner is dismissed or has no + // errors. Written as a CSS custom property (consumed by .viewer-search-bar + // in styles.css) rather than passed as a prop, since SearchBar renders + // `.viewer-search-bar` directly with no style override slot. + useLayoutEffect(() => { + const bannerVisible = !bannerDismissed && findings.globalErrors.length > 0 && view !== 'flow'; + const el = bannerRef.current; + const root = document.documentElement; + if (!bannerVisible || !el) { + root.style.removeProperty('--search-bar-top'); + return; + } + const BANNER_GAP = 12; // matches --search-bar-top's no-banner fallback in styles.css + function applyOffset() { + root.style.setProperty('--search-bar-top', `${el!.offsetHeight + BANNER_GAP}px`); + } + applyOffset(); + const ro = new ResizeObserver(applyOffset); + ro.observe(el); + return () => { + ro.disconnect(); + root.style.removeProperty('--search-bar-top'); + }; + }, [bannerDismissed, findings.globalErrors.length, view]); + function toggleMinimapOpen() { const next = !minimapOpen; localStorage.setItem('ignatius-minimap', String(next)); @@ -276,6 +320,47 @@ export function App() { [model], ); + // Graph search (graph-flow-search CP2, SC1/SC2/SC5): match-set computation + // over model.nodes using CP1's pure entityMatches. graphSearchActive tracks + // whether a term is present at all — GraphView's searchMatches prop is null + // (no active search, nothing dims) when inactive, and a Set (possibly empty, + // meaning "active search, zero matches — dim everything") when active. Pure + // UI state: never touches the model, layout fingerprint, layout-store, or + // URL hash (SC9). + const graphSearchActive = graphSearchTerm.trim() !== ''; + const graphSearchMatchIds = useMemo(() => { + if (!model || !graphSearchActive) return []; + const term = graphSearchTerm.trim(); + const ids: string[] = []; + for (const node of model.nodes) { + if (entityMatches(node, term, graphSearchIncludeBody)) ids.push(node.id); + } + ids.sort((a, b) => a.localeCompare(b)); + return ids; + }, [model, graphSearchActive, graphSearchTerm, graphSearchIncludeBody]); + const graphSearchMatches = useMemo( + () => (graphSearchActive ? new Set(graphSearchMatchIds) : null), + [graphSearchActive, graphSearchMatchIds], + ); + + // Reset the Enter-cycle cursor whenever the term or body toggle changes so + // the next Enter press always starts from the first match in id order. + useEffect(() => { + graphSearchCursorRef.current = -1; + }, [graphSearchTerm, graphSearchIncludeBody]); + + // Enter cycles ascending-id matches, wrapping (SC3). Centers + selects via + // the existing navigateToEntity handle — no new GraphView method needed. + function handleGraphSearchEnter() { + const ids = graphSearchMatchIds; + if (ids.length === 0) return; + const next = (graphSearchCursorRef.current + 1) % ids.length; + const nextId = ids[next]; + if (nextId === undefined) return; + graphSearchCursorRef.current = next; + graphViewRef.current?.navigateToEntity(nextId); + } + // Deep-link modal restore: a URL loaded with entity= opens that modal once // the model (and its index) is available. One-shot — guarded by a ref so an // SSE model refetch does not re-open a modal the user already closed. Works @@ -335,7 +420,7 @@ export function App() {
{/* ── ERD surface chrome (hidden on flow surface) ── */} {!isFlowSurface && showBanner && ( -
+
+ {matchCount !== null && ( + {matchCount} of {totalCount} + )} +
+ {children} +
+ ); + }, +); diff --git a/src/app/styles.css b/src/app/styles.css index 640692f..0c3bb5c 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1169,6 +1169,85 @@ code { color: var(--color-text-muted); } +/* graph-flow-search CP2: shared Graph/Flows search bar (SearchBar.tsx). + Compact frosted pill, top-center — visually consistent with .dict-search-bar + but deliberately NOT full-width, so it never collides with the top-right + help/theme toggles + FindingsPanel or the top-left branding block. The + full-width global error banner is handled separately via --search-bar-top + (see below) since width alone doesn't avoid a top-anchored, full-width + banner. */ +.viewer-search-bar { + position: fixed; + /* --search-bar-top is set by App.tsx's banner-measuring layout effect while + the global-error banner is visible, so the bar sits below it instead of + being occluded (banner is full-width, z-index:200 vs. the bar's 45). + Falls back to 12px (no banner) — never written by CSS. */ + top: var(--search-bar-top, 12px); + left: 50%; + transform: translateX(-50%); + z-index: 45; + display: flex; + flex-direction: column; + align-items: center; +} + +.viewer-search-bar-inner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--color-border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + background-color: color-mix(in srgb, var(--color-background) 75%, transparent); +} + +.viewer-search-input { + width: 200px; + padding: 0.4rem 0.65rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + font-size: 0.85rem; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +.viewer-search-input:focus { + border-color: var(--color-link); +} + +.viewer-search-input::placeholder { + color: var(--color-text-muted); +} + +.viewer-search-body-toggle { + padding: 0.35rem 0.6rem; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: 0.78rem; + cursor: pointer; + white-space: nowrap; +} + +.viewer-search-body-toggle--active { + background: var(--color-link); + border-color: var(--color-link); + color: var(--color-background); +} + +.viewer-search-count { + font-size: 0.78rem; + color: var(--color-text-muted); + white-space: nowrap; +} + /* CP9: DD search highlight — CSS Custom Highlight API pseudo-element. The --dd-search-highlight var is set per-mode by applyThemeCssVars at runtime (authoritative). These fallbacks cover surfaces that don't call it (e.g. static @@ -2450,6 +2529,7 @@ a.dict-link-missing:hover { .flow-chrome, .graph-global-banner, .dict-search-bar, + .viewer-search-bar, .dict-nav-panel, .findings-panel, .theme-toggle, diff --git a/src/app/views/graph/GraphView.tsx b/src/app/views/graph/GraphView.tsx index e3c4cee..8b7b16f 100644 --- a/src/app/views/graph/GraphView.tsx +++ b/src/app/views/graph/GraphView.tsx @@ -108,6 +108,11 @@ export interface GraphViewProps { minimapOpen: boolean; /** Initial layout mode (read once; changes arrive via applyLayoutMode handle). */ initialLayoutMode: LayoutMode; + /** Active graph-search match set (entity ids). Null = no active search — no + * search-* classes applied. Applied as `search-match`/`search-dim` classes + * (graph-flow-search CP2); survives hover tiers, lineage, layout changes, + * and SSE model refresh (reapplied on cy rebuild). */ + searchMatches: ReadonlySet | null; /** Called when cy readiness changes (true = cy alive; false = cy destroyed). */ onCyReadyChange(ready: boolean): void; /** Called on every cy zoom event with the readout percent (100 = fit baseline). */ @@ -141,6 +146,7 @@ export const GraphView = forwardRef( layoutKey, minimapOpen, initialLayoutMode, + searchMatches, onCyReadyChange, onZoomPercentChange, onLayoutModeChange, @@ -169,6 +175,12 @@ export const GraphView = forwardRef( const findingsRef = useRef(findings); findingsRef.current = findings; + // searchMatchesRef: read by applySearchClasses (defined inside cy-init, + // wired to applySearchClassesRef below) so the reapply effect below never + // needs the cy-init effect to re-run when the term changes mid-typing. + const searchMatchesRef = useRef | null>(searchMatches); + searchMatchesRef.current = searchMatches; + // minimapOpenRef: read by cy effect on mount (avoid adding minimapOpen to deps). const minimapOpenRef = useRef(minimapOpen); minimapOpenRef.current = minimapOpen; @@ -195,6 +207,9 @@ export const GraphView = forwardRef( const exitLineageHoverRef = useRef<(() => void) | null>(null); // Wired inside cy-init to the local redrawMarkers closure (clears + redraws badges). const redrawMarkersRef = useRef<(() => void) | null>(null); + // Wired inside cy-init to the local applySearchClasses closure (search-match/ + // search-dim class application, keyed off searchMatchesRef). + const applySearchClassesRef = useRef<(() => void) | null>(null); const cyZoomInRef = useRef<(() => void) | null>(null); const cyZoomOutRef = useRef<(() => void) | null>(null); const cySetPercentRef = useRef<((pct: number) => void) | null>(null); @@ -254,6 +269,17 @@ export const GraphView = forwardRef( redrawMarkersRef.current?.(); }, [findings.entityErrors, isActive]); // eslint-disable-line react-hooks/exhaustive-deps + // ── Search class reapply (graph-flow-search CP2, SC1/SC4) ────────────── + // Runs on every searchMatches change (live as the shell debounces the + // term) AND whenever cyReady flips true — which covers the cy-rebuild + // paths (SSE model refresh, initial mount) where the fresh cy instance + // starts with no search-* classes and must have the ACTIVE term reapplied + // without the user retyping it (SC4). + useEffect(() => { + if (!isActive || !cyReady) return; + applySearchClassesRef.current?.(); + }, [searchMatches, isActive, cyReady]); // eslint-disable-line react-hooks/exhaustive-deps + // ── Navigator toggle effect (CP18) ───────────────────────────────────── // Runs on minimapOpen, cyReady, and isActive changes. // Gated on isActive so the navigator is torn down when leaving graph view, @@ -694,6 +720,31 @@ export const GraphView = forwardRef( }; redrawMarkersRef.current = redrawMarkers; + // ── Graph search classes (graph-flow-search CP2) ─────────────────────── + // search-match / search-dim are DEDICATED classes, distinct from the + // hover-tier classes (faded/inherited-dim/hover-focus) and the ephemeral + // .inherited lineage class — clearFocusTiers/clearInheritedEdges never + // touch them, so hover, shift-lineage, background tap, and layout + // changes cannot erase active search dimming (SC4). Reads the match set + // from searchMatchesRef (never stale — mirrored every render), so the + // reapply effect above can call this without the cy-init effect + // re-running on every keystroke. + function applySearchClasses(): void { + if (cy.destroyed()) return; + cy.elements().removeClass('search-match search-dim'); + const matches = searchMatchesRef.current; + if (!matches) return; // no active search — leave every element undimmed + const matchedNodes = cy.nodes().filter(n => matches.has(n.id())); + const unmatchedNodes = cy.nodes().difference(matchedNodes); + matchedNodes.addClass('search-match'); + unmatchedNodes.addClass('search-dim'); + // An edge stays undimmed only when BOTH endpoints match (SC1). + cy.edges() + .filter(e => !(matches.has(e.source().id()) && matches.has(e.target().id()))) + .addClass('search-dim'); + } + applySearchClassesRef.current = applySearchClasses; + // Tracks the last hash string we wrote ourselves, to break the hashchange feedback loop. let lastWrittenHash = ''; @@ -1362,6 +1413,7 @@ export const GraphView = forwardRef( resetLayoutRef.current = null; applyLayoutModeRef.current = null; redrawMarkersRef.current = null; + applySearchClassesRef.current = null; cyZoomInRef.current = null; cyZoomOutRef.current = null; cySetPercentRef.current = null; diff --git a/src/app/views/graph/styles.ts b/src/app/views/graph/styles.ts index 471cedd..e02e0ed 100644 --- a/src/app/views/graph/styles.ts +++ b/src/app/views/graph/styles.ts @@ -4,6 +4,17 @@ import type { GroupConfig } from '../../../model/parse'; import { blendHex, pastel, lighten } from '../../logic/color'; import { SPOTLIGHT_LINE_INHERITED } from '../../dom/theme-css-vars'; +/** + * Graph search match border (graph-flow-search CP2). Distinct gold/yellow so + * it never reads as the amber "spotlight-line-out" lineage color or a group's + * border color — mirrors the Dictionary view's `--dd-search-highlight` yellow + * so "search" reads as the same visual language across surfaces. + */ +const SEARCH_MATCH_BORDER: Record = { + dark: '#fde047', + light: '#ca8a04', +}; + export function buildStyles(groups: Record, theme: ThemeConfig, mode: ThemeMode): cytoscape.Stylesheet[] { const p = mode === 'light' ? theme.light : theme.dark; const defaultNodeBg = pastel(p.textMuted, p.background, p.pastelMix); @@ -209,5 +220,24 @@ export function buildStyles(groups: Record, theme: ThemeCon }, }); + // Graph search (graph-flow-search CP2). Dedicated classes — distinct from + // the hover-tier classes above — so `clearFocusTiers` never strips active + // search dimming. Pushed LAST so they win the cascade over the span-graded + // edge opacity (`edge[span=...]`, declared early in `base`) and the + // per-group border-color rules, matching the precedence the file already + // gives `.faded`/`.inherited-dim` over span grading. + base.push({ + selector: '.search-dim', + style: { 'opacity': 0.2 }, + }); + + base.push({ + selector: 'node.search-match', + style: { + 'border-width': 3, + 'border-color': SEARCH_MATCH_BORDER[mode], + }, + }); + return base; } diff --git a/test/checks/test-graph-search.ts b/test/checks/test-graph-search.ts new file mode 100644 index 0000000..2aa1bf8 --- /dev/null +++ b/test/checks/test-graph-search.ts @@ -0,0 +1,310 @@ +/** + * test-graph-search.ts — CI-runnable Playwright check for the Graph search + * bar (graph-flow-search CP2): dim/highlight classes, count readout, Enter + * cycling, body-toggle opt-in, hover/layout-mode survival, and the SC9 + * no-persistence guarantee. + * + * Serves models/key-inherited, which has Party + PartyType (a two-entity + * title match by id) and Person, whose markdown body contains "honest" + * nowhere in any entity id — a clean body-only match fixture. + * + * Skips gracefully (exit 0) when dist/static/index.js is absent — CI builds + * before running checks. + */ + +import { chromium } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const MODEL = join(ROOT, 'models/key-inherited'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); + +if (!existsSync(BUNDLE)) { + console.log('SKIP: dist/static/index.js not built (run `bun run build:bundle`). CI builds it before checks.'); + process.exit(0); +} + +let failures = 0; + +function assert(cond: boolean, label: string, detail?: string): void { + if (cond) { + console.log(` PASS ${label}`); + } else { + console.error(` FAIL ${label}${detail ? `\n ${detail}` : ''}`); + failures++; + } +} + +const PORT = 3297; +const handle = serveCommand(MODEL, { port: PORT }); +await new Promise(r => setTimeout(r, 400)); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + +try { + await page.goto(`http://localhost:${PORT}/#view=graph`, { waitUntil: 'load' }); + await page.waitForSelector('.graph-panel canvas', { timeout: 20_000 }); + await page.waitForSelector('.viewer-search-bar--graph', { timeout: 10_000 }); + await page.waitForTimeout(1500); // let the initial layout settle + + const totalEntities = await page.evaluate(() => window.__IGNATIUS_CY__!.nodes('[classification]').length); + assert(totalEntities > 0, 'model has entity nodes to search', `got ${totalEntities}`); + + // --------------------------------------------------------------------- + // SC9 (baseline) — capture hash + persisted layout positions BEFORE any + // search interaction. Re-checked below after typing/toggling (not after + // Enter, which legitimately writes pan/zoom via the pre-existing + // navigateToEntity → cy.center() path — unrelated to search). + // --------------------------------------------------------------------- + const hashBefore = await page.evaluate(() => location.hash); + const layoutPositionsBefore = await page.evaluate(() => localStorage.getItem('ignatius-layout-positions')); + + // --------------------------------------------------------------------- + // SC1 + SC2 — "Party" matches Party + PartyType by id (verified against + // the fixture: no other entity id in models/key-inherited contains + // "party", and PaymentMethod — which FKs to Party — does not match). + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', 'Party'); + await page.waitForTimeout(400); + + const afterParty = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__!; + const entities = cy.nodes('[classification]'); + const matched = entities.filter(n => n.hasClass('search-match')); + const dimmed = entities.filter(n => n.hasClass('search-dim')); + const bothMatchEdge = cy.edges().filter(e => + (e.source().id() === 'Party' && e.target().id() === 'PartyType') || + (e.source().id() === 'PartyType' && e.target().id() === 'Party')); + const mixedEdge = cy.edges().filter(e => + (e.source().id() === 'Party' && e.target().id() === 'PaymentMethod') || + (e.source().id() === 'PaymentMethod' && e.target().id() === 'Party')); + return { + matchedCount: matched.length, + dimmedCount: dimmed.length, + partyMatch: cy.$id('Party').hasClass('search-match'), + partyTypeMatch: cy.$id('PartyType').hasClass('search-match'), + bothMatchEdgeCount: bothMatchEdge.length, + bothMatchEdgeDimmed: bothMatchEdge.hasClass('search-dim'), + mixedEdgeCount: mixedEdge.length, + mixedEdgeDimmed: mixedEdge.hasClass('search-dim'), + }; + }); + + assert(afterParty.matchedCount === 2, 'SC1: search-match applied to exactly 2 entities (Party, PartyType)', `got ${afterParty.matchedCount}`); + assert(afterParty.dimmedCount === totalEntities - 2, 'SC1: search-dim applied to every non-matching entity', `dimmed=${afterParty.dimmedCount} total=${totalEntities}`); + assert(afterParty.partyMatch, 'SC1: Party carries search-match'); + assert(afterParty.partyTypeMatch, 'SC1: PartyType carries search-match'); + assert(afterParty.bothMatchEdgeCount > 0, 'fixture sanity: a Party↔PartyType edge exists'); + assert(!afterParty.bothMatchEdgeDimmed, 'SC1: Party↔PartyType edge (both endpoints match) stays undimmed'); + assert(afterParty.mixedEdgeCount > 0, 'fixture sanity: a Party↔PaymentMethod edge exists'); + assert(afterParty.mixedEdgeDimmed, 'SC1: Party↔PaymentMethod edge (one endpoint matches) is dimmed'); + + const countText = await page.locator('.viewer-search-count').textContent(); + assert(countText === `2 of ${totalEntities}`, 'SC2: count readout tracks the match set', `got "${countText}"`); + + // --------------------------------------------------------------------- + // SC9 — hash/localStorage untouched by typing + toggling (no Enter yet). + // --------------------------------------------------------------------- + await page.click('.viewer-search-body-toggle'); // on + await page.waitForTimeout(200); + await page.click('.viewer-search-body-toggle'); // back off + await page.waitForTimeout(200); + + const hashAfterSearch = await page.evaluate(() => location.hash); + const layoutPositionsAfterSearch = await page.evaluate(() => localStorage.getItem('ignatius-layout-positions')); + assert(hashAfterSearch === hashBefore, 'SC9: URL hash unchanged while searching', `before="${hashBefore}" after="${hashAfterSearch}"`); + assert(layoutPositionsAfterSearch === layoutPositionsBefore, 'SC9: ignatius-layout-positions unchanged while searching'); + + // --------------------------------------------------------------------- + // SC3 — Enter cycles ascending-id matches, wrapping ("Party" < "PartyType"). + // --------------------------------------------------------------------- + async function pressEnterAndGetSelection(): Promise { + await page.locator('.viewer-search-input').focus(); + await page.keyboard.press('Enter'); + await page.waitForTimeout(300); + return page.evaluate(() => window.__IGNATIUS_CY__!.nodes(':selected').map(n => n.id())); + } + + let selected = await pressEnterAndGetSelection(); + assert(selected.length === 1 && selected[0] === 'Party', 'SC3: first Enter selects "Party" (ascending id order)', JSON.stringify(selected)); + + selected = await pressEnterAndGetSelection(); + assert(selected.length === 1 && selected[0] === 'PartyType', 'SC3: second Enter selects "PartyType"', JSON.stringify(selected)); + + selected = await pressEnterAndGetSelection(); + assert(selected.length === 1 && selected[0] === 'Party', 'SC3: third Enter wraps back to "Party"', JSON.stringify(selected)); + + // --------------------------------------------------------------------- + // SC4 — hover in/out does not erase active search dimming. + // --------------------------------------------------------------------- + await page.evaluate(() => window.__IGNATIUS_CY__!.$id('Person').emit('mouseover')); + await page.waitForTimeout(200); + await page.evaluate(() => window.__IGNATIUS_CY__!.$id('Person').emit('mouseout')); + await page.waitForTimeout(200); + + const afterHover = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__!; + const entities = cy.nodes('[classification]'); + return { + matched: entities.filter(n => n.hasClass('search-match')).length, + dimmed: entities.filter(n => n.hasClass('search-dim')).length, + }; + }); + assert( + afterHover.matched === 2 && afterHover.dimmed === totalEntities - 2, + 'SC4: hover in/out leaves search classes intact', + JSON.stringify(afterHover), + ); + + // Layout-mode toggle ('l' key) — blur the search input first so the + // shortcut isn't swallowed by the editable guard. has no tabindex, + // so focusing it is a no-op when an input is currently focused — blur the + // active element instead. + await page.evaluate(() => { + const active = document.activeElement; + if (active instanceof HTMLElement) active.blur(); + }); + const layoutModeBefore = await page.evaluate(() => localStorage.getItem('ignatius-layout-mode') ?? 'organic'); + await page.keyboard.press('l'); + await page.waitForFunction( + (before: string) => (localStorage.getItem('ignatius-layout-mode') ?? 'organic') !== before, + layoutModeBefore, + { timeout: 5000 }, + ); + await page.waitForTimeout(1500); // let the relayout settle + + const afterLayoutToggle = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__!; + const entities = cy.nodes('[classification]'); + return { + matched: entities.filter(n => n.hasClass('search-match')).length, + dimmed: entities.filter(n => n.hasClass('search-dim')).length, + }; + }); + assert( + afterLayoutToggle.matched === 2 && afterLayoutToggle.dimmed === totalEntities - 2, + 'SC4: layout-mode toggle leaves search classes intact', + JSON.stringify(afterLayoutToggle), + ); + + // --------------------------------------------------------------------- + // SC5 (graph half) — body toggle opt-in: "honest" appears only in + // Person's markdown body, never in any entity id in this fixture. + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', 'honest'); + await page.waitForTimeout(400); + + const bodyOffMatches = await page.evaluate(() => + window.__IGNATIUS_CY__!.nodes('[classification]').filter(n => n.hasClass('search-match')).length); + assert(bodyOffMatches === 0, 'SC5: body-only term does not match with the body toggle off', `got ${bodyOffMatches}`); + + await page.click('.viewer-search-body-toggle'); // on + await page.waitForTimeout(200); + + const bodyOn = await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__!; + return { + personMatch: cy.$id('Person').hasClass('search-match'), + matchCount: cy.nodes('[classification]').filter(n => n.hasClass('search-match')).length, + }; + }); + assert(bodyOn.personMatch && bodyOn.matchCount === 1, 'SC5: toggling body on matches Person via its prose body', JSON.stringify(bodyOn)); + + // --------------------------------------------------------------------- + // Clear restores everything. + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', ''); + await page.waitForTimeout(400); + + const clearedCount = await page.evaluate(() => window.__IGNATIUS_CY__!.elements('.search-match, .search-dim').length); + assert(clearedCount === 0, 'clearing the term removes every search-match/search-dim class', `got ${clearedCount} still classed`); + + const countReadoutGone = await page.locator('.viewer-search-count').count(); + assert(countReadoutGone === 0, 'count readout hides once the term is cleared', `got ${countReadoutGone} elements`); + +} catch (err) { + console.error('FAIL:', err instanceof Error ? err.message : String(err)); + failures++; +} finally { + await page.close(); + await browser.close(); + handle.stop(); +} + +// --------------------------------------------------------------------------- +// Banner-collision regression — .viewer-search-bar was fully occluded by +// .graph-global-banner (full-width, top:0, z-index:200) whenever a model has +// global validation errors. models/broken-demo always renders that banner +// (parse.invalid_yaml, parse.missing_id, parse.empty_frontmatter), so it's +// the fixture for pinning "the bar sits below the banner, never under it". +// --------------------------------------------------------------------------- + +const BANNER_MODEL = join(ROOT, 'models/broken-demo'); +const BANNER_PORT = 3303; +const bannerHandle = serveCommand(BANNER_MODEL, { port: BANNER_PORT }); +await new Promise(r => setTimeout(r, 400)); + +const bannerBrowser = await chromium.launch(); +const bannerPage = await bannerBrowser.newPage({ viewport: { width: 1440, height: 900 } }); + +try { + await bannerPage.goto(`http://localhost:${BANNER_PORT}/#view=graph`, { waitUntil: 'load' }); + await bannerPage.waitForSelector('.graph-panel canvas', { timeout: 20_000 }); + await bannerPage.waitForSelector('.graph-global-banner', { timeout: 10_000 }); + await bannerPage.waitForSelector('.viewer-search-bar--graph', { timeout: 10_000 }); + await bannerPage.waitForTimeout(500); // let the banner-offset layout effect settle + + const barBox = await bannerPage.locator('.viewer-search-bar--graph').boundingBox(); + const bannerBox = await bannerPage.locator('.graph-global-banner').boundingBox(); + assert(barBox !== null, 'banner-collision: search bar has a bounding box'); + assert(bannerBox !== null, 'banner-collision: banner has a bounding box'); + + if (barBox && bannerBox) { + const intersects = + barBox.x < bannerBox.x + bannerBox.width && + barBox.x + barBox.width > bannerBox.x && + barBox.y < bannerBox.y + bannerBox.height && + barBox.y + barBox.height > bannerBox.y; + assert( + !intersects, + 'banner-collision: search bar bounding box does not intersect the banner', + `bar=${JSON.stringify(barBox)} banner=${JSON.stringify(bannerBox)}`, + ); + assert( + barBox.y >= bannerBox.y + bannerBox.height, + 'banner-collision: search bar sits fully below the banner', + `bar.y=${barBox.y} banner.bottom=${bannerBox.y + bannerBox.height}`, + ); + } + + // Playwright's click() actionability check fails if the target point is + // covered by another element (e.g. the banner sitting on top of the bar) — + // a successful click + type proves the bar is visible AND clickable, not + // just geometrically clear. + await bannerPage.locator('.viewer-search-input').click(); + await bannerPage.keyboard.type('Order'); + await bannerPage.waitForTimeout(400); + const typedValue = await bannerPage.locator('.viewer-search-input').inputValue(); + assert( + typedValue === 'Order', + 'banner-collision: search input is visible and clickable with the banner shown', + `got "${typedValue}"`, + ); +} catch (err) { + console.error('FAIL:', err instanceof Error ? err.message : String(err)); + failures++; +} finally { + await bannerPage.close(); + await bannerBrowser.close(); + bannerHandle.stop(); +} + +if (failures > 0) { + console.error(`\n${failures} failure(s).`); + process.exit(1); +} +console.log('\nCP2 graph-search: all assertions passed.'); +process.exit(0); diff --git a/test/visual/test-graph-search.ts b/test/visual/test-graph-search.ts new file mode 100644 index 0000000..16eb988 --- /dev/null +++ b/test/visual/test-graph-search.ts @@ -0,0 +1,88 @@ +/** + * Visual verification: Graph search (graph-flow-search CP2). + * + * Types "Party" into the Graph search bar and screenshots the result: + * Party + PartyType highlighted (search-match), every other entity dimmed + * (search-dim), and the "n of N" count readout visible. + * + * Run: bun test/visual/test-graph-search.ts + * + * NOT run by `bun run test` — visual screenshot only. Requires: + * - Playwright chromium installed + * - models/key-inherited present + */ + +import { chromium } from 'playwright'; +import { resolve, join } from 'path'; +import { mkdirSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const TMP = join(ROOT, 'tmp', 'graph-search'); +mkdirSync(TMP, { recursive: true }); + +const PORT = 7457; +const BASE = `http://localhost:${PORT}`; + +const note = (m: string) => console.log(m); +const fail = (m: string): never => { console.error('FAIL:', m); process.exit(1); }; + +note('Starting server for models/key-inherited…'); +const MODEL = join(ROOT, 'models/key-inherited'); +const serverHandle = serveCommand(MODEL, { port: PORT }); + +async function waitForServer(url: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return true; + } catch {} + await new Promise(r => setTimeout(r, 250)); + } + return false; +} + +const serverReady = await waitForServer(BASE, 15_000); +if (!serverReady) fail('Server did not start within 15 seconds'); +note(`Server ready at ${BASE}`); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); + +async function shot(name: string): Promise { + const p = join(TMP, name); + await page.screenshot({ path: p, fullPage: false }); + note(` Screenshot: ${p}`); + return p; +} + +note('Loading app on graph view…'); +await page.goto(`${BASE}/#view=graph`, { waitUntil: 'load' }); +await page.waitForSelector('.graph-panel canvas', { timeout: 20_000 }); +await page.waitForSelector('.viewer-search-bar--graph', { timeout: 10_000 }); +await page.waitForTimeout(1500); + +await shot('01-graph-no-search.png'); +note(' Captured graph before any search.'); + +note('Typing "Party" into the search bar…'); +await page.fill('.viewer-search-input', 'Party'); +await page.waitForTimeout(500); // debounce + reapply + +const readout = await page.locator('.viewer-search-count').textContent(); +note(` Count readout: "${readout}"`); + +await shot('02-graph-search-active.png'); +note(' Captured active search: Party/PartyType highlighted, rest dimmed.'); + +await browser.close(); +serverHandle.stop(); + +note('\nVisual check complete.'); +note('Review 02-graph-search-active.png — it should show:'); +note(' - Party and PartyType with a gold border (search-match)'); +note(' - Every other entity + non-connecting edges dimmed to ~0.2 opacity'); +note(' - The "n of N" count readout in the search bar'); +process.exit(0); From 3c4d52082df9665bcf16e2ed9863ad82bb8f33d3 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 05:39:43 -0400 Subject: [PATCH 04/13] feat(flow): cross-diagram search with results dropdown Search spans every non-synthetic diagram including sub-DFDs; grouped dropdown rows navigate via selectDiagramById with the full breadcrumb path, and the rendered diagram dims non-matches by base token (role- split suffixes stripped) with hover taking precedence while active. Tokens update live without tearing down the SVG renderer. CP3 of docs/spec/graph-flow-search.md. --- src/app/App.tsx | 73 ++++- src/app/components/flow/FlowSearchResults.tsx | 73 +++++ src/app/styles.css | 84 ++++++ src/app/views/flow/FlowsView.tsx | 51 ++++ src/flow-view/FlowDiagramSvg.tsx | 38 ++- test/checks/test-flow-search.ts | 255 ++++++++++++++++++ test/visual/test-flow-search.ts | 86 ++++++ 7 files changed, 651 insertions(+), 9 deletions(-) create mode 100644 src/app/components/flow/FlowSearchResults.tsx create mode 100644 test/checks/test-flow-search.ts create mode 100644 test/visual/test-flow-search.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index c2d4b79..3ab3b79 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,11 +14,12 @@ import { buildModelIndex } from '../model/model-index'; import type { ModelIndex } from '../model/model-index'; import { hexToRgba } from './logic/color'; import { buildAllFlowNodeIds } from './logic/flow-node-ids'; -import { entityMatches } from './logic/search'; +import { entityMatches, searchFlowDiagrams } from './logic/search'; import { Modal } from './components/ui/Modal'; import { HelpModal } from './components/ui/HelpModal'; import { ZoomControl } from './components/ui/ZoomControl'; import { SearchBar } from './components/ui/SearchBar'; +import { FlowSearchResults } from './components/flow/FlowSearchResults'; import { EntityModal } from './components/entity/EntityModal'; import { FindingsPanel } from './components/findings/FindingsPanel'; import { LegendModal } from './views/flow/LegendModal'; @@ -116,6 +117,11 @@ export function App() { // Ascending-id cursor for Enter-to-cycle (SC3) — reset whenever the term or // body toggle changes so the next Enter always starts from the first match. const graphSearchCursorRef = useRef(-1); + // Flow search state (graph-flow-search CP3) — term + includeBody, survives + // view switches the same way graphSearchTerm does (shell-owned, FlowsView + // stays mounted-but-inactive across view switches). + const [flowSearchTerm, setFlowSearchTerm] = useState(''); + const [flowSearchIncludeBody, setFlowSearchIncludeBody] = useState(false); // Pending scroll target: set by onNavigateToProcess, consumed by the view-switch // useEffect once the dict container is visible and the process anchor exists. const pendingScrollProcessIdRef = useRef(null); @@ -200,6 +206,15 @@ export function App() { document.title = model?._meta?.name ?? 'Ignatius'; }, [model]); + // Whether the flow surface is active — hoisted early since it (and + // bannerVisible below) are read by both the banner-offset effect and the + // render's banner gate; a single derivation keeps them from diverging (F-1). + const isFlowSurface = view === 'flow'; + // Global-error banner visibility: hidden on the flow surface (F-1 — this + // used to be re-derived independently by the layout effect below and by + // the render gate; one shared value now feeds both). + const bannerVisible = !bannerDismissed && findings.globalErrors.length > 0 && !isFlowSurface; + // Offset the graph search bar below the global-error banner (fixes the bar // being fully occluded by the full-width, higher-z-index banner). Measures // the banner's REAL rendered height via ResizeObserver rather than a fixed @@ -210,7 +225,6 @@ export function App() { // in styles.css) rather than passed as a prop, since SearchBar renders // `.viewer-search-bar` directly with no style override slot. useLayoutEffect(() => { - const bannerVisible = !bannerDismissed && findings.globalErrors.length > 0 && view !== 'flow'; const el = bannerRef.current; const root = document.documentElement; if (!bannerVisible || !el) { @@ -228,7 +242,7 @@ export function App() { ro.disconnect(); root.style.removeProperty('--search-bar-top'); }; - }, [bannerDismissed, findings.globalErrors.length, view]); + }, [bannerVisible]); function toggleMinimapOpen() { const next = !minimapOpen; @@ -361,6 +375,34 @@ export function App() { graphViewRef.current?.navigateToEntity(nextId); } + // Flow search (graph-flow-search CP3, SC6/SC7): cross-diagram match list via + // CP1's searchFlowDiagrams, recomputed whenever the term/toggle/diagram set + // changes. flowSearchActive gates both the dropdown (SearchBar's results + // slot) and the token set threaded into FlowsView → FlowDiagramSvg for + // in-diagram dimming — same "null = inactive, Set = active" contract as + // the graph search above (SC9: never touches the model, layout store, or hash). + const flowSearchActive = flowSearchTerm.trim() !== ''; + const flowSearchResults = useMemo(() => { + if (!flowDiagrams || !flowSearchActive) return []; + return searchFlowDiagrams(flowDiagrams, flowSearchTerm.trim(), flowSearchIncludeBody); + }, [flowDiagrams, flowSearchActive, flowSearchTerm, flowSearchIncludeBody]); + const flowSearchTokens = useMemo( + () => (flowSearchActive ? new Set(flowSearchResults.map(r => r.token)) : null), + [flowSearchActive, flowSearchResults], + ); + + // Row click / Enter routes through the existing selectDiagramById, which + // already reconstructs the full breadcrumb path into any sub-DFD — no new + // navigation machinery needed. Enter opens the first result (the keyboard + // path to the top match). + function handleFlowSearchSelect(diagramId: string) { + flowsViewRef.current?.selectDiagramById(diagramId); + } + function handleFlowSearchEnter() { + const first = flowSearchResults[0]; + if (first) flowsViewRef.current?.selectDiagramById(first.diagramId); + } + // Deep-link modal restore: a URL loaded with entity= opens that modal once // the model (and its index) is available. One-shot — guarded by a ref so an // SSE model refetch does not re-open a modal the user already closed. Works @@ -413,13 +455,10 @@ export function App() { // read the live index without re-running effects or causing stale-closure bugs. modelIndexRef.current = modelIndex; - const showBanner = !bannerDismissed && findings.globalErrors.length > 0; - const isFlowSurface = view === 'flow'; - return (
{/* ── ERD surface chrome (hidden on flow surface) ── */} - {!isFlowSurface && showBanner && ( + {bannerVisible && (
+ ))} +
+ ))} + {overflow > 0 && ( +
+{overflow} more
+ )} +
+ ); +} diff --git a/src/app/styles.css b/src/app/styles.css index 0c3bb5c..4f36430 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1248,6 +1248,90 @@ code { white-space: nowrap; } +/* graph-flow-search CP3: Flows results dropdown, rendered into the SearchBar's + results slot below .viewer-search-bar-inner. Same frosted-pill language as + the bar itself. Nested under .viewer-search-bar, so it's already covered by + that selector's @media print rule (no separate print-hidden entry needed). */ +.viewer-search-results { + margin-top: 6px; + width: 320px; + max-height: 360px; + overflow-y: auto; + border-radius: 8px; + border: 1px solid var(--color-border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + background-color: color-mix(in srgb, var(--color-background) 90%, transparent); +} + +.viewer-search-results-group + .viewer-search-results-group { + border-top: 1px solid var(--color-border); +} + +.viewer-search-results-group-title { + padding: 6px 10px 2px; + font-size: 0.72rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.viewer-search-result-row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.35rem 0.65rem; + background: none; + border: none; + color: var(--color-text); + font-size: 0.85rem; + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.viewer-search-result-row:hover, +.viewer-search-result-row:focus-visible { + background: var(--color-surface-alt); +} + +.viewer-search-result-kind { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2rem; + height: 1.2rem; + border-radius: 3px; + font-size: 0.68rem; + font-weight: 700; + background: var(--color-surface-alt); + border: 1px solid var(--color-border); +} + +.viewer-search-result-dotted { + flex: none; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); +} + +.viewer-search-result-label { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-search-results-overflow { + padding: 0.35rem 0.65rem; + font-size: 0.78rem; + color: var(--color-text-muted); +} + /* CP9: DD search highlight — CSS Custom Highlight API pseudo-element. The --dd-search-highlight var is set per-mode by applyThemeCssVars at runtime (authoritative). These fallbacks cover surfaces that don't call it (e.g. static diff --git a/src/app/views/flow/FlowsView.tsx b/src/app/views/flow/FlowsView.tsx index c8e0e19..b7e0f5e 100644 --- a/src/app/views/flow/FlowsView.tsx +++ b/src/app/views/flow/FlowsView.tsx @@ -63,6 +63,13 @@ export interface FlowChromeCallbacks { * the selected DFD and drill-down stack. */ onRegisterRetheme?: (fn: ((mode: 'dark' | 'light') => void) | null) => void; + /** + * Called once to register a function that applies a live search-token set + * (graph-flow-search CP3) to the current diagram without rebuilding the + * renderer — preserves the selected DFD and drill-down stack the same way + * onRegisterRetheme does. Calling it with null clears active search dimming. + */ + onRegisterSearchTokens?: (fn: ((tokens: ReadonlySet | null) => void) | null) => void; /** * Called on every diagram render — top-level select, drill-down, and drill-up. * The id is the diagram being rendered (the current top of the breadcrumb stack). @@ -210,6 +217,11 @@ export function initFlowGraphCore( getThemeConfig?: () => ThemeConfig | undefined, // CP21: token-keyed usage index so ext/store dialogs can render Processes section. nodeUsageIndex?: ReadonlyMap, + // graph-flow-search CP3: initial search-token set for in-diagram dimming. + // Kept live via chromeCallbacks.onRegisterSearchTokens (see currentSearchTokens + // below) rather than a dependency-array rebuild, so typing a search term never + // tears down the renderer — that would lose the drill-down stack. + initialSearchTokens?: ReadonlySet | null, ): () => void { // Resolver for node ⓘ docs + in-dialog [[links]]. The entityModel parameter // may be a getter (() => Model | undefined) so the resolver always reads the @@ -239,6 +251,12 @@ export function initFlowGraphCore( // The current diagram is always the last entry. const stack: Array<{ diagram: FlowDiagram; label: string }> = []; + // Live search-token set (graph-flow-search CP3). Updated in place by + // chromeCallbacks.onRegisterSearchTokens without rebuilding the renderer — + // survives drill-down/selectDiagramById since it lives in this closure, + // outside renderDiagram. + let currentSearchTokens: ReadonlySet | null = initialSearchTokens ?? null; + // Current palette mode. Updated by retheme() without rebuilding the renderer. let currentThemeMode: 'dark' | 'light' = initialThemeMode; @@ -383,6 +401,7 @@ export function initFlowGraphCore( svgProps: { diagram, kindPalette, + searchTokens: currentSearchTokens, onDrill: handleDrill, onReady: () => { window.__IGNATIUS_FLOW_READY__ = true; @@ -421,6 +440,14 @@ export function initFlowGraphCore( doRender(mode); }); + // Register the search-tokens hook — same live-update pattern as retheme: + // re-renders with the new match set without unmounting, so the drill-down + // stack and breadcrumb position survive every keystroke. + chromeCallbacks?.onRegisterSearchTokens?.((tokens) => { + currentSearchTokens = tokens; + doRender(currentThemeMode); + }); + // Notify the app that the active diagram changed so it can update the URL hash. chromeCallbacks?.onActiveDiagramChange?.(diagram.id); @@ -519,6 +546,7 @@ export function initFlowGraphCore( return () => { chromeCallbacks?.onResetLayout?.(null); chromeCallbacks?.onRegisterRetheme?.(null); + chromeCallbacks?.onRegisterSearchTokens?.(null); if (svgRoot) { svgRoot.unmount(); svgRoot = null; @@ -585,6 +613,12 @@ export interface FlowsViewProps { onActiveDiagramChange?: (id: string, isInitial: boolean) => void; /** Called whenever the flow ZoomControl readout changes. */ onZoomPercentChange: (pct: number) => void; + /** + * Match-set for graph-flow-search CP3: base tokens that keep full opacity in + * the rendered diagram, threaded through to FlowDiagramSvg's searchTokens + * prop. null = no active search (no dimming from this source). + */ + searchTokens: ReadonlySet | null; } // --------------------------------------------------------------------------- @@ -603,6 +637,7 @@ export const FlowsView = forwardRef( onOpenEntity, onActiveDiagramChange = undefined, onZoomPercentChange, + searchTokens, }, ref, ) { @@ -612,6 +647,8 @@ export const FlowsView = forwardRef( const flowSelectDiagramRef = useRef<((id: string) => void) | null>(null); // Retheme callback: updates the flow SVG palette without tearing down the renderer. const flowRethemeRef = useRef<((mode: 'dark' | 'light') => void) | null>(null); + // Search-tokens callback: applies live dimming without tearing down the renderer. + const flowSearchTokensRef = useRef<((tokens: ReadonlySet | null) => void) | null>(null); // Flow reset layout: registered by initFlowGraphCore so the FAB can trigger it. const flowResetLayoutRef = useRef<(() => void) | null>(null); // Tracks the currently selected top-level DFD id across SSE re-renders. @@ -707,6 +744,9 @@ export const FlowsView = forwardRef( onRegisterRetheme: (fn) => { flowRethemeRef.current = fn; }, + onRegisterSearchTokens: (fn) => { + flowSearchTokensRef.current = fn; + }, onActiveDiagramChange: (id) => { activeFlowDiagramIdRef.current = id; window.__IGNATIUS_ACTIVE_FLOW_DFD__ = id; @@ -783,11 +823,14 @@ export const FlowsView = forwardRef( (open) => { flowOpenRef.current = open; }, getThemeConfig, nodeUsageIndex, + searchTokens, ); return cleanup; }, [isActive, flowDiagrams]); // eslint-disable-line react-hooks/exhaustive-deps // Note: themeMode intentionally excluded — retheme is handled by the separate effect below. // onOpenEntity, getEntityModel, getThemeConfig are stable refs/callbacks — not deps. + // searchTokens intentionally excluded — live updates handled by the separate + // effect below (mirrors themeMode/retheme); this call only seeds the initial value. // Re-theme the flow SVG whenever the theme changes while the flow view is active. // Uses the registered retheme callback which calls root.render() with the new @@ -796,6 +839,14 @@ export const FlowsView = forwardRef( if (isActive) flowRethemeRef.current?.(themeMode); }, [themeMode, isActive]); + // Apply the live search-token set (graph-flow-search CP3) whenever it + // changes while the flow view is active. Uses the registered callback + // which calls root.render() in place — no renderer rebuild, so the + // drill-down stack survives every keystroke. + useEffect(() => { + if (isActive) flowSearchTokensRef.current?.(searchTokens); + }, [searchTokens, isActive]); + // FlowsView renders only the FlowChrome overlay and leaves the actual diagram // DOM managed by the imperative initFlowGraphCore inside the containerRef. // FlowChrome is gated on isActive to match the original `isFlowSurface &&` guard. diff --git a/src/flow-view/FlowDiagramSvg.tsx b/src/flow-view/FlowDiagramSvg.tsx index 7c918f6..41adeb6 100644 --- a/src/flow-view/FlowDiagramSvg.tsx +++ b/src/flow-view/FlowDiagramSvg.tsx @@ -185,6 +185,17 @@ function truncateLabel(label: string, maxChars: number): string { return label.slice(0, maxChars - 1) + '…'; } +/** + * Strips the role-split layout suffixes (external src/snk routing copies, + * split store read/write copies — see flow-layout.ts's `ext:--src/--snk` + * and `:--read/--write` id schemes) so a node/edge-endpoint id + * compares equal to the base token search.ts's searchFlowDiagrams produces + * (graph-flow-search CP3, SC7). + */ +function baseToken(id: string): string { + return id.replace(/--(src|snk|read|write)$/, ''); +} + function storeWidth(name: string): number { // Reserve a right-side slot for the ⓘ badge so it never overlaps the name. const bodyW = Math.max(STORE_BODY_W, measureText(name, 11.5) + STORE_INFO_PAD); @@ -829,6 +840,13 @@ export type FlowDiagramSvgProps = { zoomTo(scale: number): void; resetFit(): void; } | null) => void; + /** + * Match-set for graph-flow-search CP3: base tokens (role-split --src/--snk/ + * --read/--write suffixes stripped, see baseToken) that stay at full opacity + * when no hover is active. null = no active search (no dimming from this + * source). A pointer hover always wins over search dimming while it's active. + */ + searchTokens?: ReadonlySet | null; }; export function FlowDiagramSvg({ @@ -846,6 +864,7 @@ export function FlowDiagramSvg({ onRegisterPanTo, onZoomChange, onRegisterZoomControl, + searchTokens = null, }: FlowDiagramSvgProps) { // Select palette based on current theme. const c = themeMode === 'light' ? LIGHT_PALETTE : DARK_PALETTE; @@ -1493,8 +1512,23 @@ export function FlowDiagramSvg({ } focus = { nodes: fNodes, edges: fEdges }; } - const nodeOpacity = (id: string) => (!focus || focus.nodes.has(id) ? 1 : DIM_OPACITY); - const edgeOpacity = (id: string) => (!focus || focus.edges.has(id) ? 1 : DIM_OPACITY); + // Search dimming (graph-flow-search CP3, SC7) applies only while no hover is + // active — hover always wins, matching the existing focus-tier precedence. + const nodeOpacity = (id: string) => { + if (focus) return focus.nodes.has(id) ? 1 : DIM_OPACITY; + if (searchTokens) return searchTokens.has(baseToken(id)) ? 1 : DIM_OPACITY; + return 1; + }; + const edgeOpacity = (id: string) => { + if (focus) return focus.edges.has(id) ? 1 : DIM_OPACITY; + if (searchTokens) { + const edge = edges.find(e => e.id === id); + if (!edge) return 1; + const matches = searchTokens.has(baseToken(edge.source)) || searchTokens.has(baseToken(edge.target)); + return matches ? 1 : DIM_OPACITY; + } + return 1; + }; // Resolve the hovered edge render for the tooltip. const tooltipEdge = edgeTooltip !== null diff --git a/test/checks/test-flow-search.ts b/test/checks/test-flow-search.ts new file mode 100644 index 0000000..ce2256f --- /dev/null +++ b/test/checks/test-flow-search.ts @@ -0,0 +1,255 @@ +/** + * test-flow-search.ts — CI-runnable Playwright check for the Flows search bar + * (graph-flow-search CP3): cross-diagram dropdown, sub-DFD navigation, + * in-diagram dim/highlight via searchTokens, body-toggle opt-in, the display + * cap's "+N more" overflow line, and the SC9 no-persistence guarantee. + * + * Serves models/key-inherited, whose flows/order-to-cash carries a + * "Create-Sales-Order" sub-DFD (Validate-Customer, Record-Order) plus a + * sibling refund/Process-Return flow. Expected match sets are computed + * directly via parseFlows + CP1's searchFlowDiagrams (the same source data + * the server parses) rather than hardcoded, so the assertions stay correct + * if the fixture model ever changes. + * + * Skips gracefully (exit 0) when dist/static/index.js is absent — CI builds + * before running checks. + */ + +import { chromium } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; +import { parseFlows } from '../../src/flows/flow-parse'; +import { searchFlowDiagrams } from '../../src/app/logic/search'; + +const ROOT = resolve(import.meta.dir, '../..'); +const MODEL = join(ROOT, 'models/key-inherited'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); + +if (!existsSync(BUNDLE)) { + console.log('SKIP: dist/static/index.js not built (run `bun run build:bundle`). CI builds it before checks.'); + process.exit(0); +} + +let failures = 0; + +function assert(cond: boolean, label: string, detail?: string): void { + if (cond) { + console.log(` PASS ${label}`); + } else { + console.error(` FAIL ${label}${detail ? `\n ${detail}` : ''}`); + failures++; + } +} + +// --------------------------------------------------------------------------- +// Expected match sets — computed from the same fixture data the server +// parses, via CP1's pure matcher (already unit-proven in test-viewer-search.ts). +// --------------------------------------------------------------------------- + +const { flowModel } = await parseFlows(MODEL); + +const validateResults = searchFlowDiagrams(flowModel.diagrams, 'Validate', false); +assert(validateResults.length === 1, 'fixture sanity: "Validate" matches exactly one flow node', `got ${validateResults.length}`); +const validateResult = validateResults[0]; +assert(validateResult?.kind === 'process' && validateResult.diagramId === 'Create-Sales-Order', 'fixture sanity: match is the process living in the Create-Sales-Order sub-DFD'); + +const visaBodyOff = searchFlowDiagrams(flowModel.diagrams, 'Visa', false); +assert(visaBodyOff.length === 0, 'fixture sanity: "Visa" (body-only text) has no title-field match', `got ${visaBodyOff.length}`); +const visaBodyOn = searchFlowDiagrams(flowModel.diagrams, 'Visa', true); +assert(visaBodyOn.length === 1 && visaBodyOn[0]?.diagramId === 'order-to-cash', 'fixture sanity: "Visa" matches Collect Payment\'s body when body search is on', JSON.stringify(visaBodyOn)); + +const manyResults = searchFlowDiagrams(flowModel.diagrams, 'e', false); +assert(manyResults.length > 20, 'fixture sanity: "e" produces more than the display cap', `got ${manyResults.length}`); + +// --------------------------------------------------------------------------- + +const PORT = 3298; +const handle = serveCommand(MODEL, { port: PORT }); +await new Promise(r => setTimeout(r, 400)); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + +try { + await page.goto(`http://localhost:${PORT}/#view=flow`, { waitUntil: 'load' }); + await page.waitForFunction(() => !!(window as { __IGNATIUS_FLOW_READY__?: unknown }).__IGNATIUS_FLOW_READY__, { timeout: 20_000 }); + await page.waitForSelector('.viewer-search-bar--flow', { timeout: 10_000 }); + await page.waitForTimeout(1000); // let the initial diagram settle + + // --------------------------------------------------------------------- + // SC9 (baseline) — capture hash + flow-layout localStorage BEFORE any + // search interaction, and re-check after typing/toggling (not after the + // navigation click below, which legitimately writes dfd= via the + // pre-existing selectDiagramById → onActiveDiagramChange path). + // --------------------------------------------------------------------- + const hashBefore = await page.evaluate(() => location.hash); + const flowLayoutBefore = await page.evaluate(() => localStorage.getItem('ignatius-flow-layout-positions')); + + // --------------------------------------------------------------------- + // SC6 — typing "Validate" lists the sub-DFD process: kind, dotted number, + // label, and owning diagram title (as a group header). + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', 'Validate'); + await page.waitForSelector('.viewer-search-result-row[data-token="proc:Validate-Customer"]', { timeout: 5000 }); + + const row = await page.evaluate(() => { + const el = document.querySelector('.viewer-search-result-row[data-token="proc:Validate-Customer"]'); + const group = el?.closest('.viewer-search-results-group'); + return { + rowCount: document.querySelectorAll('.viewer-search-result-row').length, + rowText: el?.textContent ?? '', + groupTitle: group?.querySelector('.viewer-search-results-group-title')?.textContent ?? '', + kind: el?.querySelector('.viewer-search-result-kind')?.textContent ?? '', + }; + }); + assert(row.rowCount === 1, 'SC6: exactly one dropdown row for "Validate"', `got ${row.rowCount}`); + assert(row.rowText.includes('1.1.1'), 'SC6: row shows the dotted number', row.rowText); + assert(row.rowText.includes('Validate Customer'), 'SC6: row shows the label', row.rowText); + assert(row.groupTitle === 'Create Sales Order', 'SC6: row is grouped under its owning diagram title', row.groupTitle); + assert(row.kind === 'P', 'SC6: row shows the process kind marker', row.kind); + + // Toggle body on/off — exercised here (no title-field-vs-body ambiguity + // for this term) purely to prove it doesn't disturb hash/localStorage. + await page.click('.viewer-search-body-toggle'); + await page.waitForTimeout(200); + await page.click('.viewer-search-body-toggle'); + await page.waitForTimeout(200); + + const hashAfterSearch = await page.evaluate(() => location.hash); + const flowLayoutAfterSearch = await page.evaluate(() => localStorage.getItem('ignatius-flow-layout-positions')); + assert(hashAfterSearch === hashBefore, 'SC9: URL hash unchanged while searching (flow bar)', `before="${hashBefore}" after="${hashAfterSearch}"`); + assert(flowLayoutAfterSearch === flowLayoutBefore, 'SC9: ignatius-flow-layout-positions unchanged while searching'); + + // --------------------------------------------------------------------- + // SC6 — clicking the row navigates into the sub-DFD (active diagram + + // the diagram actually renders that sub-DFD's own nodes). + // --------------------------------------------------------------------- + await page.click('.viewer-search-result-row[data-token="proc:Validate-Customer"]'); + await page.waitForFunction( + () => (window as { __IGNATIUS_ACTIVE_FLOW_DFD__?: string }).__IGNATIUS_ACTIVE_FLOW_DFD__ === 'Create-Sales-Order', + { timeout: 10_000 }, + ); + await page.waitForSelector('[data-token="proc:Record-Order"]', { timeout: 10_000 }); + assert(true, 'SC6: clicking the row navigates to the owning sub-DFD (active diagram + rendered nodes)'); + + // --------------------------------------------------------------------- + // SC7 — non-matching node (Record-Order) at DIM_OPACITY, matching node + // (Validate-Customer) at full opacity. + // --------------------------------------------------------------------- + const opacitiesAfterNav = await page.evaluate(() => ({ + validate: document.querySelector('[data-token="proc:Validate-Customer"]')?.getAttribute('opacity'), + record: document.querySelector('[data-token="proc:Record-Order"]')?.getAttribute('opacity'), + })); + assert(opacitiesAfterNav.validate === '1', 'SC7: matching node (Validate Customer) renders at full opacity', JSON.stringify(opacitiesAfterNav)); + assert(opacitiesAfterNav.record === '0.3', 'SC7: non-matching node (Record Order) renders at DIM_OPACITY', JSON.stringify(opacitiesAfterNav)); + + // Hover wins while active; release restores the search dim. + await page.locator('[data-token="proc:Record-Order"]').hover(); + await page.waitForFunction( + () => document.querySelector('[data-token="proc:Record-Order"]')?.getAttribute('opacity') === '1', + { timeout: 5000 }, + ); + assert(true, 'SC7: hovering a dimmed node temporarily overrides search dim (opacity → 1)'); + + await page.mouse.move(20, 20); + await page.waitForFunction( + () => document.querySelector('[data-token="proc:Record-Order"]')?.getAttribute('opacity') === '0.3', + { timeout: 5000 }, + ); + assert(true, 'SC7: releasing hover restores the search dim'); + + // --------------------------------------------------------------------- + // Clear restores everything (in-diagram half) — dropdown closes, both + // nodes return to full opacity, and the breadcrumb still shows the + // sub-DFD path we navigated into (proves the SVG/chrome state, not the + // search state, drives navigation). + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', ''); + await page.waitForFunction(() => document.querySelectorAll('.viewer-search-results').length === 0, { timeout: 5000 }); + + const opacitiesAfterClear = await page.evaluate(() => ({ + validate: document.querySelector('[data-token="proc:Validate-Customer"]')?.getAttribute('opacity'), + record: document.querySelector('[data-token="proc:Record-Order"]')?.getAttribute('opacity'), + })); + assert(opacitiesAfterClear.validate === '1' && opacitiesAfterClear.record === '1', 'clearing the term restores full opacity to every node', JSON.stringify(opacitiesAfterClear)); + + const breadcrumbHasSubDfd = (await page.locator('body').textContent())?.includes('Create Sales Order') ?? false; + assert(breadcrumbHasSubDfd, 'SC6: breadcrumb reflects the sub-DFD path after navigation'); + + // --------------------------------------------------------------------- + // SC5 (flow half) — body toggle opt-in, plus Enter navigates to the + // first (only) result's diagram. + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', 'Visa'); + await page.waitForTimeout(400); + const visaOffRowCount = await page.locator('.viewer-search-result-row').count(); + assert(visaOffRowCount === 0, 'SC5: body-only term does not match with the body toggle off', `got ${visaOffRowCount}`); + + await page.click('.viewer-search-body-toggle'); // on + await page.waitForSelector('.viewer-search-result-row[data-token="proc:Collect-Payment"]', { timeout: 5000 }); + const visaOnRow = await page.evaluate(() => { + const el = document.querySelector('.viewer-search-result-row[data-token="proc:Collect-Payment"]'); + const group = el?.closest('.viewer-search-results-group'); + return { + groupTitle: group?.querySelector('.viewer-search-results-group-title')?.textContent ?? '', + }; + }); + assert(visaOnRow.groupTitle === 'Order To Cash', 'SC5: toggling body on matches Collect Payment via its prose body', JSON.stringify(visaOnRow)); + + // Enter opens the first (only) result — navigates to order-to-cash. + await page.locator('.viewer-search-input').focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => (window as { __IGNATIUS_ACTIVE_FLOW_DFD__?: string }).__IGNATIUS_ACTIVE_FLOW_DFD__ === 'order-to-cash', + { timeout: 10_000 }, + ); + assert(true, 'SC6: Enter opens the first dropdown row and navigates there'); + + await page.click('.viewer-search-body-toggle'); // back off + + // --------------------------------------------------------------------- + // SC6 — display cap: "+N more" overflow line, verified against the + // independently-computed total (not a hardcoded cap value). + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', 'e'); + await page.waitForSelector('.viewer-search-results-overflow', { timeout: 5000 }); + + const overflowInfo = await page.evaluate(() => ({ + shownRows: document.querySelectorAll('.viewer-search-result-row').length, + overflowText: document.querySelector('.viewer-search-results-overflow')?.textContent ?? '', + })); + const overflowMatch = overflowInfo.overflowText.match(/^\+(\d+) more$/); + assert(overflowMatch !== null, 'SC6: overflow line renders "+N more"', `got "${overflowInfo.overflowText}"`); + if (overflowMatch) { + const overflowN = parseInt(overflowMatch[1]!, 10); + assert( + overflowInfo.shownRows + overflowN === manyResults.length, + 'SC6: shown rows + overflow N equals the total match count', + `shown=${overflowInfo.shownRows} overflow=${overflowN} total=${manyResults.length}`, + ); + } + + // --------------------------------------------------------------------- + // Clear restores everything (dropdown half). + // --------------------------------------------------------------------- + await page.fill('.viewer-search-input', ''); + await page.waitForTimeout(300); + const dropdownGone = await page.locator('.viewer-search-results').count(); + assert(dropdownGone === 0, 'clearing the term removes the results dropdown', `got ${dropdownGone} elements`); + +} catch (err) { + console.error('FAIL:', err instanceof Error ? err.message : String(err)); + failures++; +} finally { + await page.close(); + await browser.close(); + handle.stop(); +} + +if (failures > 0) { + console.error(`\n${failures} failure(s).`); + process.exit(1); +} +console.log('\nCP3 flow-search: all assertions passed.'); +process.exit(0); diff --git a/test/visual/test-flow-search.ts b/test/visual/test-flow-search.ts new file mode 100644 index 0000000..b75227b --- /dev/null +++ b/test/visual/test-flow-search.ts @@ -0,0 +1,86 @@ +/** + * Visual verification: Flow search (graph-flow-search CP3). + * + * Types "Validate" into the Flows search bar and screenshots the result: the + * results dropdown open (Validate Customer, grouped under Create Sales Order) + * while the currently-rendered diagram's own nodes — none of which match — + * render dimmed (searchTokens folded into FlowDiagramSvg's opacity rules). + * + * Run: bun test/visual/test-flow-search.ts + * + * NOT run by `bun run test` — visual screenshot only. Requires: + * - Playwright chromium installed + * - models/key-inherited present + */ + +import { chromium } from 'playwright'; +import { resolve, join } from 'path'; +import { mkdirSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const TMP = join(ROOT, 'tmp', 'flow-search'); +mkdirSync(TMP, { recursive: true }); + +const PORT = 7458; +const BASE = `http://localhost:${PORT}`; + +const note = (m: string) => console.log(m); +const fail = (m: string): never => { console.error('FAIL:', m); process.exit(1); }; + +note('Starting server for models/key-inherited…'); +const MODEL = join(ROOT, 'models/key-inherited'); +const serverHandle = serveCommand(MODEL, { port: PORT }); + +async function waitForServer(url: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return true; + } catch {} + await new Promise(r => setTimeout(r, 250)); + } + return false; +} + +const serverReady = await waitForServer(BASE, 15_000); +if (!serverReady) fail('Server did not start within 15 seconds'); +note(`Server ready at ${BASE}`); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); + +async function shot(name: string): Promise { + const p = join(TMP, name); + await page.screenshot({ path: p, fullPage: false }); + note(` Screenshot: ${p}`); + return p; +} + +note('Loading app on flow view…'); +await page.goto(`${BASE}/#view=flow`, { waitUntil: 'load' }); +await page.waitForFunction(() => !!(window as { __IGNATIUS_FLOW_READY__?: unknown }).__IGNATIUS_FLOW_READY__, { timeout: 20_000 }); +await page.waitForSelector('.viewer-search-bar--flow', { timeout: 10_000 }); +await page.waitForTimeout(1000); + +await shot('01-flow-no-search.png'); +note(' Captured flow view before any search.'); + +note('Typing "Validate" into the flow search bar…'); +await page.fill('.viewer-search-input', 'Validate'); +await page.waitForSelector('.viewer-search-result-row[data-token="proc:Validate-Customer"]', { timeout: 5000 }); +await page.waitForTimeout(300); // let the opacity transition settle + +await shot('02-flow-search-active.png'); +note(' Captured active search: dropdown open, current diagram nodes dimmed.'); + +await browser.close(); +serverHandle.stop(); + +note('\nVisual check complete.'); +note('Review 02-flow-search-active.png — it should show:'); +note(' - The results dropdown open, listing "Validate Customer" (1.1.1) under "Create Sales Order"'); +note(' - Every node in the currently-rendered diagram dimmed to ~0.3 opacity (none match "Validate")'); +process.exit(0); From e41a2415f071c0b8cde4dabb752f639a68d4166a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 05:41:02 -0400 Subject: [PATCH 05/13] docs(spec): CP4 covers the keymap spec surfaces --- docs/spec/graph-flow-search.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index 6444d30..d5397db 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -48,7 +48,7 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- | CP1 | Pure matchers in `src/app/logic/search.ts`: per-kind title/body match functions taking `includeBody`, plus the recursive cross-diagram flow walker returning grouped results with tokens. Unit check `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 in `App.tsx`, match computation, `searchMatches` prop into `GraphView`, `search-match`/`search-dim` styles in `styles.ts`, count readout, Enter cycle, `.viewer-search-bar` CSS. Playwright check `test/checks/test-graph-search.ts` + visual `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. Playwright check `test/checks/test-flow-search.ts` + visual `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, `docs/guides/commands.md` shortcut row, `CLAUDE.md` feature-map row. Extend `test/checks/test-shortcuts.ts`. | SC8 resolver cases green in `test-shortcuts.ts`; docs rows present. | +| CP4 | `/` shortcut end-to-end: resolver action, `useKeyboardShortcuts` `onSearch`, shell focus routing, `DictionaryViewHandle.focusSearch()`, HelpModal search + `/` rows, `docs/guides/commands.md` shortcut row, `CLAUDE.md` feature-map row, and keymap amendments to `docs/spec/keyboard-nav-shortcuts.md` + `docs/spec/help-overlay.md` (the repo requires those specs stay current with the keymap). Extend `test/checks/test-shortcuts.ts`. | SC8 resolver cases green in `test-shortcuts.ts`; docs rows present; both keymap specs amended with change-log entries. | ## Change tree @@ -74,7 +74,9 @@ A test/checks/test-flow-search.ts — Playwright: dropdown, sub-DFD navig 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 CLAUDE.md — feature-map 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) ``` @@ -135,6 +137,12 @@ M CLAUDE.md — feature-map row ## Change log +### 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. From de396abe043bd67a8e84c47b07c8aed31308b711 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 06:10:32 -0400 Subject: [PATCH 06/13] feat(app): / focuses the active view's search Bare-key shortcut routed per view (graph/flow SearchBar focus, dict via a new DictionaryViewHandle.focusSearch). Help overlay, commands guide, keymap specs, and the feature map updated together per the shortcut-surface convention. CP4 of docs/spec/graph-flow-search.md. --- CLAUDE.md | 3 ++- docs/guides/commands.md | 1 + docs/spec/help-overlay.md | 12 ++++++--- docs/spec/keyboard-nav-shortcuts.md | 14 ++++++++++- src/app/App.tsx | 18 ++++++++++++++ src/app/components/ui/HelpModal.tsx | 3 +++ src/app/hooks/useKeyboardShortcuts.ts | 8 ++++-- src/app/logic/shortcuts.ts | 6 ++++- src/app/views/dict/DictionaryView.tsx | 10 ++++++++ test/checks/test-shortcuts.ts | 35 +++++++++++++++++++++++++++ 10 files changed, 101 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a24806c..771cec6 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, `/` search-focus; pure resolver + global hook; editable/modifier guards) | 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/guides/commands.md b/docs/guides/commands.md index cd5ed75..0f2ce26 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -122,6 +122,7 @@ 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 | +| `/` | 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). 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..847bafa 100644 --- a/docs/spec/keyboard-nav-shortcuts.md +++ b/docs/spec/keyboard-nav-shortcuts.md @@ -31,11 +31,17 @@ 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. +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. The search feature itself (bars, +matching, dimming) is owned by `docs/spec/graph-flow-search.md`; this resolver +owns only the `/` key binding and the shell's focus-routing dispatch. **Modifier-gated zoom** (CP4) — resolved *before* the bare-key guards, so the editable guard does **not** block them (they are not typed characters), and @@ -128,3 +134,9 @@ 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. diff --git a/src/app/App.tsx b/src/app/App.tsx index 3ab3b79..58349fe 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,6 +19,7 @@ import { Modal } from './components/ui/Modal'; import { HelpModal } from './components/ui/HelpModal'; import { ZoomControl } from './components/ui/ZoomControl'; import { SearchBar } from './components/ui/SearchBar'; +import type { SearchBarHandle } from './components/ui/SearchBar'; import { FlowSearchResults } from './components/flow/FlowSearchResults'; import { EntityModal } from './components/entity/EntityModal'; import { FindingsPanel } from './components/findings/FindingsPanel'; @@ -71,6 +72,11 @@ export function App() { const flowsViewRef = useRef(null); // Handle to DictionaryView — provides toggleLens for keyboard shortcut. const dictViewRef = useRef(null); + // Handles to the per-view SearchBar — the `/` shortcut (CP4) focuses whichever + // one belongs to the active view; Dictionary is reached via dictViewRef instead + // since its search input lives inside DictionaryView, not a SearchBar. + const graphSearchBarRef = useRef(null); + const flowSearchBarRef = useRef(null); const { view, setView, openEntity, closeEntity } = useHashRoute({ onRestoreDfd: (dfdId) => flowsViewRef.current?.selectDiagramById(dfdId), @@ -275,6 +281,15 @@ export function App() { else if (view === 'flow') flowsViewRef.current?.resetZoom(); } + // Keyboard `/` (SC8) — focus the active view's search input. Graph and Flow + // route through their SearchBar's focus handle; Dictionary's search input + // lives inside DictionaryView, reached via its own handle. + function handleKeyboardSearchFocus() { + if (view === 'graph') graphSearchBarRef.current?.focus(); + else if (view === 'flow') flowSearchBarRef.current?.focus(); + else if (view === 'dict') dictViewRef.current?.focusSearch(); + } + // Global keyboard shortcut handler — single window keydown listener. // Reads current `view`/callbacks via a ref inside the hook (no stale closures). useKeyboardShortcuts({ @@ -286,6 +301,7 @@ export function App() { onZoomOut: handleKeyboardZoomOut, onZoomReset: handleKeyboardZoomReset, onHelp: () => setShowHelp(true), + onSearch: handleKeyboardSearchFocus, }); // NOTE: cy-init effect, navigator toggle effect, and all cy-specific refs have been @@ -568,6 +584,7 @@ export function App() { {/* Graph search bar (graph-flow-search CP2) — mounted while Graph view is active and a model is loaded. */} {view === 'graph' && model && ( 0 && ( void; /** `?` — open the view-aware help overlay. */ onHelp: () => void; + /** `/` — focus the active view's search input. */ + onSearch: () => void; } /** @@ -37,11 +39,12 @@ export function useKeyboardShortcuts({ onZoomOut, onZoomReset, onHelp, + onSearch, }: KeyboardShortcutsConfig): void { // Latest config ref — updated synchronously on every render so the stable // listener closure never reads stale values. - const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp }); - configRef.current = { view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp }; + const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch }); + configRef.current = { view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch }; useEffect(() => { function handleKeyDown(e: KeyboardEvent): void { @@ -77,6 +80,7 @@ export function useKeyboardShortcuts({ case 'zoomOut': cfg.onZoomOut(); break; case 'zoomReset': cfg.onZoomReset(); break; case 'help': cfg.onHelp(); break; + case 'search': cfg.onSearch(); break; } } diff --git a/src/app/logic/shortcuts.ts b/src/app/logic/shortcuts.ts index 23b948d..19f09b1 100644 --- a/src/app/logic/shortcuts.ts +++ b/src/app/logic/shortcuts.ts @@ -10,6 +10,8 @@ * f → view flow (any view) * l → toggleLayout (view==='graph' only; null otherwise) * b → toggleLens (view==='dict' only; null otherwise) + * / → search (any view; ordinary bare key — unlike '?' it needs no + * Shift, so it resolves in the normal switch below) * ? → help (any view; needs Shift, so resolved before guard 2) * * Guards checked before the switch: @@ -51,7 +53,8 @@ export type ShortcutAction = | { type: 'zoomIn' } | { type: 'zoomOut' } | { type: 'zoomReset' } - | { type: 'help' }; + | { type: 'help' } + | { type: 'search' }; // --------------------------------------------------------------------------- // resolveShortcut @@ -106,6 +109,7 @@ export function resolveShortcut( case 'f': return { type: 'view', view: 'flow' }; case 'l': return view === 'graph' ? { type: 'toggleLayout' } : null; case 'b': return view === 'dict' ? { type: 'toggleLens' } : null; + case '/': return { type: 'search' }; default: return null; } } diff --git a/src/app/views/dict/DictionaryView.tsx b/src/app/views/dict/DictionaryView.tsx index 3f909f7..d048bbb 100644 --- a/src/app/views/dict/DictionaryView.tsx +++ b/src/app/views/dict/DictionaryView.tsx @@ -47,9 +47,11 @@ function readStoredLens(): 'read' | 'browse' { * Imperative handle exposed by DictionaryView to the shell (App). The shell * calls into the view for actions that originate outside the dict surface: * - `toggleLens`: keyboard shortcut `b` → flip read↔browse lens. + * - `focusSearch`: keyboard shortcut `/` → focus the dict search input. */ export interface DictionaryViewHandle { toggleLens(): void; + focusSearch(): void; } interface DictionaryViewProps { @@ -110,6 +112,10 @@ const DictionaryView = forwardRef( // The resolver is rebuilt from allDiagrams via useMemo (see below). const [openFlowResult, setOpenFlowResult] = useState(null); + // Ref to the dict search input — focused by the `/` keyboard shortcut via + // DictionaryViewHandle.focusSearch(). + const searchInputRef = useRef(null); + function switchLens(next: 'read' | 'browse') { try { localStorage.setItem(LENS_STORAGE_KEY, next); @@ -127,6 +133,9 @@ const DictionaryView = forwardRef( toggleLens() { switchLens(lens === 'read' ? 'browse' : 'read'); }, + focusSearch() { + searchInputRef.current?.focus(); + }, }), [lens]); // eslint-disable-line react-hooks/exhaustive-deps // Spotlight active id — may be a bare entity id or a ":" flow-node token. @@ -907,6 +916,7 @@ const DictionaryView = forwardRef(
Date: Tue, 14 Jul 2026 06:13:15 -0400 Subject: [PATCH 07/13] docs(spec): validator column schema for checkpoints table --- docs/spec/graph-flow-search.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index d5397db..f542f10 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -43,12 +43,12 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- ## Checkpoints -| # | Scope | Proof | -|---|-------|-------| -| CP1 | Pure matchers in `src/app/logic/search.ts`: per-kind title/body match functions taking `includeBody`, plus the recursive cross-diagram flow walker returning grouped results with tokens. Unit check `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 in `App.tsx`, match computation, `searchMatches` prop into `GraphView`, `search-match`/`search-dim` styles in `styles.ts`, count readout, Enter cycle, `.viewer-search-bar` CSS. Playwright check `test/checks/test-graph-search.ts` + visual `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. Playwright check `test/checks/test-flow-search.ts` + visual `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, `docs/guides/commands.md` shortcut row, `CLAUDE.md` feature-map row, and keymap amendments to `docs/spec/keyboard-nav-shortcuts.md` + `docs/spec/help-overlay.md` (the repo requires those specs stay current with the keymap). Extend `test/checks/test-shortcuts.ts`. | SC8 resolver cases green in `test-shortcuts.ts`; docs rows present; both keymap specs amended with change-log entries. | +| # | 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 | ## Change tree @@ -137,6 +137,12 @@ M CLAUDE.md — feature-map row (and '/' added to t ## Change log +### 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. From 2e9a1281b7e3c73e1fdac9a684e609783e5fe7ad Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 06:15:35 -0400 Subject: [PATCH 08/13] docs(spec): implementation log for graph-flow-search --- docs/spec/graph-flow-search.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index f542f10..f9abd01 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -154,3 +154,32 @@ M CLAUDE.md — feature-map row (and '/' added to t **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 4 iterations of the /autopilot subagent loop. 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 + +**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. + +**Deferred items still open:** + +- none — the FOLLOWUPS ledger closed empty (F-1 folded into CP3). From 10bda6a0c2139207136caad76f6bb4388c5d33f2 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 06:44:18 -0400 Subject: [PATCH 09/13] chore(signals): refresh after graph-flow-search --- docs/wiki/index.md | 187 +++++++++++++++++++++++---------------------- docs/wiki/scan.md | 90 +++++++++++----------- 2 files changed, 138 insertions(+), 139 deletions(-) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 35950b0..5d645d7 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 +56e97e5f687b6efbbd3a66008d18a631c6ad6f94 1 # 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/<slug>.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (346L) — 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). `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 `<g>`. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `<title>` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP3, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (310L) — 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). Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (255L) — 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. 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` (88L) — 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. `test-flow-search.ts` (86L) — 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`. - [`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 | 58921 | 265 | 70% | +| Markdown | 20404 | 298 | 24% | +| CSS | 2928 | 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<ElkLayoutResult>` — 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<string>): 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:<id>--src` in band 0 aggregates all source-role partners; sink copy `ext:<id>--snk` in band 4 aggregates all sink-role partners; single-role externals get one copy). `FlowElementData` node variant carries `extKind?: FlowKindKey` and `storeKind?: FlowKindKey` for kind-colored fills. **Edge-hover-data (CP1):** `FlowElementData` edge variant carries `dataLines: string[]` (structured data items for the hover tooltip), populated in `buildFlowData` via `normalizeEdgeData(edge.data)`. `normalizeEdgeData(data: string | string[] | undefined): string[]` — pure exported helper: `string[]` passes through; a non-empty string splits on the literal `", "` separator; empty/undefined → `[]`. The existing joined `label` string is unchanged. **Process node sizing (#5):** `processNodeSize(label: string): { lines: string[]; width: number; height: number }` — pure exported helper; wraps the label into lines (max width cap) and computes the node height from line count so long process names no longer overflow the fixed 120×68 rect. Consumed by `nodeSize` in [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and by the SVG renderer in [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). +[`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (603L) — renderer-agnostic DFD layout helpers. Exports `assignStoreNumbers`, `buildFlowData`, `computeFlowLayout`, `normalizeEdgeData`, `processNodeSize`, `FlowElementData`, `NodePos`. **Role-split node model (dfd-overhaul):** `buildFlowData` applies the role-split strategy — `buildExternalRouting` caps each external at at most TWO aggregated layout node copies (source copy `ext:<id>--src` in band 0 aggregates all source-role partners; sink copy `ext:<id>--snk` in band 4 aggregates all sink-role partners; single-role externals get one copy). `FlowElementData` node variant carries `extKind?: FlowKindKey` and `storeKind?: FlowKindKey` for kind-colored fills. **Edge-hover-data (CP1):** `FlowElementData` edge variant carries `dataLines: string[]` (structured data items for the hover tooltip), populated in `buildFlowData` via `normalizeEdgeData(edge.data)`. `normalizeEdgeData(data: string | string[] | undefined): string[]` — pure exported helper: `string[]` passes through; a non-empty string splits on the literal `", "` separator; empty/undefined → `[]`. The existing joined `label` string is unchanged. **Process node sizing (#5):** `processNodeSize(label: string): { lines: string[]; width: number; height: number }` — pure exported helper; wraps the label into lines (max width cap) and computes the node height from line count so long process names no longer overflow the fixed 120×68 rect. Consumed by `nodeSize` in [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and by the SVG renderer in [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). -[`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (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<string, { x, y }>` matching `ElkLayoutResult.positions`. - `elkEdgeRoutes?: Record<string, Array<{ x, y }>>` — ELK-routed edge polylines (from `computeElkLayout`). When provided for an edge and neither endpoint has been dragged, the renderer draws the ELK polyline instead of `orthogonalPath`. Absent → `orthogonalPath` used for all edges. @@ -225,7 +225,8 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - Background-colored edge casing rendered under each edge line for visual separation on dense diagrams. - Channel-placed inline labels: labels satisfying `isInlineLabel` (≤ 22 chars) render in full in the inter-band channel; longer labels render a truncated `first~22…` preview chip. The native SVG `<title>` reveal is removed; full data appears in the styled HTML tooltip instead. - **Edge-hover tooltip (CP2):** `edgeTooltip` state `{ edgeId, x, y }` separate from `hover` (avoids mousemove triggering dim/highlight on every pixel). On pointer enter over an edge `<g>` or its chip, `setEdgeTooltip` captures pointer `clientX/clientY`. A `position:fixed` HTML div (`.flow-edge-tooltip`) renders over the SVG listing every `dataLines` item (one `<li>` per item) under a `<sourceLabel> → <targetLabel>` header. Tooltip shown for any edge with non-empty `dataLines` (inline and gated alike). `tooltipClearTimer` ref (flicker guard) delays clearing by ~80ms so crossing from the edge path layer to the chip layer does not flash the tooltip off. `data-contract` and `data-contract-type` attributes on edge `<g>` are unchanged. Dim/highlight focus behaviour on `hover` preserved. `truncateLabel(label, maxChars): string` — module-private; returns label unchanged if ≤ maxChars, else first `maxChars - 1` chars + `…`. -- `onZoomChange?: (scale: number) => 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<string> | null` prop (default `null`) — base tokens that stay at full opacity when no hover is active; `null` means no active search (no dimming from this source). `nodeOpacity`/`edgeOpacity` check `hover`-driven focus first (unchanged precedence), then fall back to `searchTokens` — a pointer hover always wins over search dimming while it's active. Non-matching nodes/edges dim to `DIM_OPACITY` (0.3), the same constant hover-focus dimming already used. An edge counts as matching when either `baseToken(edge.source)` or `baseToken(edge.target)` is in `searchTokens`. `baseToken(id: string): string` — module-private; strips the role-split layout suffixes (`--src`/`--snk`/`--read`/`--write`, via `id.replace(/--(src|snk|read|write)$/, '')`) so a node/edge-endpoint id compares equal to the base token that [`src/app/logic/search.ts`](../../src/app/logic/search.ts)'s `searchFlowDiagrams` produces. [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (409L) — DFD minimap + toolbar chrome wrapper. `.flow-minimap-wrapper` left offset aligned to 16px (matching DG `.minimap` left offset — CP19). @@ -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<string, NodeBox>` — 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<string, Point[]>` — 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) (92L) — 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`, `onSearch` (**graph-flow-search**: fires on [`/`](../..) — focuses the active view's search input). 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<string>` 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:`/`<kind>:<name>`/`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 `"<kind>:<name>"`; entity cards pass `"db:<entityId>"`. otherCardId resolution: `db:<name>` → bare entity id; anything else → raw `"<kind>:<name>"`. Walks all diagrams + sub-DFDs recursively. Array data joined with `", "`. Result sorted by otherCardId ascending. - - [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) (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) (115L) — 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). 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 ([`/`](../..) resolves here). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. Imported by `useKeyboardShortcuts` only. - [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) (100L) — pure geometry helper for separating overlapping spotlight overlay lines (#2). No DOM, no React, no Bun/Node imports. Exports `separateSpotlightLines(base: BaseAnchor, directions: readonly LineDirection[]): SpotlightLineSpec[]`, `BaseAnchor`, `SpotlightLineSpec`, `LineDirection`, `SPOTLIGHT_LINE_GAP = 14`. Algorithm: K=1 → base line unchanged; K>1 → symmetric perpendicular offset of `(i - (K-1)/2) × GAP` per line — horizontal anchor spreads y, vertical anchor spreads x, centre of mass stays on base anchor. Each spec carries exactly ONE direction so each rendered `<path>` has a single arrowhead. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx). - [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) (220L) — pure inherited-connection logic for the DD browse lens and DG graph (#9, key-inheritance-lineage). No DOM, no React, no Bun/Node imports. Exports `buildInheritedConnections(index: ModelIndex, entityId: string): InheritedConnection[]`, `InheritedConnection = { otherId, direction: 'out'|'in'|'both', via: string }`, `INHERITED_IDENTITY = 'identity'`. **`direction` is always `'out'`** — the lineage line points FROM the active card OUTWARD to the lineage member; the union retains `'in'`/`'both'` for shape-compatibility with `SpotlightConnection` but no returned entry ever carries those values. `SpotlightOverlay` renders an `'out'` connection as ONE line with a single arrowhead at the far (member) end. **Key-edge connected-component model:** lineage follows ONLY key edges — an edge where `Object.keys(edge.on)` (FK columns on the child/source side) are ALL ⊆ `pkByNode.get(edge.source)` (child's PK), a non-empty subset test implementing IDEF1X identifying semantics; empirically equivalent to `edge.identifying === true` on key-inherited models. Catches identifying-1:many (FK a proper subset of PK, e.g. `SalesInvoice→Party`) in addition to identifying-1:1 (FK = full PK). Never follows a secondary FK. Identity group = transitive connected component over key edges in BOTH directions, cycle-safe via visited Set; subtype-cluster membership falls out naturally (member→basetype is a key edge). For every group member M ≠ active: emit M as identity link (`via = INHERITED_IDENTITY`) and emit M's direct FK connections with otherId outside the group (via = M id), both de-duped against the active's own direct connections. Does NOT call `buildSpotlightConnections` for de-dup (removed). Bundles to one entry per otherId (first-seen wins); result sorted ascending by otherId; active in no group → []. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx). @@ -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; toggle Body 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 — results list by diagram; click one to navigate there. Non-matches dim in the diagram"). Each branch closes with a view-specific `Keyboard` shortcuts section from `shortcutRows(view)`, which always includes a [`/`](../..) → "Focus the search bar" row (in addition to `G · D · F` and `?`). Distinct from `LegendModal` (symbol reference); this is the conceptual orientation map. + - [`src/app/components/ui/SearchBar.tsx`](../../src/app/components/ui/SearchBar.tsx) (140L) — exports `SearchBar` (`forwardRef<SearchBarHandle, SearchBarProps>`) and `SearchBarHandle` (`{ focus(): void }`). Shared search-bar chrome for the Graph and Flows surfaces (graph-flow-search CP2/CP3) — renders `.viewer-search-bar` with a debounced (200ms), echo-guarded `<input type="search">` (the same debounce/echo pattern `DictionaryView`'s own search box uses), a `.viewer-search-body-toggle` "Body" button (`includeBody`), a `matchCount`/`totalCount` readout ("n of N", hidden when `matchCount === null`), and a `children` slot for a results dropdown. Enter flushes any pending debounce then calls `onEnter` (the caller decides what "next" means — Graph cycles matches, Flows opens the first result row); Escape clears the term and blurs. Props: `term`, `onTermChange`, `includeBody`, `onIncludeBodyChange`, `matchCount`, `totalCount`, `onEnter`, `placeholder`, `ariaLabel`, `className?`, `children?`. `App.tsx` mounts two instances tagged `className="viewer-search-bar--graph"` / `"viewer-search-bar--flow"` as CSS hooks; neither currently has a distinct rule in `styles.css` — both render through the shared `.viewer-search-bar` styling. + +- **Flow search components:** + - [`src/app/components/flow/FlowSearchResults.tsx`](../../src/app/components/flow/FlowSearchResults.tsx) (73L) — exports `FlowSearchResults({ results, onSelect })`. Renders the flow `SearchBar`'s results-slot dropdown (`.viewer-search-results`). Rows arrive already grouped by diagram — `searchFlowDiagrams` walks one diagram fully (parent, then its sub-DFDs) before moving to the next, so same-diagram rows are always contiguous — and are capped at `DISPLAY_CAP = 20` with a "+N more" line for the remainder. Each row shows a one-letter kind badge (`RESULT_KIND_LABEL`: process→P, external→E, store→S, diagram→D), the process's `dottedNumber` when present, and the result label; clicking a row calls `onSelect(diagramId)`. - **Entity components:** - [`src/app/components/entity/EntityModal.tsx`](../../src/app/components/entity/EntityModal.tsx) (129L) — exports `SelectedEntityModal`. @@ -309,12 +309,12 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/components/findings/FindingsPanel.tsx`](../../src/app/components/findings/FindingsPanel.tsx) (93L) — `<FindingsPanel>` renders only when `totalFindings > 0`; collapses to badge; present across all three views. - **Views:** - - [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (1346L) — exports `GraphView` (forwardRef) and `GraphViewHandle` + `LayoutMode` types. Owns full cy lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes: `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `retheme`. Navigator lifecycle is gated on `isActive` to prevent the CP18 crash. `wheelSensitivity: 0.2` passed to cytoscape. **Key-inheritance-lineage (CP-B) — SHIFT+HOVER trigger:** lineage (dotted `edge.inherited` rays + 3-tier focus opacity) is revealed by **SHIFT+HOVER**, not click/select. `cy.on('mouseover', 'node')` branches on `evt.originalEvent?.shiftKey`: shift held → `enterLineageHover(nodeId)` draws ephemeral inherited cy edges (`INHERITED_EDGE_PREFIX + id`, class `INHERITED_EDGE_CLASS = 'inherited'`) and applies 3-tier focus opacity; no shift → plain direct-neighbour dim only (no rays drawn). `cy.on('mouseout', 'node')` → `exitLineageHover()` clears rays and restores opacity. A document-level `keydown`/`keyup` pair on `'Shift'` reads the live `hoveredNodeIdRef` so holding or releasing Shift WHILE already hovering a node enters/exits lineage without a new pointer event; both listeners removed in cy-init cleanup (no leak). A plain click does NOT draw lineage: `cy.on('tap', 'node')` checks `evt.originalEvent?.shiftKey` — shift held returns immediately, leaving cytoscape's automatic tap-select as a persistent highlight and never calling `onSelectEntity` (no modal, no lineage); no shift calls `evt.target.unselect()` then `onSelectEntity(node)`, opening the modal without leaving the node visually selected behind it. A separate `cy.on('tap', ...)` with no node selector handles background taps (`evt.target === cy`): clears inherited edges, focus tiers, and the lineage-active flag, then calls `onDeselectEntity()`. The navigate/panelNavigate/hash-restore paths call `.select()` explicitly and also leave lineage state untouched. Lineage state refs: `hoveredNodeIdRef`, `lineageActiveRef`, `enterLineageHoverRef`, `exitLineageHoverRef` (all `useRef` — stale-closure safe for the document listeners). Ephemeral edges stripped on mouseout, Shift-release, deselect, reselect, relayout, and teardown — never enter model / layoutFingerprint / layout-store / export / ELK. **3-tier focus opacity (DG 3-tier focus opacity):** `applyFocusTiers(focusNode)` splits the graph into three opacity tiers on shift+hover: direct (focused node + real graph neighbors via `connectedEdges().not('.inherited')` + lineage/joiners — no class, full opacity 1.0), inherited/ancestral (dotted-ray target nodes — `.inherited-dim` class at 0.5), unrelated (`.faded` class at 0.2). `clearFocusTiers()` removes all three classes; called on mouseout, Shift-release, deselect, reselect, relayout, and teardown. + - [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (1447L) — exports `GraphView` (forwardRef) and `GraphViewHandle` + `LayoutMode` types. Owns full cy lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes: `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `retheme`. Navigator lifecycle is gated on `isActive` to prevent the CP18 crash. `wheelSensitivity: 0.2` passed to cytoscape. **Key-inheritance-lineage (CP-B) — SHIFT+HOVER trigger:** lineage (dotted `edge.inherited` rays + 3-tier focus opacity) is revealed by **SHIFT+HOVER**, not click/select. `cy.on('mouseover', 'node')` branches on `evt.originalEvent?.shiftKey`: shift held → `enterLineageHover(nodeId)` draws ephemeral inherited cy edges (`INHERITED_EDGE_PREFIX + id`, class `INHERITED_EDGE_CLASS = 'inherited'`) and applies 3-tier focus opacity; no shift → plain direct-neighbour dim only (no rays drawn). `cy.on('mouseout', 'node')` → `exitLineageHover()` clears rays and restores opacity. A document-level `keydown`/`keyup` pair on `'Shift'` reads the live `hoveredNodeIdRef` so holding or releasing Shift WHILE already hovering a node enters/exits lineage without a new pointer event; both listeners removed in cy-init cleanup (no leak). A plain click does NOT draw lineage: `cy.on('tap', 'node')` checks `evt.originalEvent?.shiftKey` — shift held returns immediately, leaving cytoscape's automatic tap-select as a persistent highlight and never calling `onSelectEntity` (no modal, no lineage); no shift calls `evt.target.unselect()` then `onSelectEntity(node)`, opening the modal without leaving the node visually selected behind it. A separate `cy.on('tap', ...)` with no node selector handles background taps (`evt.target === cy`): clears inherited edges, focus tiers, and the lineage-active flag, then calls `onDeselectEntity()`. The navigate/panelNavigate/hash-restore paths call `.select()` explicitly and also leave lineage state untouched. Lineage state refs: `hoveredNodeIdRef`, `lineageActiveRef`, `enterLineageHoverRef`, `exitLineageHoverRef` (all `useRef` — stale-closure safe for the document listeners). Ephemeral edges stripped on mouseout, Shift-release, deselect, reselect, relayout, and teardown — never enter model / layoutFingerprint / layout-store / export / ELK. **3-tier focus opacity (DG 3-tier focus opacity):** `applyFocusTiers(focusNode)` splits the graph into three opacity tiers on shift+hover: direct (focused node + real graph neighbors via `connectedEdges().not('.inherited')` + lineage/joiners — no class, full opacity 1.0), inherited/ancestral (dotted-ray target nodes — `.inherited-dim` class at 0.5), unrelated (`.faded` class at 0.2). `clearFocusTiers()` removes all three classes; called on mouseout, Shift-release, deselect, reselect, relayout, and teardown. **Cross-view search (graph-flow-search CP2):** accepts a `searchMatches: ReadonlySet<string> | null` prop (entity ids; `null` = no active search — no `search-*` classes applied). Mirrored into `searchMatchesRef` so the cy-init closure's `applySearchClasses` (wired via `applySearchClassesRef`) always reads the live value without the cy-init effect re-running on every keystroke. A dedicated effect reapplies search classes on every `searchMatches` change AND whenever `cyReady` flips true — covering the cy-rebuild paths (SSE model refresh, initial mount) where a fresh cy instance starts with no `search-*` classes and must have the active term reapplied without the user retyping it. `applySearchClasses` clears then reapplies `.search-match`/`.search-dim`: matched nodes get `.search-match`; every other node gets `.search-dim`; an edge stays undimmed only when BOTH endpoints match. These are DEDICATED classes, distinct from the hover-tier classes (`.faded`/`.inherited-dim`) and the ephemeral `.inherited` lineage class — `clearFocusTiers`/`clearInheritedEdges` never touch them, so hover, Shift-lineage, background tap, and layout changes cannot erase active search dimming. - [`src/app/views/graph/organic-layout.ts`](../../src/app/views/graph/organic-layout.ts) (648L) — organic (fCoSE-based) layout engine, rewritten around `cytoscape-fcose` in place of the old ELK-stress organic mode. `ORGANIC_FALLBACK_THRESHOLD = 500` — at or above this entity count, organic mode falls back to layered even when the user chose "organic" (raised from a historical 150 now that compound group regions make a ~400-entity organic layout tractable in ~14s). `LAYERED_THOROUGHNESS_TINY/SMALL/MEDIUM/LARGE` = 30/20/14/7 (ELK `elk.layered.thoroughness` by entity-count tier) and `ORGANIC_ITERS_TINY/SMALL/MEDIUM` (`OrganicIters = { clusterFan, leafFan, deoverlap }`) = `{80,80,90}`/`{40,40,45}`/`{20,20,22}` scale the local-pass iteration budgets; `organicIters(n)` derives the tier. `buildScratchCore(live, groupRegions = false)` builds a HEADLESS cytoscape mirror of the live graph (node dims baked in as `dw`/`dh` data so scratch geometry matches live boxes without label machinery) — every read/write against a painted live core pays renderer bookkeeping, so the entire multi-seed layout search runs on this mirror and only the winning positions touch the live core; `groupRegions: true` (passed by `GraphView` when entity count ≥ 150) wraps each colour family in an invisible compound parent (`_group_<name>`) so fCoSE, which is compound-aware, decomposes a large model into per-family sub-layouts plus one small region-vs-region problem instead of one flat hairball. `GROUP_PULL_SELECTOR = 'edge[groupPull = "true"]'` + `addGroupPullEdges(cy)` add EPHEMERAL same-group attraction springs (star topology, anchor = highest-degree groupmate, ties by id) so fCoSE pulls each colour family into one neighbourhood; they exist only inside the synchronous layout run and `arrangeOrganic` strips them first. The multi-seed search (driven from `GraphView.tsx`, not this file) scores each candidate via three fitness functions defined here: `countEdgeCrossings(cy)` (pairwise segment-intersection count, edges sharing an endpoint excluded), `groupScatter(cy)` (per-group summed distance-to-centroid, normalised by mean edge length), and `excessWireLength(cy)` (summed amount by which each edge exceeds 1.5× the median length, in median units — penalises a bridge node flung to one extreme with long tethers). `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline, run in order: strip group-pull edges → `expandCore` (uniform-scale inflate the non-satellite core about its centroid, satellites riding along rigidly — a similarity transform that cannot introduce a crossing) → `fanSubtypeClusters` → `separateClusterFans(iters.clusterFan)` → `dockLeaves` (pull far-flung degree-1 satellites back onto a size-aware ring around their hub) → `dockIsolates` (place degree-0 grouped nodes on their colour family's perimeter, since no force ever moves an edgeless node) → `separateLeafFan(iters.leafFan)` → `decollinearNodes` → `separateNodesFromEdges(iters.deoverlap)` (nudge a node off any non-incident edge that pierces its box) → `deoverlapNodes(iters.deoverlap)`. `gradeEdgeSpans(cy)` grades every rendered edge's `span` data (`'near'|'mid'|'far'`) by percentile within the layout's own length distribution (top decile + length/median ratio ≥1.5 → far, top quartile + ratio ≥1.2 → mid) for `styles.ts`'s length-graded de-emphasis; pure function of current positions, called after every layout run / drag release. - [`src/app/views/graph/navigator.ts`](../../src/app/views/graph/navigator.ts) — `mountNavigator` / `teardownNavigator` / `NavigatorInstance` — cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` BEFORE `nav.destroy()`: `destroy()` removes overlay/window listeners but leaks the cy `'resize'` + onRender listeners, which fire `Navigator.resize→boundingBox` on a destroyed core (`isHeadless` of null); removing them while cy is alive fixes that crash. `test-navigator-teardown.ts` pins this call-order contract. - - [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts) — `buildStyles(themeMode, semanticColors)` → cytoscape stylesheet array. **3-tier focus opacity:** `.faded` selector sets `opacity: 0.2` (unrelated tier, changed from 0.3); new `.inherited-dim` selector sets `opacity: 0.5` (inherited/ancestral tier); `edge.inherited` opacity changed from 0.85 to 0.5 so dotted rays match their `.inherited-dim` target nodes. Includes `edge.inherited` selector: dotted line, `SPOTLIGHT_LINE_INHERITED[themeMode]` color from [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), thinner than direct edges. Length-graded de-emphasis (data set by `organic-layout.ts`'s `gradeEdgeSpans` after layout/drag): `edge[span = "mid"]` → `opacity: 0.5`; `edge[span = "far"]` → `opacity: 0.22, width: 1, z-index: -1` — placed after the identifying/dashed rules so the span grading wins the cascade; long-haul cross-domain wires de-emphasize at overview but stay hoverable/selectable and regain full presence as you zoom in or follow them. - - [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (720L+) — `DictionaryView` now a `forwardRef` exposing `DictionaryViewHandle { toggleLens(): void }` (wraps existing `switchLens`). Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search (CP9), `beforeprint`/`afterprint` (CP10), DD sidebar process nesting with `compareDottedProcesses` (CP24). `kbd-hint` B badge present on the lens toggle control. **Browse lens (dd-spotlight-grid):** lens toggle (`'read' | 'browse'`) persisted to `localStorage`. Browse lens renders entity groups + Processes / External Entities / Data Stores sections. Spotlight state: `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId` (focus mode). FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`. `SpotlightOverlay` mounted when `activeId !== null`. Fixed frosted search bar (`.dict-search-bar`); search and focus are mutually exclusive. **DD inherited lines are SHIFT-GATED:** a `shiftHeld` boolean state is driven by a document-level `keydown`/`keyup` pair on `Shift` + a `window` `blur` reset (registered in a dependency-free `useEffect`); the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId` (deps include `shiftHeld`), and the inherited-id foldings into `spotlitIds`/`focusSet` are wrapped with `if (shiftHeld)` — so without Shift no inherited card is lit, extra-focused, or surfaced as an off-screen chip. FK (solid) and flow (dashed) spotlight lines are unchanged (show on plain hover/pin). `SpotlightOverlay` is unchanged. - - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (766L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `openFlowToken`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). + - [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts) (243L) — `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. **3-tier focus opacity:** `.faded` selector sets `opacity: 0.2` (unrelated tier, changed from 0.3); `.inherited-dim` selector sets `opacity: 0.5` (inherited/ancestral tier); `edge.inherited` opacity changed from 0.85 to 0.5 so dotted rays match their `.inherited-dim` target nodes. Includes `edge.inherited` selector: dotted line, `SPOTLIGHT_LINE_INHERITED[themeMode]` color from [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), thinner than direct edges. Length-graded de-emphasis (data set by `organic-layout.ts`'s `gradeEdgeSpans` after layout/drag): `edge[span = "mid"]` → `opacity: 0.5`; `edge[span = "far"]` → `opacity: 0.22, width: 1, z-index: -1` — placed after the identifying/dashed rules so the span grading wins the cascade; long-haul cross-domain wires de-emphasize at overview but stay hoverable/selectable and regain full presence as you zoom in or follow them. **Graph search (graph-flow-search CP2):** `SEARCH_MATCH_BORDER` (`Record<ThemeMode, string>` — dark `#fde047` / light `#ca8a04`), a distinct gold/yellow chosen so it never reads as the amber `spotlight-line-out` lineage color or a group's border color, mirroring the Dictionary view's `--dd-search-highlight` yellow so "search" reads as one visual language across surfaces. `.search-dim` sets `opacity: 0.2`; `node.search-match` sets `border-width: 3` / `border-color: SEARCH_MATCH_BORDER[mode]`. Both rules are pushed LAST in the stylesheet array so they win the cascade over the span-graded edge opacity and the per-group border-color rules. + - [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (1452L) — `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(): void; focusSearch(): void }` (`toggleLens` wraps the existing `switchLens`; `focusSearch` — added for **graph-flow-search** — focuses `searchInputRef`, the dict search `<input>`, in response to the shell's [`/`](../..) shortcut). Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search (CP9), `beforeprint`/`afterprint` (CP10), DD sidebar process nesting with `compareDottedProcesses` (CP24). Its own search (`nodeMatchesSearch`/`processMatchesSearch`/`externalMatchesSearch`/`storeMatchesSearch` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts), always matching columns + body) is unchanged by graph-flow-search — the new title-first, body-opt-in matchers (`entityMatches`, `flowProcessMatches`, etc.) are exclusive to the Graph and Flows `SearchBar` surfaces. `kbd-hint` B badge present on the lens toggle control. **Browse lens (dd-spotlight-grid):** lens toggle (`'read' | 'browse'`) persisted to `localStorage`. Browse lens renders entity groups + Processes / External Entities / Data Stores sections. Spotlight state: `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId` (focus mode). FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`. `SpotlightOverlay` mounted when `activeId !== null`. Fixed frosted search bar (`.dict-search-bar`); search and focus are mutually exclusive. **DD inherited lines are SHIFT-GATED:** a `shiftHeld` boolean state is driven by a document-level `keydown`/`keyup` pair on `Shift` + a `window` `blur` reset (registered in a dependency-free `useEffect`); the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId` (deps include `shiftHeld`), and the inherited-id foldings into `spotlitIds`/`focusSet` are wrapped with `if (shiftHeld)` — so without Shift no inherited card is lit, extra-focused, or surfaced as an off-screen chip. FK (solid) and flow (dashed) spotlight lines are unchanged (show on plain hover/pin). `SpotlightOverlay` is unchanged. + - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (864L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `openFlowToken`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet<string> | null` prop (base tokens — `proc:`/`ext:`/`<kind>:<name>`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token. - [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) (204L) — `LegendModal` component. - **Globals:** [`src/app/globals.d.ts`](../../src/app/globals.d.ts) (46L) — `window.__MODEL__`, `window.__THEME_MODE__`, `window.__IGNATIUS_MODE__` ('live'|'static'), `window.__LAYOUT_KEY__`, `window.__FLOW_MODEL__`, `window.__FLOW_LAYOUT_KEYS__`, `window.__IGNATIUS_CY__`, `window.__IGNATIUS_CY_GEN__`, `window.__IGNATIUS_FLOW_READY__`, `window.__IGNATIUS_FLOW_GEN__`, `window.__IGNATIUS_ACTIVE_FLOW_DFD__`, `window.__IGNATIUS_PERF__`. @@ -339,7 +339,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde [`src/types/cytoscape-fcose.d.ts`](../../src/types/cytoscape-fcose.d.ts) — ambient declarations for `cytoscape-fcose` (no `@types` package); default-exports the plugin's CJS register function typed as `cytoscape.Ext` so `cytoscape.use(fcose)` type-checks, with fCoSE's layout options object cast to `cytoscape.LayoutOptions` at each `cy.layout()` call site since fCoSE isn't in cytoscape's core layout-name union. -[`src/app/styles.css`](../../src/app/styles.css) — full SPA stylesheet (2577L). Key additions: `@media print` block (CP10), `::highlight(dd-search-highlight)` (CP9), `.dict-process-direction` badges, `.flow-minimap-wrapper` (left: 16px, CP19), `.zoom-control` (CP22/CP23), `.kbd-hint` (small keyboard-shortcut badge displayed inside FAB items and the DD lens toggle), `.flow-edge-tooltip` (position:fixed edge hover tooltip; `.flow-edge-tooltip__header`, `.flow-edge-tooltip__list`, `.flow-edge-tooltip__list li`), `.help-toggle` (top-bar `?` button; added to the print-hidden list), `.help-modal` + `.help-section` + `.help-section-title` + `.help-row` + `.help-term` + `.help-desc` + `.help-intro` + `.help-footnote` (help overlay content styles). DD chrome: `.dict-view` (full-width scroll container), `.dict-view-inner`, `.dict-search-bar` (fixed frosted top bar), `.dict-browse-lens`, `.dict-grid-card`, `.dict-flow-grid-card`, `.spotlight-overlay`, `.spotlight-line`, `.spotlight-line--flow`, `.spotlight-chips-container`, chip sub-classes, `.dict-browse-focus-bar`. +[`src/app/styles.css`](../../src/app/styles.css) — full SPA stylesheet (2741L). Key additions: `@media print` block (CP10), `::highlight(dd-search-highlight)` (CP9), `.dict-process-direction` badges, `.flow-minimap-wrapper` (left: 16px, CP19), `.zoom-control` (CP22/CP23), `.kbd-hint` (small keyboard-shortcut badge displayed inside FAB items and the DD lens toggle), `.flow-edge-tooltip` (position:fixed edge hover tooltip; `.flow-edge-tooltip__header`, `.flow-edge-tooltip__list`, `.flow-edge-tooltip__list li`), `.help-toggle` (top-bar `?` button; added to the print-hidden list), `.help-modal` + `.help-section` + `.help-section-title` + `.help-row` + `.help-term` + `.help-desc` + `.help-intro` + `.help-footnote` (help overlay content styles). DD chrome: `.dict-view` (full-width scroll container), `.dict-view-inner`, `.dict-search-bar` (fixed frosted top bar), `.dict-browse-lens`, `.dict-grid-card`, `.dict-flow-grid-card`, `.spotlight-overlay`, `.spotlight-line`, `.spotlight-line--flow`, `.spotlight-chips-container`, chip sub-classes, `.dict-browse-focus-bar`. **graph-flow-search additions:** `.viewer-search-bar` + `.viewer-search-bar-inner` (compact frosted pill, top-center, positioned via `top: var(--search-bar-top, 12px)` — the custom property App.tsx's banner-measuring layout effect sets while the global-error banner is visible) + `.viewer-search-input` + `.viewer-search-body-toggle` (+ `--active` state) + `.viewer-search-count` — the shared Graph/Flows `SearchBar` chrome; `.viewer-search-results` + `.viewer-search-results-group` (+ `-title`) + `.viewer-search-result-row` (+ `-kind`, `-dotted`, `-label`) + `.viewer-search-results-overflow` — the Flows results dropdown (`FlowSearchResults`), same frosted-pill language, nested under `.viewer-search-bar` so it inherits that selector's `@media print` rule. ### generators @@ -371,64 +371,65 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify ### docs -[`docs/design/folder-model.md`](../../docs/design/folder-model.md) (104L) — design doc for the folder-model migration (#16): problem statement, goals/non-goals, approach comparison (kept `_*` vs hoisted), recommendation (Approach B, hard-cut, minor bump), migration strategy (throwaway prompt not a CLI command), resolved questions. -[`docs/design/app-tsx-decomposition.md`](../../docs/design/app-tsx-decomposition.md) (142L) — design doc for the `src/App.tsx` → [`src/app/`](../../src/app) decomposition. -[`docs/design/src-root-organization.md`](../../docs/design/src-root-organization.md) (49L) — design doc for the [`src/`](../../src) top-level subdirectory split. -[`docs/design/bidirectional-predicates.md`](../../docs/design/bidirectional-predicates.md) — design doc for bidirectional predicate feature. -[`docs/design/cli-and-outputs.md`](../../docs/design/cli-and-outputs.md) — design doc for CLI modes and static output approach. -[`docs/design/markdown-driven-erd.md`](../../docs/design/markdown-driven-erd.md) — design doc for markdown-driven entity file format. -[`docs/design/branding.md`](../../docs/design/branding.md) — design doc for branding system. -[`docs/design/dict-navigation.md`](../../docs/design/dict-navigation.md) — design doc for data dictionary navigation. -[`docs/design/viewer-fab-ux.md`](../../docs/design/viewer-fab-ux.md) — design doc for floating action button UX. -[`docs/design/ignatius-project-config.md`](../../docs/design/ignatius-project-config.md) — design doc for `ignatius.yml` config + model discovery. -[`docs/design/noorm-modeling-skill.md`](../../docs/design/noorm-modeling-skill.md) — design doc for the ignatius modeling skill. -[`docs/design/schema-lint-and-error-ux.md`](../../docs/design/schema-lint-and-error-ux.md) (205L) — design doc for schema lint + error UX. -[`docs/design/graph-position-persistence.md`](../../docs/design/graph-position-persistence.md) (118L) — design doc for graph node position persistence. -[`docs/design/wiki-entity-links.md`](../../docs/design/wiki-entity-links.md) (59L) — design doc for wiki-style `[[Entity]]` body links. -[`docs/design/process-flows.md`](../../docs/design/process-flows.md) (207L) — design doc for SSADM DFD subsystem. -[`docs/design/unified-app.md`](../../docs/design/unified-app.md) (152L) — design doc for unified SPA collapse. -[`docs/design/dd-spotlight-grid.md`](../../docs/design/dd-spotlight-grid.md) — design doc for DD browse-lens spotlight grid: browse/read lens concept, spotlight predicate philosophy, flow-node cross-domain spotlight, focus mode. -[`docs/design/dfd-edge-hover-data.md`](../../docs/design/dfd-edge-hover-data.md) (100L) — design doc for DFD edge-hover data reveal (issue #14): problem statement (long `db:` column lists gated off-canvas), three approach comparison (HTML overlay tooltip chosen for zoom-independence), recommendation, verification plan, open questions. Implements the approach adopted in CP1–CP4. -[`docs/design/dfd-nesting-depth.md`](../../docs/design/dfd-nesting-depth.md) (75L) — design doc for the arbitrary DFD nesting depth fix (issue #15): root-cause analysis of the `renumberLeaf` bug (last-segment-only prefix, no subDfd recursion), goal/non-goal boundary, approach (prefix full relative number, recurse subDfds), and verification strategy. -[`docs/design/dfd-overhaul.md`](../../docs/design/dfd-overhaul.md) (93L) — design doc for the DFD viewer overhaul: canonical Yourdon/SSADM leveling (context → Level-1 → leaf decompositions), ELK-driven layout with crossing minimisation, 5-band partitioning, role-split node model (Decision D), and orthogonal edge routing from ELK `sections`. Proving model: [`models/llm-memory-db-mssql/`](../../models/llm-memory-db-mssql). -[`docs/design/keyboard-nav-shortcuts.md`](../../docs/design/keyboard-nav-shortcuts.md) (110L) — design doc for single-key keyboard navigation shortcuts: keymap rationale (g/d/f/l/b), guard philosophy (editable + modifier), `kbd-hint` badge UX inline in FAB + lens toggle. -[`docs/design/key-inheritance-lineage.md`](../../docs/design/key-inheritance-lineage.md) (72L) — design doc for the key-inheritance-lineage feature: problem statement (subtype/dependent-1:1 entities look unrelated to group-mates), two edge-kind taxonomy (subtype-cluster vs dependent-identifying-1:1), transitive BFS closure rationale, DG ephemeral edge approach (why not model edges), DD dotted inherited line, `SPOTLIGHT_LINE_INHERITED` color design. -[`docs/design/viewer-ux-polish.md`](../../docs/design/viewer-ux-polish.md) (133L) — design doc for the viewer-ux-polish batch: 6 fixes (#1 HTML title, #2 spotlight line separation, #3 native-1:1 zoom model, #4 Cmd/Ctrl+/-/0 to canvas, #5 process node sizing, #6/#8 entity modal history via `entity=` hash). Explains the per-fix approach, goals/non-goals, and deferred item (#7 graph search highlight, issue #18). -[`docs/design/help-overlay.md`](../../docs/design/help-overlay.md) (62L) — design doc for the view-aware help overlay: problem statement (no in-app orientation for first-time users), approach (view-switched modal on `?` key + top-bar button, conceptual terms + per-view keyboard section), distinction from `LegendModal` (symbol reference), non-goals. -[`docs/spec/folder-model.md`](../../docs/spec/folder-model.md) (117L) — implementation contract for folder-model migration (#16): success criteria (entity scan confined to `data/`, optional `groups/`, root `externals/`+`stores/`, per-DFD override removed, fixture migration, count baselines, skill + docs update), checkpoints CP1–CP4, implementation log (squashed to d024c43, 2026-06-17, 577 PASS). -[`docs/spec/app-tsx-decomposition.md`](../../docs/spec/app-tsx-decomposition.md) (246L) — implementation contract for App.tsx decomposition. -[`docs/spec/src-root-organization.md`](../../docs/spec/src-root-organization.md) (82L) — implementation contract for [`src/`](../../src) directory split. -[`docs/spec/cli-and-outputs.md`](../../docs/spec/cli-and-outputs.md) — implementation contract for CLI output modes and theme system. -[`docs/spec/branding.md`](../../docs/spec/branding.md) — implementation contract for branding. -[`docs/spec/dict-navigation.md`](../../docs/spec/dict-navigation.md) — implementation contract for dict side nav. -[`docs/spec/dict-polish.md`](../../docs/spec/dict-polish.md) — implementation contract for dict visual polish. -[`docs/spec/viewer-fab-ux.md`](../../docs/spec/viewer-fab-ux.md) — implementation contract for FAB UX. -[`docs/spec/ignatius-project-config.md`](../../docs/spec/ignatius-project-config.md) — implementation contract for `ignatius.yml` config loading + model discovery. -[`docs/spec/derive-classification.md`](../../docs/spec/derive-classification.md) — implementation contract for 5-rule classification derivation. -[`docs/spec/noorm-modeling-skill.md`](../../docs/spec/noorm-modeling-skill.md) — implementation contract for the ignatius modeling skill. -[`docs/spec/bidirectional-predicates.md`](../../docs/spec/bidirectional-predicates.md) — implementation contract for bidirectional predicates. -[`docs/spec/schema-lint-and-error-ux.md`](../../docs/spec/schema-lint-and-error-ux.md) (141L) — implementation contract for schema lint + error UX. -[`docs/spec/graph-position-persistence.md`](../../docs/spec/graph-position-persistence.md) (106L) — implementation contract for graph node position persistence. -[`docs/spec/wiki-entity-links.md`](../../docs/spec/wiki-entity-links.md) (79L) — implementation contract for wiki-entity links. -[`docs/spec/process-flows.md`](../../docs/spec/process-flows.md) (682L) — comprehensive implementation contract for SSADM DFD: parse, 11 `flow.*` rules, flow viewer, sub-DFD drill-down, `db:` store → rich entity dialog, non-entity store kinds, `title:` override, `titlelize`, entity↔process cross-reference, DFD URL navigability, process dictionary fused into Dictionary. -[`docs/spec/unified-app.md`](../../docs/spec/unified-app.md) (216L) — implementation contract for unified SPA. -[`docs/spec/dfd-polish-round4.md`](../../docs/spec/dfd-polish-round4.md) (159L) — implementation contract for CP24–26: DD sidebar hierarchical process nesting, DD card IO endpoint clickability, DD card process sample-data tables. -[`docs/spec/render-perf-indexing.md`](../../docs/spec/render-perf-indexing.md) (231L) — implementation contract for render-perf-indexing batch: L1 preset-layout cache-skip, L2 ELK thoroughness/cost scaling, L3 O(n²) → id→element Map, L4 ELK in Web Worker, `buildModelIndex` 13-map O(1) index. 2026-07-06 addendum: the organic multi-seed search moved off the live cy core onto a headless scratch core stepped one candidate per macrotask (non-blocking, partially obviating deferred CP6 `render-perf-elk-web-worker` for the organic path), and `deoverlapNodes`/`separateNodesFromEdges` now read node dimensions once up front instead of calling `outerWidth()`/`outerHeight()` inside their O(n²) pairwise loops. -[`docs/spec/unified-app-polish.md`](../../docs/spec/unified-app-polish.md) (194L) — implementation contract for CP1–CP13 unified-app-polish batch. -[`docs/spec/dfd-polish-round2.md`](../../docs/spec/dfd-polish-round2.md) (169L) — implementation contract for CP14–17. -[`docs/spec/dfd-polish-round3.md`](../../docs/spec/dfd-polish-round3.md) (238L) — implementation contract for CP18–23. -[`docs/spec/dd-spotlight-grid.md`](../../docs/spec/dd-spotlight-grid.md) (239L) — implementation contract for DD browse-lens spotlight grid: `spotlight.ts`, `flow-spotlight.ts`, `GridCard`, `FlowNodeGridCard`, `SpotlightOverlay`, `DictionaryView` browse lens. CP1–CP15 build order table. -[`docs/spec/dfd-nesting-depth.md`](../../docs/spec/dfd-nesting-depth.md) (69L) — implementation contract for the arbitrary DFD nesting depth fix: success criteria pin `renumberDiagram` recursion + full-prefix invariant, `test-deep-nesting.ts` assertions, `flows-leveling` fixture servability, skill F8 recursion update, and `bun run test` + `build:cli` green gate. -[`docs/spec/dfd-overhaul.md`](../../docs/spec/dfd-overhaul.md) (155L) — implementation contract for the DFD viewer overhaul. Success criteria C1–C18 including: C4 (5-band ordering invariant), C6 (context diagram: 1 process, external-only flows), C7 (L1 overview: all leaf-activity processes + stores with degree ≥ 2, no degree=1 stores), C12 (dotted numbering), C13 (inline labels: length gate ≤ 22 chars), C14 (external role-split: at most 2 copies per external), C15 (ELK-routed edge geometry from `sections`), C16 (single-row bands), C17 (node centers in positions), C18 (ELK runs in browser — `terminateQuietly` guard). Checkpoints 4a–4e. -[`docs/spec/dfd-edge-hover-data.md`](../../docs/spec/dfd-edge-hover-data.md) (83L) — implementation contract for DFD edge-hover data reveal (issue #14). CP1–CP4 build order. Success criteria: `normalizeEdgeData` pure helper; `dataLines: string[]` on `FlowElementData` edge variant; `position:fixed` HTML hover tooltip listing full data items under `source → target` header; long labels render truncated `…` preview inline (not suppressed); native `<title>` removed; `data-contract`/`data-contract-type` unchanged; dim/highlight focus preserved; CI Playwright check + visual screenshot; `bun run test` exit 0. -[`docs/spec/keyboard-nav-shortcuts.md`](../../docs/spec/keyboard-nav-shortcuts.md) (130L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). -[`docs/spec/help-overlay.md`](../../docs/spec/help-overlay.md) (54L) — implementation contract for the help overlay: `HelpModal` component contract (view prop, Modal primitive, three view branches + shared Keyboard section), `{type:'help'}` `ShortcutAction` resolution order (after editable guard, before modifier guard, gated off ctrl/meta/alt), `showHelp` state in shell, `.help-toggle` top-bar button placement, `.help-*` CSS classes, test-plan (`test-help-overlay.ts`: `?` button + key open, Escape closes, view-aware content, editable guard). -[`docs/spec/key-inheritance-lineage.md`](../../docs/spec/key-inheritance-lineage.md) (372L) — implementation contract for key-inheritance-lineage: `buildInheritedConnections` key-edge connected-component algorithm (FK ⊆ PK, both directions, cycle-safe, identifying-1:many covered), DG ephemeral `edge.inherited` lifecycle (SHIFT+HOVER trigger — not click/select), DD `SpotlightOverlay` dotted inherited lines SHIFT-GATED in `DictionaryView`, `SPOTLIGHT_LINE_INHERITED` constant placement, test-plan (`test-spotlight-inherited.ts`, `test-inherited-edges-no-leak.ts`, `test-graph-inherited-edges.ts`, `test-navigator-teardown.ts`). Design doc [`docs/design/key-inheritance-lineage.md`](../../docs/design/key-inheritance-lineage.md) also rewritten to the key-edge rule. -[`docs/guides/commands.md`](../../docs/guides/commands.md) (154L) — user guide for CLI subcommands and keyboard shortcuts; updated to document the `?` key (open help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. -[`docs/glossary.md`](../../docs/glossary.md) (52L) — canonical vocabulary: DG, DD, DFD, DE, DS, EE, Process, Data Flow. -[`docs/research/ssadm-dfd-rules.md`](../../docs/research/ssadm-dfd-rules.md) (118L) — research notes on SSADM DFD rules. -[`docs/research/dfd-layout-and-leveling.md`](../../docs/research/dfd-layout-and-leveling.md) (129L) — research notes on DFD layout engines and Yourdon leveling used in specifying the dfd-overhaul approach; primary source for the ELK algorithm selection and band-partitioning option names. -[`docs/handoff-orthogonal-edges.md`](../../docs/handoff-orthogonal-edges.md) (131L) — handoff notes on orthogonal edge rendering coordinate math, ELK `sections` structure, and the [`src/edge-routes.ts`](../../src/edge-routes.ts) helper API. +[`docs/design/folder-model.md`](../design/folder-model.md) (104L) — design doc for the folder-model migration (#16): problem statement, goals/non-goals, approach comparison (kept `_*` vs hoisted), recommendation (Approach B, hard-cut, minor bump), migration strategy (throwaway prompt not a CLI command), resolved questions. +[`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) (142L) — design doc for the `src/App.tsx` → [`src/app/`](../../src/app) decomposition. +[`docs/design/src-root-organization.md`](../design/src-root-organization.md) (49L) — design doc for the [`src/`](../../src) top-level subdirectory split. +[`docs/design/bidirectional-predicates.md`](../design/bidirectional-predicates.md) — design doc for bidirectional predicate feature. +[`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md) — design doc for CLI modes and static output approach. +[`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) — design doc for markdown-driven entity file format. +[`docs/design/branding.md`](../design/branding.md) — design doc for branding system. +[`docs/design/dict-navigation.md`](../design/dict-navigation.md) — design doc for data dictionary navigation. +[`docs/design/viewer-fab-ux.md`](../design/viewer-fab-ux.md) — design doc for floating action button UX. +[`docs/design/ignatius-project-config.md`](../design/ignatius-project-config.md) — design doc for `ignatius.yml` config + model discovery. +[`docs/design/noorm-modeling-skill.md`](../design/noorm-modeling-skill.md) — design doc for the ignatius modeling skill. +[`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) (205L) — design doc for schema lint + error UX. +[`docs/design/graph-position-persistence.md`](../design/graph-position-persistence.md) (118L) — design doc for graph node position persistence. +[`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) (59L) — design doc for wiki-style `[[Entity]]` body links. +[`docs/design/process-flows.md`](../design/process-flows.md) (218L) — design doc for SSADM DFD subsystem. +[`docs/design/unified-app.md`](../design/unified-app.md) (152L) — design doc for unified SPA collapse. +[`docs/design/dd-spotlight-grid.md`](../design/dd-spotlight-grid.md) — design doc for DD browse-lens spotlight grid: browse/read lens concept, spotlight predicate philosophy, flow-node cross-domain spotlight, focus mode. +[`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) (100L) — design doc for DFD edge-hover data reveal (issue #14): problem statement (long `db:` column lists gated off-canvas), three approach comparison (HTML overlay tooltip chosen for zoom-independence), recommendation, verification plan, open questions. Implements the approach adopted in CP1–CP4. +[`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) (75L) — design doc for the arbitrary DFD nesting depth fix (issue #15): root-cause analysis of the `renumberLeaf` bug (last-segment-only prefix, no subDfd recursion), goal/non-goal boundary, approach (prefix full relative number, recurse subDfds), and verification strategy. +[`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) (93L) — design doc for the DFD viewer overhaul: canonical Yourdon/SSADM leveling (context → Level-1 → leaf decompositions), ELK-driven layout with crossing minimisation, 5-band partitioning, role-split node model (Decision D), and orthogonal edge routing from ELK `sections`. Proving model: [`models/llm-memory-db-mssql/`](../../models/llm-memory-db-mssql). +[`docs/design/keyboard-nav-shortcuts.md`](../design/keyboard-nav-shortcuts.md) (110L) — design doc for single-key keyboard navigation shortcuts: keymap rationale (g/d/f/l/b), guard philosophy (editable + modifier), `kbd-hint` badge UX inline in FAB + lens toggle. +[`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) (132L) — design doc for the key-inheritance-lineage feature: problem statement (subtype/dependent-1:1 entities look unrelated to group-mates), two edge-kind taxonomy (subtype-cluster vs dependent-identifying-1:1), transitive BFS closure rationale, DG ephemeral edge approach (why not model edges), DD dotted inherited line, `SPOTLIGHT_LINE_INHERITED` color design. +[`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md) (133L) — design doc for the viewer-ux-polish batch: 6 fixes (#1 HTML title, #2 spotlight line separation, #3 native-1:1 zoom model, #4 Cmd/Ctrl+/-/0 to canvas, #5 process node sizing, #6/#8 entity modal history via `entity=` hash). Explains the per-fix approach, goals/non-goals, and deferred item (#7 graph search highlight, issue #18) — [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) supersedes that deferral. +[`docs/design/help-overlay.md`](../design/help-overlay.md) (62L) — design doc for the view-aware help overlay: problem statement (no in-app orientation for first-time users), approach (view-switched modal on `?` key + top-bar button, conceptual terms + per-view keyboard section), distinction from `LegendModal` (symbol reference), non-goals. +[`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) (82L) — design doc for search on the Graph and Flows views (issue #18): problem statement (no way to find a node by name in either view; Dictionary already has search-as-you-type), goals (Graph search bar with dim/highlight + `n of N` count + Enter-to-pan cycling, Flows search bar with a cross-diagram results dropdown, title-first matching with an opt-in body-text toggle, [`/`](../..) focuses the active view's search input on all three views), dim-don't-filter approach rationale, pure matchers in [`src/app/logic/search.ts`](../../src/app/logic/search.ts) with per-view wiring, shared `SearchBar` component, distinct `search-match`/`search-dim` cytoscape classes chosen over reusing `.faded` (hover's `clearFocusTiers` would erase it), cross-diagram flow matcher walking non-synthetic diagrams recursively, alternatives considered (filter-not-dim, reusing `.faded`, a global command-palette search, flow search scoped to the visible diagram only). Supersedes [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md)'s deferred item #7 (graph search highlight, issue #18). +[`docs/spec/folder-model.md`](../spec/folder-model.md) (117L) — implementation contract for folder-model migration (#16): success criteria (entity scan confined to `data/`, optional `groups/`, root `externals/`+`stores/`, per-DFD override removed, fixture migration, count baselines, skill + docs update), checkpoints CP1–CP4, implementation log (squashed to d024c43, 2026-06-17, 577 PASS). +[`docs/spec/app-tsx-decomposition.md`](../spec/app-tsx-decomposition.md) (246L) — implementation contract for App.tsx decomposition. +[`docs/spec/src-root-organization.md`](../spec/src-root-organization.md) (82L) — implementation contract for [`src/`](../../src) directory split. +[`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) — implementation contract for CLI output modes and theme system. +[`docs/spec/branding.md`](../spec/branding.md) — implementation contract for branding. +[`docs/spec/dict-navigation.md`](../spec/dict-navigation.md) — implementation contract for dict side nav. +[`docs/spec/dict-polish.md`](../spec/dict-polish.md) — implementation contract for dict visual polish. +[`docs/spec/viewer-fab-ux.md`](../spec/viewer-fab-ux.md) — implementation contract for FAB UX. +[`docs/spec/ignatius-project-config.md`](../spec/ignatius-project-config.md) — implementation contract for `ignatius.yml` config loading + model discovery. +[`docs/spec/derive-classification.md`](../spec/derive-classification.md) — implementation contract for 5-rule classification derivation. +[`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) — implementation contract for the ignatius modeling skill. +[`docs/spec/bidirectional-predicates.md`](../spec/bidirectional-predicates.md) — implementation contract for bidirectional predicates. +[`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) (141L) — implementation contract for schema lint + error UX. +[`docs/spec/graph-position-persistence.md`](../spec/graph-position-persistence.md) (106L) — implementation contract for graph node position persistence. +[`docs/spec/wiki-entity-links.md`](../spec/wiki-entity-links.md) (79L) — implementation contract for wiki-entity links. +[`docs/spec/process-flows.md`](../spec/process-flows.md) (682L) — comprehensive implementation contract for SSADM DFD: parse, 11 `flow.*` rules, flow viewer, sub-DFD drill-down, `db:` store → rich entity dialog, non-entity store kinds, `title:` override, `titlelize`, entity↔process cross-reference, DFD URL navigability, process dictionary fused into Dictionary. +[`docs/spec/unified-app.md`](../spec/unified-app.md) (216L) — implementation contract for unified SPA. +[`docs/spec/dfd-polish-round4.md`](../spec/dfd-polish-round4.md) (159L) — implementation contract for CP24–26: DD sidebar hierarchical process nesting, DD card IO endpoint clickability, DD card process sample-data tables. +[`docs/spec/render-perf-indexing.md`](../spec/render-perf-indexing.md) (231L) — implementation contract for render-perf-indexing batch: L1 preset-layout cache-skip, L2 ELK thoroughness/cost scaling, L3 O(n²) → id→element Map, L4 ELK in Web Worker, `buildModelIndex` 13-map O(1) index. 2026-07-06 addendum: the organic multi-seed search moved off the live cy core onto a headless scratch core stepped one candidate per macrotask (non-blocking, partially obviating deferred CP6 `render-perf-elk-web-worker` for the organic path), and `deoverlapNodes`/`separateNodesFromEdges` now read node dimensions once up front instead of calling `outerWidth()`/`outerHeight()` inside their O(n²) pairwise loops. +[`docs/spec/unified-app-polish.md`](../spec/unified-app-polish.md) (194L) — implementation contract for CP1–CP13 unified-app-polish batch. +[`docs/spec/dfd-polish-round2.md`](../spec/dfd-polish-round2.md) (169L) — implementation contract for CP14–17. +[`docs/spec/dfd-polish-round3.md`](../spec/dfd-polish-round3.md) (238L) — implementation contract for CP18–23. +[`docs/spec/dd-spotlight-grid.md`](../spec/dd-spotlight-grid.md) (239L) — implementation contract for DD browse-lens spotlight grid: `spotlight.ts`, `flow-spotlight.ts`, `GridCard`, `FlowNodeGridCard`, `SpotlightOverlay`, `DictionaryView` browse lens. CP1–CP15 build order table. +[`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (69L) — implementation contract for the arbitrary DFD nesting depth fix: success criteria pin `renumberDiagram` recursion + full-prefix invariant, `test-deep-nesting.ts` assertions, `flows-leveling` fixture servability, skill F8 recursion update, and `bun run test` + `build:cli` green gate. +[`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (155L) — implementation contract for the DFD viewer overhaul. Success criteria C1–C18 including: C4 (5-band ordering invariant), C6 (context diagram: 1 process, external-only flows), C7 (L1 overview: all leaf-activity processes + stores with degree ≥ 2, no degree=1 stores), C12 (dotted numbering), C13 (inline labels: length gate ≤ 22 chars), C14 (external role-split: at most 2 copies per external), C15 (ELK-routed edge geometry from `sections`), C16 (single-row bands), C17 (node centers in positions), C18 (ELK runs in browser — `terminateQuietly` guard). Checkpoints 4a–4e. +[`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) (83L) — implementation contract for DFD edge-hover data reveal (issue #14). CP1–CP4 build order. Success criteria: `normalizeEdgeData` pure helper; `dataLines: string[]` on `FlowElementData` edge variant; `position:fixed` HTML hover tooltip listing full data items under `source → target` header; long labels render truncated `…` preview inline (not suppressed); native `<title>` removed; `data-contract`/`data-contract-type` unchanged; dim/highlight focus preserved; CI Playwright check + visual screenshot; `bun run test` exit 0. +[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (142L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). +[`docs/spec/help-overlay.md`](../spec/help-overlay.md) (58L) — implementation contract for the help overlay: `HelpModal` component contract (view prop, Modal primitive, three view branches + shared Keyboard section), `{type:'help'}` `ShortcutAction` resolution order (after editable guard, before modifier guard, gated off ctrl/meta/alt), `showHelp` state in shell, `.help-toggle` top-bar button placement, `.help-*` CSS classes, test-plan (`test-help-overlay.ts`: `?` button + key open, Escape closes, view-aware content, editable guard). 2026-07-14 addendum: Graph and Flows "How to explore" sections gained a Search row, and every view's Keyboard section now lists [`/`](../..) (focus that view's search input). +[`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) (372L) — implementation contract for key-inheritance-lineage: `buildInheritedConnections` key-edge connected-component algorithm (FK ⊆ PK, both directions, cycle-safe, identifying-1:many covered), DG ephemeral `edge.inherited` lifecycle (SHIFT+HOVER trigger — not click/select), DD `SpotlightOverlay` dotted inherited lines SHIFT-GATED in `DictionaryView`, `SPOTLIGHT_LINE_INHERITED` constant placement, test-plan (`test-spotlight-inherited.ts`, `test-inherited-edges-no-leak.ts`, `test-graph-inherited-edges.ts`, `test-navigator-teardown.ts`). Design doc [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) also rewritten to the key-edge rule. +[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) (185L) — implementation contract for Graph/Flows search: SC1–SC11 (Graph `search-match`/`search-dim` classes + `n of N` count + Enter-cycle via `navigateToEntity`, resilience across hover/lineage/relayout/SSE-refresh, per-bar body-toggle title-vs-body matching per kind, Flows cross-diagram matcher over non-synthetic diagrams + results dropdown + `selectDiagramById` navigation including sub-DFDs, base-token role-split-suffix matching, [`/`](../..) → `{type:'search'}` shortcut wired through `DictionaryViewHandle.focusSearch()`, no persistence/layout/network side effects, bundle-only parity pinned as SC11), CP1–CP4 build order (pure matchers, Graph wiring, Flows wiring, [`/`](../..) shortcut + docs). Change log: checkpoint-table column-schema correction, CP4 scope extended to cover the keymap spec surfaces ([`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md), [`docs/spec/help-overlay.md`](../spec/help-overlay.md)). Implementation log: shipped 2026-07-14 across 7 commits via the /autopilot subagent loop, closing issue #18. +[`docs/guides/commands.md`](../guides/commands.md) (155L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) key (focus the active view's search bar — Graph, Dictionary, Flows) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. +[`docs/glossary.md`](../glossary.md) (52L) — canonical vocabulary: DG, DD, DFD, DE, DS, EE, Process, Data Flow. +[`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (118L) — research notes on SSADM DFD rules. +[`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) (129L) — research notes on DFD layout engines and Yourdon leveling used in specifying the dfd-overhaul approach; primary source for the ELK algorithm selection and band-partitioning option names. ### scripts @@ -446,11 +447,10 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify - [`models/`](../../models) is a container of five sibling model roots — `key-inherited/`, `orm-hybrid/`, `orm-pure/`, `broken-demo/`, `llm-memory-db-mssql/` — each with its own `ignatius.yml`. All model roots use the v0.11.0 folder layout: entities under `data/`, group defs under `groups/` (optional), DFD externals under root `externals/` (optional), non-db stores under root `stores/` (optional). `broken-demo/` is the deliberately-broken fixture (4 global + 8 entity = 12 total findings; 9 parsed entities). `key-inherited/` carries demo DFDs under `flows/` (order-to-cash with sub-DFD + refund), a root `externals/Customer.md`, and a root `stores/gateway-log.md`. [`models/llm-memory-db-mssql/`](../../models/llm-memory-db-mssql) is the proving model for the dfd-overhaul (6 activity diagrams, 7+ flows including dense diagrams). Reference/fixture data, not a domain. - [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) — imported by [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (called after raw parse), [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) (`CONTEXT_DIAGRAM_ID`/`SYSTEM_PROCESS_ID` for balancing guard), and [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (`SYNTHETIC_DIAGRAM_IDS` to exclude synthesized diagrams from DD sidebar). - [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) — imported by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (for `isInlineLabel`) and by [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (for `computeElkLayout` + `terminateQuietly`). -- [`src/edge-routes.ts`](../../src/edge-routes.ts) — imported by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) for coordinate math and by [`test/checks/test-edge-routes.ts`](../../test/checks/test-edge-routes.ts) as a unit test. - [`src/app/logic/relationship-key.ts`](../../src/app/logic/relationship-key.ts) — imported by [`src/app/components/entity/ChildrenTable.tsx`](../../src/app/components/entity/ChildrenTable.tsx) only. - [`src/flows/titlelize.ts`](../../src/flows/titlelize.ts) — imported by [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) only. [`src/flows/flow-usage-index.ts`](../../src/flows/flow-usage-index.ts) — exports `buildEntityUsageIndex` and `buildFlowNodeUsageIndex`; `buildEntityUsageIndex` imported by [`src/app/App.tsx`](../../src/app/App.tsx) and [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx); `buildFlowNodeUsageIndex` imported by [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx). [`src/types/css-highlight.d.ts`](../../src/types/css-highlight.d.ts) — ambient declarations consumed globally. - [`src/app/logic/spotlight.ts`](../../src/app/logic/spotlight.ts) imported by [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) only (`spotlight-inherited.ts` no longer calls `buildSpotlightConnections` — de-dup is now implicit in the key-edge connected-component traversal). [`src/app/logic/flow-spotlight.ts`](../../src/app/logic/flow-spotlight.ts) imported by [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) only. [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) only. [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) (DD dotted inherited lines) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (DG ephemeral inherited cy edges on select). [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) imported by [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) only. [`src/app/components/entity/GridCard.tsx`](../../src/app/components/entity/GridCard.tsx) imported by [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) only. [`src/app/components/entity/FlowNodeGridCard.tsx`](../../src/app/components/entity/FlowNodeGridCard.tsx) imported by [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) only. The spotlight-grid cluster (7 modules) is self-contained for DD browse-lens; `spotlight-inherited.ts` additionally cross-cuts into DG GraphView. -- [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) imported by [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) only. [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) imported by [`src/app/App.tsx`](../../src/app/App.tsx) only. Adding a new shortcut key requires changes in `shortcuts.ts` (resolver switch + `ShortcutAction` union), `useKeyboardShortcuts.ts` (new callback in `KeyboardShortcutsConfig` + case in switch), shell `App.tsx` (state + callback wiring + component render), `FabMenu.tsx` (badge if applicable), and `keyboard-nav-shortcuts.md` + `help-overlay.md` specs. `HelpModal` imported by [`src/app/App.tsx`](../../src/app/App.tsx) only. [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) imported by [`src/flow-view/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (DFD zoom math) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (graph zoom math); also imported by `test-zoom-scale.ts`. +- [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) imported by [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) only. [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) imported by [`src/app/App.tsx`](../../src/app/App.tsx) only. Adding a new shortcut key requires changes in `shortcuts.ts` (resolver switch + `ShortcutAction` union), `useKeyboardShortcuts.ts` (new callback in `KeyboardShortcutsConfig` + case in switch), shell `App.tsx` (state + callback wiring + component render), `FabMenu.tsx` (badge if applicable), and `keyboard-nav-shortcuts.md` + `help-overlay.md` specs. `HelpModal` imported by [`src/app/App.tsx`](../../src/app/App.tsx) only. [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) imported by [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (DFD zoom math) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) (graph zoom math); also imported by `test-zoom-scale.ts`. - [`src/model/layout-fingerprint.ts`](../../src/model/layout-fingerprint.ts) imported by [`src/server/server.ts`](../../src/server/server.ts) (for `/api/model`) and [`src/generators/app.ts`](../../src/generators/app.ts) (for static `__LAYOUT_KEY__` injection). [`src/app/views/graph/layout-store.ts`](../../src/app/views/graph/layout-store.ts) imported by [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) and [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx). [`src/model/wikilink.ts`](../../src/model/wikilink.ts) imported by [`src/model/parse.ts`](../../src/model/parse.ts) and [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts). - [`src/cli/open-browser.ts`](../../src/cli/open-browser.ts) is dynamically imported by [`src/cli/cli.ts`](../../src/cli/cli.ts) only when `--open` is passed to `serve`. - [`src/types/file-imports.d.ts`](../../src/types/file-imports.d.ts) — ambient module declarations for `*.html`, `*.css` imports. [`src/types/cytoscape-navigator.d.ts`](../../src/types/cytoscape-navigator.d.ts) — ambient declarations for `cytoscape-navigator`. [`src/types/cytoscape-fcose.d.ts`](../../src/types/cytoscape-fcose.d.ts) — ambient declarations for `cytoscape-fcose`. [`bun-env.d.ts`](../../bun-env.d.ts) — ambient Bun type augmentations. @@ -459,4 +459,5 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify - CLI subcommand status: `serve` ✓ active; `validate` ✓ active; `export` ✓ active; `version` ✓ active; `update` ✓ active; `dict` ✗ removal stub; `graph` ✗ removal stub; `flow` ✗ removal stub. - Binary name is `ignatius` (`dist/ignatius`); package.json `name` is `ignatius`. The repo *directory* `derek-db-generator/` is the only remaining `derek` reference — a known leftover. - [`assets/noorm-logo.svg`](../../assets/noorm-logo.svg) — default branding logo, imported by [`src/theme/branding-defaults.ts`](../../src/theme/branding-defaults.ts) as a file reference. -- Deterministic substrate: [`.claude/project/deterministic-signals.md`](deterministic-signals.md) +- [`src/app/logic/search.ts`](../../src/app/logic/search.ts) (graph-flow-search) imported by [`src/app/App.tsx`](../../src/app/App.tsx) (Graph/Flows matchers + `searchFlowDiagrams`) and [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (pre-existing Dictionary matchers, unchanged). [`src/app/components/ui/SearchBar.tsx`](../../src/app/components/ui/SearchBar.tsx) imported by [`src/app/App.tsx`](../../src/app/App.tsx) only (two instances). [`src/app/components/flow/FlowSearchResults.tsx`](../../src/app/components/flow/FlowSearchResults.tsx) imported by [`src/app/App.tsx`](../../src/app/App.tsx) only (rendered inside the flow `SearchBar`'s children slot). +- Deterministic substrate: [`docs/wiki/scan.md`](scan.md) diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 7fee3e0..56e97e5 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -1,22 +1,18 @@ ---- -generated_at: 2026-06-20T07:57:54Z -atomic_version: 5.5.0 ---- # Deterministic signals ## Tree ├── .cursor/ (1) │ └── rules/ (1) -│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (f56f031, 180L, 21995ch, 22220B) +│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (1836e40, 181L, 22730ch, 22957B) ├── .github/ (1) │ └── workflows/ (2) │ ├── ci.yml (8718b72, 56L, 1568ch, 1568B) │ └── release-please.yml (d099f8c, 85L, 2750ch, 2752B) ├── assets/ (1) │ └── noorm-logo.svg (8d46c28, 6L, 2529ch, 2529B) -├── docs/ (6) -│ ├── design/ (26) +├── docs/ (5) +│ ├── design/ (27) │ │ ├── app-tsx-decomposition.md (6620907, 142L, 9400ch, 9870B) │ │ ├── bidirectional-predicates.md (5e872cc, 67L, 3485ch, 3522B) │ │ ├── branding.md (6025a97, 160L, 7673ch, 7956B) @@ -28,6 +24,7 @@ atomic_version: 5.5.0 │ │ ├── dict-navigation.md (0c0f0f7, 100L, 5253ch, 5278B) │ │ ├── example-instance-tables.md (ba05799, 135L, 9385ch, 9441B) │ │ ├── folder-model.md (0c51512, 104L, 5558ch, 5591B) +│ │ ├── graph-flow-search.md (71e93a9, 82L, 7857ch, 7897B) │ │ ├── graph-position-persistence.md (7d5bf76, 118L, 7012ch, 7070B) │ │ ├── help-overlay.md (f573c6b, 62L, 3274ch, 3291B) │ │ ├── ignatius-project-config.md (854740b, 107L, 6806ch, 6838B) @@ -45,7 +42,7 @@ atomic_version: 5.5.0 │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) │ ├── guides/ (10) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (ccbbf62, 154L, 8444ch, 8480B) +│ │ ├── commands.md (ee6a7f8, 155L, 8502ch, 8540B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) │ │ ├── flows.md (ec7f7cd, 148L, 8533ch, 8577B) │ │ ├── folder-format.md (ec20419, 159L, 5499ch, 5517B) @@ -57,7 +54,7 @@ atomic_version: 5.5.0 │ ├── research/ (2) │ │ ├── dfd-layout-and-leveling.md (091455c, 129L, 14807ch, 14993B) │ │ └── ssadm-dfd-rules.md (7d83a02, 118L, 8356ch, 8460B) -│ ├── spec/ (32) +│ ├── spec/ (33) │ │ ├── app-tsx-decomposition.md (430d88c, 246L, 21478ch, 22248B) │ │ ├── bidirectional-predicates.md (e6355c0, 157L, 7664ch, 7712B) │ │ ├── branding.md (bd50adc, 102L, 8976ch, 9031B) @@ -74,15 +71,16 @@ atomic_version: 5.5.0 │ │ ├── dict-polish.md (eef2bdc, 87L, 6588ch, 6618B) │ │ ├── example-instance-tables.md (a7c76ab, 140L, 15242ch, 15296B) │ │ ├── folder-model.md (9221504, 117L, 11731ch, 11793B) +│ │ ├── graph-flow-search.md (fc4ccd6, 156L, 13782ch, 13954B) │ │ ├── graph-position-persistence.md (ae7bdcd, 106L, 8067ch, 8126B) -│ │ ├── help-overlay.md (71ecb8e, 54L, 3605ch, 3611B) +│ │ ├── help-overlay.md (605fbef, 58L, 4378ch, 4390B) │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) -│ │ ├── keyboard-nav-shortcuts.md (e8c4d50, 130L, 10048ch, 10121B) +│ │ ├── keyboard-nav-shortcuts.md (06d68b5, 142L, 11569ch, 11654B) │ │ ├── noorm-flow-discovery.md (4b7f5a8, 83L, 10554ch, 10666B) │ │ ├── noorm-modeling-skill.md (947190e, 204L, 22457ch, 22582B) │ │ ├── process-flows.md (e5e6472, 682L, 83302ch, 83868B) -│ │ ├── render-perf-indexing.md (81d5c58, 202L, 14691ch, 14869B) +│ │ ├── render-perf-indexing.md (cf067c8, 231L, 17191ch, 17394B) │ │ ├── schema-lint-and-error-ux.md (6a3d652, 141L, 20922ch, 20995B) │ │ ├── src-root-organization.md (828c298, 82L, 6295ch, 6363B) │ │ ├── unified-app-polish.md (91f397f, 194L, 20173ch, 20294B) @@ -90,8 +88,7 @@ atomic_version: 5.5.0 │ │ ├── viewer-fab-ux.md (9a889a1, 101L, 8717ch, 8759B) │ │ ├── viewer-ux-polish.md (bfcb728, 180L, 28658ch, 28880B) │ │ └── wiki-entity-links.md (569742d, 79L, 4329ch, 4377B) -│ ├── glossary.md (3c64c68, 52L, 2453ch, 2481B) -│ └── handoff-orthogonal-edges.md (98c09b9, 131L, 10940ch, 11020B) +│ └── glossary.md (3c64c68, 52L, 2453ch, 2481B) ├── models/ (6) │ ├── broken-demo/ (3) │ │ ├── data/ (12) @@ -211,20 +208,21 @@ atomic_version: 5.5.0 │ └── SKILL.md (cc2af30, 48L, 4879ch, 4925B) ├── spec/ (1) │ └── spec.md (1472997, 464L, 20238ch, 20368B) [generated] -├── src/ (10) +├── src/ (9) │ ├── app/ (11) -│ │ ├── components/ (5) +│ │ ├── components/ (6) │ │ │ ├── entity/ (9 files, 0 dirs) │ │ │ ├── findings/ (1 file, 0 dirs) +│ │ │ ├── flow/ (1 file, 0 dirs) │ │ │ ├── flow-node/ (4 files, 0 dirs) │ │ │ ├── process/ (6 files, 0 dirs) -│ │ │ └── ui/ (4 files, 0 dirs) +│ │ │ └── ui/ (5 files, 0 dirs) │ │ ├── dom/ (2) │ │ │ ├── body-links.ts (d795f15, 70L, 2869ch, 2875B) │ │ │ └── theme-css-vars.ts (61ca750, 114L, 6255ch, 6267B) │ │ ├── hooks/ (4) │ │ │ ├── useHashRoute.ts (c50964c, 121L, 5142ch, 5150B) -│ │ │ ├── useKeyboardShortcuts.ts (5f179f3, 88L, 3307ch, 3321B) +│ │ │ ├── useKeyboardShortcuts.ts (33998d8, 92L, 3462ch, 3478B) │ │ │ ├── useModelData.ts (4877d58, 158L, 5483ch, 5609B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) │ │ ├── logic/ (11) @@ -234,8 +232,8 @@ atomic_version: 5.5.0 │ │ │ ├── flow-node-ids.ts (aa105f6, 28L, 950ch, 954B) │ │ │ ├── flow-spotlight.ts (0725869, 136L, 5096ch, 5114B) │ │ │ ├── relationship-key.ts (21bbb6e, 21L, 950ch, 956B) -│ │ │ ├── search.ts (c37e8e6, 124L, 4583ch, 4583B) -│ │ │ ├── shortcuts.ts (3a08de4, 111L, 4219ch, 4247B) +│ │ │ ├── search.ts (3c76b8b, 272L, 10130ch, 10138B) +│ │ │ ├── shortcuts.ts (bf68ab5, 115L, 4433ch, 4465B) │ │ │ ├── spotlight-inherited.ts (8fa6183, 220L, 9495ch, 9873B) │ │ │ ├── spotlight-lines.ts (0138798, 100L, 3696ch, 3724B) │ │ │ └── spotlight.ts (d4aec20, 101L, 3534ch, 3540B) @@ -243,12 +241,12 @@ atomic_version: 5.5.0 │ │ │ ├── dict/ (1 file, 0 dirs) │ │ │ ├── flow/ (2 files, 0 dirs) │ │ │ └── graph/ (7 files, 0 dirs) -│ │ ├── App.tsx (74d1fcf, 593L, 26248ch, 26375B) +│ │ ├── App.tsx (429a948, 771L, 35314ch, 35479B) │ │ ├── globals.d.ts (77489f4, 46L, 2556ch, 2562B) │ │ ├── hash-router.ts (04e4fa6, 95L, 2804ch, 2806B) │ │ ├── index.html (e560504, 14L, 396ch, 396B) │ │ ├── main.tsx (881fa67, 12L, 293ch, 293B) -│ │ └── styles.css (9eba952, 2577L, 58174ch, 59278B) +│ │ └── styles.css (49b4ec5, 2741L, 62529ch, 63637B) │ ├── cli/ (7) │ │ ├── cli.ts (284a888, 336L, 11989ch, 14833B) │ │ ├── discover.ts (2a355d6, 176L, 5910ch, 5940B) @@ -259,7 +257,7 @@ atomic_version: 5.5.0 │ │ └── version.ts (8ca92d2, 6L, 312ch, 314B) │ ├── flow-view/ (5) │ │ ├── FlowChrome.tsx (caf68e2, 409L, 15329ch, 15750B) -│ │ ├── FlowDiagramSvg.tsx (91e3b4d, 1716L, 69938ch, 71945B) +│ │ ├── FlowDiagramSvg.tsx (77a60a8, 1750L, 71330ch, 73341B) │ │ ├── elk-flow-layout.ts (2c68f80, 353L, 14722ch, 15545B) │ │ ├── flow-layout.ts (57004f3, 603L, 24035ch, 24826B) │ │ └── zoom-scale.ts (5a575f2, 74L, 3068ch, 3092B) @@ -284,13 +282,13 @@ atomic_version: 5.5.0 │ ├── theme/ (2) │ │ ├── branding-defaults.ts (4ede4cb, 78L, 2512ch, 2514B) │ │ └── theme-defaults.ts (e3cdd69, 183L, 6248ch, 6400B) -│ ├── types/ (3) -│ │ ├── css-highlight.d.ts (68d2921, 38L, 1071ch, 1071B) -│ │ ├── cytoscape-navigator.d.ts (10d3feb, 51L, 1802ch, 1806B) -│ │ └── file-imports.d.ts (c8fc2ce, 46L, 1951ch, 1953B) -│ └── edge-routes.ts (58ce6af, 146L, 5589ch, 5609B) +│ └── types/ (4) +│ ├── css-highlight.d.ts (68d2921, 38L, 1071ch, 1071B) +│ ├── cytoscape-fcose.d.ts (5a609a6, 17L, 673ch, 673B) +│ ├── cytoscape-navigator.d.ts (10d3feb, 51L, 1802ch, 1806B) +│ └── file-imports.d.ts (c8fc2ce, 46L, 1951ch, 1953B) ├── test/ (4) -│ ├── checks/ (84) +│ ├── checks/ (86) │ │ ├── test-api-model.ts (7cb8b51, 97L, 3807ch, 3809B) │ │ ├── test-app-gen-zero-diagrams.ts (34d2a2f, 130L, 4325ch, 4581B) │ │ ├── test-app-title.ts (53472fb, 144L, 5253ch, 5631B) @@ -320,7 +318,6 @@ atomic_version: 5.5.0 │ │ ├── test-dict-route.ts (941d985, 70L, 1848ch, 1848B) │ │ ├── test-discover.ts (bb28ca7, 181L, 7642ch, 7674B) │ │ ├── test-edge-hover-data.ts (8976f0c, 169L, 5555ch, 5799B) -│ │ ├── test-edge-routes.ts (004acc0, 176L, 6261ch, 6285B) │ │ ├── test-elk-flow-positions.ts (a5c93c5, 162L, 5884ch, 6172B) │ │ ├── test-entity-usage-index.ts (3ac8bb6, 190L, 7486ch, 7498B) │ │ ├── test-export-union-injection.ts (7827bfc, 200L, 9296ch, 9792B) @@ -329,6 +326,7 @@ atomic_version: 5.5.0 │ │ ├── test-flow-endpoints.ts (8e80450, 316L, 13756ch, 13778B) │ │ ├── test-flow-fingerprint.ts (be5f9d0, 393L, 14910ch, 14982B) │ │ ├── test-flow-leveling.ts (626cefc, 468L, 18579ch, 18637B) +│ │ ├── test-flow-search.ts (d4b2cb4, 255L, 13784ch, 13812B) │ │ ├── test-flow-serve.ts (272afb2, 186L, 7205ch, 7707B) │ │ ├── test-flow-spotlight-connections.ts (9e58dcd, 349L, 16296ch, 16376B) │ │ ├── test-flow-wikilink.ts (699f762, 49L, 2157ch, 2165B) @@ -336,6 +334,7 @@ atomic_version: 5.5.0 │ │ ├── test-graph-branding.ts (3edc892, 174L, 8180ch, 8426B) │ │ ├── test-graph-bundle-mode.ts (669847e, 213L, 8975ch, 8983B) │ │ ├── test-graph-inherited-edges.ts (6c93c7e, 240L, 10375ch, 10683B) +│ │ ├── test-graph-search.ts (45f3d0c, 310L, 14809ch, 14851B) │ │ ├── test-hash-router.ts (46f7614, 219L, 6942ch, 6998B) │ │ ├── test-help-overlay.ts (8d52edc, 140L, 5469ch, 5697B) │ │ ├── test-inherited-edges-no-leak.ts (0c8a942, 120L, 5250ch, 5348B) @@ -357,7 +356,7 @@ atomic_version: 5.5.0 │ │ ├── test-relationship-row-key.ts (4642966, 81L, 4077ch, 4097B) │ │ ├── test-semantic-colors.ts (60b1f25, 71L, 2206ch, 2206B) │ │ ├── test-serve-port.ts (6e3e5fb, 52L, 1928ch, 2192B) -│ │ ├── test-shortcuts.ts (bc038af, 311L, 14303ch, 14453B) +│ │ ├── test-shortcuts.ts (0280979, 346L, 15964ch, 16132B) │ │ ├── test-spotlight-connections.ts (0806e1c, 282L, 10879ch, 10931B) │ │ ├── test-spotlight-inherited.ts (8f7b2c4, 388L, 18139ch, 18383B) │ │ ├── test-spotlight-lines.ts (e0cb78a, 207L, 9741ch, 9775B) @@ -371,6 +370,7 @@ atomic_version: 5.5.0 │ │ ├── test-validate-examples.ts (d90bba4, 301L, 12219ch, 12276B) │ │ ├── test-validate-flows.ts (8ed98dc, 413L, 16252ch, 16268B) │ │ ├── test-validate-refs.ts (71817dd, 424L, 20636ch, 20712B) +│ │ ├── test-viewer-search.ts (6215f06, 324L, 15034ch, 15096B) │ │ ├── test-wikilink.ts (05926d4, 90L, 4473ch, 4489B) │ │ ├── test-wrap-label.ts (0e07db4, 43L, 2143ch, 2147B) │ │ ├── test-zoom-input.ts (937c495, 323L, 14623ch, 15881B) @@ -405,23 +405,19 @@ atomic_version: 5.5.0 │ ├── notes/ (2) │ │ ├── another-idea.md (1e542c5, 384L, 16674ch, 17352B) │ │ └── branding-screenshots.md (027cad8, 33L, 2524ch, 2537B) -│ └── visual/ (66) +│ └── visual/ (64) │ ├── screenshot-branding.ts (702ea86, 65L, 2336ch, 2338B) │ ├── screenshot-chrome-unification.ts (a7d7479, 326L, 13382ch, 14842B) │ ├── screenshot-cp4-live-dict.ts (f0f85bc, 34L, 1043ch, 1043B) │ ├── screenshot-dict-examples.ts (5feecf3, 105L, 3919ch, 3943B) -│ ├── screenshot-edge-style.ts (374c3af, 48L, 1568ch, 1570B) │ ├── screenshot-entity-modal.ts (5f21ade, 163L, 4943ch, 4961B) │ ├── screenshot-findings-panel.ts (dcc3ccb, 61L, 2043ch, 2049B) │ ├── screenshot-flow-theme.ts (c6603f4, 237L, 9602ch, 10272B) │ ├── screenshot-graph-findings.ts (edcbe6d, 132L, 4663ch, 4677B) │ ├── screenshot-hover-fade.ts (674ea85, 97L, 3368ch, 3372B) │ ├── screenshot-lineage-highlight.ts (4b8cd1e, 104L, 3840ch, 3860B) -│ ├── screenshot-orthogonal-edges.ts (0ef2c96, 78L, 3209ch, 3217B) -│ ├── screenshot-polyline-elk.ts (9340952, 132L, 5545ch, 5551B) │ ├── screenshot-position-persist.ts (d0580d9, 288L, 12113ch, 12280B) │ ├── screenshot-predicate-hover.ts (b45d185, 138L, 5952ch, 5964B) -│ ├── screenshot-segments-elk.ts (ca56023, 152L, 6114ch, 6122B) │ ├── screenshot-themes.ts (14d36c6, 40L, 1335ch, 1335B) │ ├── screenshot-toggle.ts (e406fba, 26L, 800ch, 800B) │ ├── screenshot-view-switch.ts (4a5e575, 228L, 8905ch, 9205B) @@ -469,7 +465,9 @@ atomic_version: 5.5.0 │ ├── test-dict-view.ts (d59319f, 223L, 9004ch, 9368B) │ ├── test-export-offline.ts (9d1630a, 308L, 14287ch, 15241B) │ ├── test-flow-live-reload.ts (b173873, 202L, 7775ch, 8463B) +│ ├── test-flow-search.ts (548b28e, 86L, 3239ch, 3253B) │ ├── test-graph-inherited-lines.ts (320bd0d, 330L, 14352ch, 14548B) +│ ├── test-graph-search.ts (bed2099, 88L, 3045ch, 3055B) │ ├── test-process-node-size.ts (b43170e, 166L, 6641ch, 7051B) │ └── test-sse-playwright.ts (636e685, 75L, 2474ch, 2476B) ├── trash/ (1) @@ -494,30 +492,30 @@ atomic_version: 5.5.0 │ └── App.tsx (8ff50d4, 78L, 2470ch, 2474B) ├── .gitignore (6d3972b, 53L, 809ch, 811B) ├── .signalsignore (50ea1be, 28L, 1036ch, 1050B) -├── CHANGELOG.md (c52b28c, 193L, 15151ch, 15157B) -├── CLAUDE.md (f56f031, 180L, 21995ch, 22220B) +├── CHANGELOG.md (871fba9, 218L, 17630ch, 17636B) +├── CLAUDE.md (1836e40, 181L, 22730ch, 22957B) ├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B) ├── README.md (727982d, 69L, 4028ch, 4036B) ├── bun-env.d.ts (4404f36, 16L, 284ch, 284B) -├── bun.lock (7a73609, 100L, 7194ch, 7194B) +├── bun.lock (01dab35, 107L, 7820ch, 7820B) ├── bunfig.toml (3ab75b0, 2L, 35ch, 35B) ├── install.sh (64a6757, 116L, 3578ch, 3594B) -├── package.json (b3ba481, 47L, 1771ch, 1773B) +├── package.json (2fca4c9, 48L, 1804ch, 1806B) ├── release-please-config.json (9f191fe, 15L, 416ch, 416B) -├── release-please-manifest.json (9abeafe, 3L, 20ch, 20B) +├── release-please-manifest.json (c2ec4bd, 3L, 20ch, 20B) └── tsconfig.json (1d9427f, 37L, 870ch, 870B) ## Manifests -- package.json: name=ignatius, version=0.11.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] +- package.json: name=ignatius, version=0.12.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] ## Languages -- TypeScript: 57127 LOC (69%), 263 files (45%) -- Markdown: 20225 LOC (24%), 297 files (50%) -- CSS: 2764 LOC (3%), 2 files (0%) +- TypeScript: 58921 LOC (70%), 265 files (45%) +- Markdown: 20404 LOC (24%), 298 files (50%) +- CSS: 2928 LOC (3%), 2 files (0%) - YAML: 1340 LOC (1%), 14 files (2%) - Shell: 116 LOC (0%), 1 file (0%) -- JSON: 102 LOC (0%), 4 files (0%) +- JSON: 103 LOC (0%), 4 files (0%) - HTML: 27 LOC (0%), 2 files (0%) - TOML: 2 LOC (0%), 1 file (0%) From b13500327d984d7af299eb04291b86e650715f80 Mon Sep 17 00:00:00 2001 From: Danilo Alonso <damusix@gmail.com> Date: Tue, 14 Jul 2026 14:48:56 -0400 Subject: [PATCH 10/13] =?UTF-8?q?docs(spec):=20CP5=20=E2=80=94=20switch=20?= =?UTF-8?q?control,=20Cmd/Ctrl+K,=20chrome=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on the live branch: descriptive toggle switch replaces the bare Body button, Cmd/Ctrl+K as the standard search chord, visual tightening pass, and the flow bar must clear the DFD breadcrumbs. --- docs/design/graph-flow-search.md | 4 +++- docs/spec/graph-flow-search.md | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/design/graph-flow-search.md b/docs/design/graph-flow-search.md index 5c49aa5..0b626cb 100644 --- a/docs/design/graph-flow-search.md +++ b/docs/design/graph-flow-search.md @@ -12,7 +12,7 @@ The Graph view (DG) and the Flows view (DFD) have no way to find a node by name. - 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 **body toggle** opts into also matching the markdown body text. +- 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. @@ -58,10 +58,12 @@ The Graph view (DG) and the Flows view (DFD) have no way to find a node by name. - 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. diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index f9abd01..ae2262a 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -31,13 +31,14 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- - 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 body toggle off, a term that appears only in a node's markdown body does not match; toggling body on makes it match. 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`. +- 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 — `/` resolves to `{ type: 'search' }` after the editable guard and gated off ctrl/meta/alt; the shell focuses the active view's search input (graph bar, flow bar, or the Dictionary's existing input via `DictionaryViewHandle.focusSearch()`). Typing `/` inside any input/textarea/contenteditable/modal inserts the character. Escape in a search input clears the term and blurs. +- 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 @@ -49,6 +50,7 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- | 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 @@ -137,6 +139,14 @@ M CLAUDE.md — feature-map row (and '/' added to t ## 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. From 015080781d9e67bc5213205a9d40c6bbd9156c87 Mon Sep 17 00:00:00 2001 From: Danilo Alonso <damusix@gmail.com> Date: Tue, 14 Jul 2026 15:49:36 -0400 Subject: [PATCH 11/13] feat(search): switch control, Cmd/Ctrl+K, chrome clearance User-feedback round: the body toggle becomes an accessible switch labeled 'Include descriptions'; Cmd/Ctrl+K focuses search on all views (pre-editable-guard, like the zoom chords) alongside /; the flow bar offsets below the DFD breadcrumbs via a measured --flow-search-bar-top; visual tightening on the bar and dropdown in both themes. CP5 of docs/spec/graph-flow-search.md. --- CLAUDE.md | 2 +- docs/guides/commands.md | 4 +- docs/spec/keyboard-nav-shortcuts.md | 28 +++++--- src/app/components/ui/HelpModal.tsx | 6 +- src/app/components/ui/SearchBar.tsx | 25 ++++--- src/app/hooks/useKeyboardShortcuts.ts | 3 +- src/app/logic/shortcuts.ts | 29 +++++--- src/app/styles.css | 96 ++++++++++++++++++++++++--- src/flow-view/FlowChrome.tsx | 62 ++++++++++++++--- test/checks/test-flow-search.ts | 90 +++++++++++++++++++++++-- test/checks/test-graph-search.ts | 14 +++- test/checks/test-shortcuts.ts | 68 +++++++++++++++++++ test/visual/test-flow-search.ts | 18 ++++- test/visual/test-graph-search.ts | 21 ++++-- 14 files changed, 396 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 771cec6..0e62ad0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,7 +152,7 @@ Paths are relative to `docs/design/`, `docs/spec/`, `docs/guides/`, and `skills/ | Dict navigation + polish | dict-navigation | dict-navigation, dict-polish | — | — | | DD browse lens: spotlight grid (entity + flow-node cards, hover/pin spotlight; solid FK predicate lines + dashed cross-domain data-flow lines; hover-reveal labels; off-screen chips; facing-edge anchoring; focus/isolate mode for scale) | dd-spotlight-grid | dd-spotlight-grid | — | — | | Graph viewer FAB UX | viewer-fab-ux | viewer-fab-ux | — | — | -| Keyboard navigation shortcuts (g/d/f view switch, l DG layout, b DD lens, `/` search-focus; 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 | — | — | diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 0f2ce26..dc6fdb2 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -122,10 +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 | -| `/` | Focus the search bar — Graph, Dictionary, Flows | +| `/` 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/keyboard-nav-shortcuts.md b/docs/spec/keyboard-nav-shortcuts.md index 847bafa..c2e8cd7 100644 --- a/docs/spec/keyboard-nav-shortcuts.md +++ b/docs/spec/keyboard-nav-shortcuts.md @@ -39,23 +39,28 @@ focus is an editable target (`input` / `textarea` / `select` / 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. The search feature itself (bars, -matching, dimming) is owned by `docs/spec/graph-flow-search.md`; this resolver -owns only the `/` key binding and the shell's focus-routing dispatch. +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** (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): +**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: @@ -81,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 @@ -140,3 +146,9 @@ real-browser Playwright check, per the project's "test the actual runtime" lesso **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/src/app/components/ui/HelpModal.tsx b/src/app/components/ui/HelpModal.tsx index 6c4d8b0..6f78186 100644 --- a/src/app/components/ui/HelpModal.tsx +++ b/src/app/components/ui/HelpModal.tsx @@ -39,7 +39,7 @@ const GRAPH_EXPLORE: Row[] = [ { term: 'Layouts', desc: 'Organic clusters related entities by force; Hierarchical stacks them in dependency layers. Toggle with L.' }, { term: 'Shift + hover', desc: 'Reveals dotted lines to an entity’s whole key-inheritance family — every relative that shares its primary key, however far apart.' }, { term: 'Click · drag · zoom', desc: 'Click an entity for full detail; drag to rearrange (positions are saved); scroll or ⌘/Ctrl +/−/0 to zoom.' }, - { term: 'Search', desc: 'Type to highlight matches and dim the rest; toggle Body to also match markdown text; Enter cycles through matches.' }, + { term: 'Search', desc: 'Type to highlight matches and dim the rest; flip Include descriptions to also match markdown text; Enter cycles through matches.' }, ]; const GRAPH_STYLES: Row[] = [ @@ -67,7 +67,7 @@ const FLOW_SYMBOLS: Row[] = [ const FLOW_EXPLORE: Row[] = [ { term: 'Levels & drill-down', desc: 'Numbered processes decompose — click one to drill into its sub-diagram; breadcrumbs walk back up. The context and overview levels are derived automatically.' }, { term: 'Inspect', desc: 'Hover a connector to see the exact data items it carries; the ⓘ badge on any node opens its details (a db: store opens the full entity).' }, - { term: 'Search', desc: 'Type to find matches across every diagram, including sub-DFDs — results list by diagram; click one to navigate there. Non-matches dim in the diagram.' }, + { term: 'Search', desc: '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.' }, ]; function shortcutRows(view: ViewName): Row[] { @@ -80,7 +80,7 @@ function shortcutRows(view: ViewName): Row[] { if (view === 'graph' || view === 'flow') { rows.push({ term: '⌘/Ctrl +/−/0', desc: 'Zoom the canvas in, out, or reset.' }); } - rows.push({ term: '/', desc: 'Focus the search bar.' }); + rows.push({ term: '/ · ⌘/Ctrl K', desc: 'Focus the search bar.' }); rows.push({ term: '?', desc: 'Open this help.' }); return rows; } diff --git a/src/app/components/ui/SearchBar.tsx b/src/app/components/ui/SearchBar.tsx index f8af109..0452254 100644 --- a/src/app/components/ui/SearchBar.tsx +++ b/src/app/components/ui/SearchBar.tsx @@ -120,15 +120,22 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>( onKeyDown={handleKeyDown} aria-label={ariaLabel} /> - <button - type="button" - className={`viewer-search-body-toggle${includeBody ? ' viewer-search-body-toggle--active' : ''}`} - aria-pressed={includeBody} - onClick={() => onIncludeBodyChange(!includeBody)} - title="Include body text in search" - > - Body - </button> + {/* Toggle switch (role="switch", not a checkbox/button-pressed pattern) — + opts markdown body text into matching. Wrapping <label> gives the + visible text a native click/tap target and doubles as the button's + accessible name; the button itself carries no separate aria-label. */} + <label className="viewer-search-switch"> + <button + type="button" + role="switch" + aria-checked={includeBody} + className="viewer-search-switch-track" + onClick={() => onIncludeBodyChange(!includeBody)} + > + <span className="viewer-search-switch-thumb" /> + </button> + <span className="viewer-search-switch-label">Include descriptions</span> + </label> {matchCount !== null && ( <span className="viewer-search-count">{matchCount} of {totalCount}</span> )} diff --git a/src/app/hooks/useKeyboardShortcuts.ts b/src/app/hooks/useKeyboardShortcuts.ts index 73fac72..830d432 100644 --- a/src/app/hooks/useKeyboardShortcuts.ts +++ b/src/app/hooks/useKeyboardShortcuts.ts @@ -69,7 +69,8 @@ export function useKeyboardShortcuts({ // preventDefault for every matched action — critically, this is what // stops the browser from page-zooming on Cmd/Ctrl +/-/0 (the zoom - // actions) before we route the zoom to the active canvas instead. + // actions) and from firing its own "focus search" behavior on + // Cmd/Ctrl+k, before we route those to the active view instead. e.preventDefault(); switch (action.type) { diff --git a/src/app/logic/shortcuts.ts b/src/app/logic/shortcuts.ts index 19f09b1..207b136 100644 --- a/src/app/logic/shortcuts.ts +++ b/src/app/logic/shortcuts.ts @@ -11,7 +11,9 @@ * l → toggleLayout (view==='graph' only; null otherwise) * b → toggleLens (view==='dict' only; null otherwise) * / → search (any view; ordinary bare key — unlike '?' it needs no - * Shift, so it resolves in the normal switch below) + * Shift, so it resolves in the normal switch below. + * Cmd/Ctrl+k is a second, always-on route to the same + * action — see "Modifier-gated zoom + search" below.) * ? → help (any view; needs Shift, so resolved before guard 2) * * Guards checked before the switch: @@ -24,11 +26,15 @@ * Key matching is done on e.key.toLowerCase() so capslock does not block * actions (shift is already guarded, preventing Shift+G etc.). * - * Modifier-gated zoom (Cmd/Ctrl + =/+ / -/_ / 0): resolved BEFORE the bare-key - * guards, so it works regardless of the editable context and is gated on - * ctrl/meta (NOT alt/shift). These map to the browser's own zoom chord, which - * we intercept and route to the active canvas instead of the page. Bare =/-/0 - * with no modifier are NOT hijacked — they fall through to null. + * Modifier-gated zoom + search (Cmd/Ctrl + =/+ / -/_ / 0 / k): resolved + * BEFORE the bare-key guards, so these work regardless of the editable + * context and are gated on ctrl/meta (NOT alt/shift). The zoom keys map to + * the browser's own zoom chord, which we intercept and route to the active + * canvas instead of the page; Cmd/Ctrl+k maps to the conventional "focus + * search" chord and resolves to the same { type: 'search' } action as '/' — + * unlike '/', it fires even while typing elsewhere (not suppressed by the + * editable guard). Bare =/-/0/k with no modifier are NOT hijacked — they + * fall through to null (bare 'k' is simply unmapped, so it types normally). */ import type { ViewName } from '../hash-router'; @@ -75,10 +81,12 @@ export function resolveShortcut( ): ShortcutAction | null { const key = e.key.toLowerCase(); - // Modifier-gated zoom: Cmd/Ctrl + =/+ / -/_ / 0. Resolved FIRST so it bypasses - // the editable guard (these are not typed characters — they are the browser's - // own page-zoom chord, which we steal for the active canvas). Gated on - // ctrl/meta ONLY: alt or shift held with these keys → no zoom. + // Modifier-gated zoom + search: Cmd/Ctrl + =/+ / -/_ / 0 / k. Resolved FIRST + // so these bypass the editable guard (not typed characters — the zoom keys + // are the browser's own page-zoom chord, which we steal for the active + // canvas; 'k' is the conventional "focus search" chord). Gated on ctrl/meta + // ONLY: alt or shift held with these keys → no action (same slot, same guard + // for both groups). if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) { switch (key) { case '=': @@ -86,6 +94,7 @@ export function resolveShortcut( case '-': case '_': return { type: 'zoomOut' }; case '0': return { type: 'zoomReset' }; + case 'k': return { type: 'search' }; } } diff --git a/src/app/styles.css b/src/app/styles.css index 4f36430..770b87b 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1191,17 +1191,39 @@ code { align-items: center; } +/* graph-flow-search CP5 (SC12): the flow bar clears the DFD breadcrumb chip + row via its OWN measured offset var, --flow-search-bar-top — never the + banner's --search-bar-top (the global banner is always hidden on the flow + surface, so that var stays at its 12px fallback there anyway; keeping the + two vars separate avoids two independent effects racing to write/clear the + same custom property). Written by FlowChrome.tsx (owns the breadcrumb DOM) + via the same ResizeObserver-driven idiom App.tsx uses for the banner. Two + classes on one element (.viewer-search-bar.viewer-search-bar--flow) so this + wins on specificity regardless of CSS source order. */ +.viewer-search-bar.viewer-search-bar--flow { + top: var(--flow-search-bar-top, 64px); +} + .viewer-search-bar-inner { + position: relative; /* anchors .viewer-search-count (see below) */ display: flex; align-items: center; - gap: 0.5rem; - padding: 6px 10px; + gap: 0.45rem; + padding: 5px 8px; border-radius: 8px; border: 1px solid var(--color-border); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); background-color: color-mix(in srgb, var(--color-background) 75%, transparent); + transition: border-color 0.15s; +} + +/* Focus ring — same border-color-on-focus idiom as .viewer-search-input and + .dict-search-input, raised to the whole pill so focusing the input reads + as the bar itself being active, not just a 1px input edge. */ +.viewer-search-bar-inner:focus-within { + border-color: var(--color-link); } .viewer-search-input { @@ -1225,25 +1247,79 @@ code { color: var(--color-text-muted); } -.viewer-search-body-toggle { - padding: 0.35rem 0.6rem; - border-radius: 6px; +/* Body-text toggle switch — role="switch", small track + thumb, accent color + when on. Sized to match the compact chrome scale (.zoom-control-btn is + 24×24; this pairs a 30×17 track with a visible text label instead of an + icon-only control, since the label IS the point — see CP5 change log). */ +.viewer-search-switch { + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + user-select: none; +} + +.viewer-search-switch-track { + position: relative; + flex: none; + display: flex; + align-items: center; + width: 30px; + height: 17px; + padding: 2px; + border-radius: 999px; border: 1px solid var(--color-border); background: var(--color-surface); - color: var(--color-text-muted); - font-size: 0.78rem; cursor: pointer; - white-space: nowrap; + transition: background 0.15s, border-color 0.15s; } -.viewer-search-body-toggle--active { +.viewer-search-switch-track:focus-visible { + outline: none; + border-color: var(--color-link); +} + +.viewer-search-switch-track[aria-checked="true"] { background: var(--color-link); border-color: var(--color-link); - color: var(--color-background); } +.viewer-search-switch-thumb { + display: block; + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--color-text-muted); + transition: transform 0.15s, background 0.15s; +} + +.viewer-search-switch-track[aria-checked="true"] .viewer-search-switch-thumb { + background: var(--color-background); + transform: translateX(13px); +} + +.viewer-search-switch-label { + font-size: 0.78rem; + color: var(--color-text-muted); + white-space: nowrap; +} + +/* Count readout — absolutely positioned OUTSIDE the flex flow so its width + never contributes to .viewer-search-bar-inner's own size. The bar is + horizontally centered (`.viewer-search-bar`'s left:50%+translateX(-50%)), + so any width change ripples into a visible shift of the whole pill (the + input included) — this keeps the bar's width (and therefore its centered + position) constant whether or not the count is showing. Still mounted + only when matchCount !== null (SearchBar.tsx) — clearing the term removes + it from the DOM entirely, not just visually. */ .viewer-search-count { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 8px; font-size: 0.78rem; + font-variant-numeric: tabular-nums; color: var(--color-text-muted); white-space: nowrap; } diff --git a/src/flow-view/FlowChrome.tsx b/src/flow-view/FlowChrome.tsx index 7b7490d..2e8c81f 100644 --- a/src/flow-view/FlowChrome.tsx +++ b/src/flow-view/FlowChrome.tsx @@ -16,9 +16,13 @@ * * The imperative core's onDrillUp and onSelectDiagram callbacks are provided * back to it via props (the chrome owns the UI; the core owns the SVG). + * + * Also writes the --flow-search-bar-top CSS custom property (measured off the + * breadcrumb row) so App.tsx's flow search bar clears the breadcrumb chips at + * any drill depth (graph-flow-search CP5, SC12) — see the effect below. */ -import { useState, useImperativeHandle, forwardRef, useRef } from 'react'; +import { useState, useImperativeHandle, forwardRef, useRef, useLayoutEffect } from 'react'; import type { FlowDiagram } from '../flows/flow-parse'; import type { MinimapData } from './FlowDiagramSvg'; import { DARK_PALETTE, LIGHT_PALETTE } from './FlowDiagramSvg'; @@ -197,6 +201,39 @@ export const FlowChrome = forwardRef<FlowChromeHandle, FlowChromeProps>( const [minimapData, setMinimapData] = useState<MinimapData | null>(null); // Minimap pan callback: calls the registered pan handler from the core. const minimapPanRef = useRef<((worldX: number, worldY: number) => void) | null>(null); + // Breadcrumb row ref — measured below so the flow search bar (App.tsx) can + // clear it (graph-flow-search CP5, SC12). + const breadcrumbRef = useRef<HTMLDivElement>(null); + + // Search-bar collision avoidance (SC12): the breadcrumb chip row grows + // WIDER with drill depth (single line, no wrap — see the div's flex rules + // below) but its HEIGHT never changes, so tracking its measured bottom + // edge is enough to guarantee the horizontally-centered flow search bar + // never overlaps it at any depth. Mirrors App.tsx's --search-bar-top + // banner-offset idiom (ResizeObserver → CSS custom property); scoped + // locally here — via its own --flow-search-bar-top var, not the banner's + // — since FlowChrome owns the breadcrumb DOM and the banner is never + // shown on the flow surface anyway (see styles.css .viewer-search-bar--flow). + useLayoutEffect(() => { + const el = breadcrumbRef.current; + const root = document.documentElement; + if (!el) { + root.style.removeProperty('--flow-search-bar-top'); + return; + } + const GAP = 12; // matches --search-bar-top's no-banner fallback gap + function applyOffset() { + const rect = el!.getBoundingClientRect(); + root.style.setProperty('--flow-search-bar-top', `${rect.bottom + GAP}px`); + } + applyOffset(); + const ro = new ResizeObserver(applyOffset); + ro.observe(el); + return () => { + ro.disconnect(); + root.style.removeProperty('--flow-search-bar-top'); + }; + }, []); useImperativeHandle(ref, () => ({ setStack(s: BreadcrumbEntry[]) { setStack(s); }, @@ -226,15 +263,18 @@ export const FlowChrome = forwardRef<FlowChromeHandle, FlowChromeProps>( return ( <> {/* ── Breadcrumb chips — top-left ── */} - <div style={{ - position: 'absolute', - top: '18px', - left: '240px', - display: 'flex', - alignItems: 'center', - gap: '8px', - zIndex: 30, - }}> + <div + ref={breadcrumbRef} + data-ignatius="flow-breadcrumbs" + style={{ + position: 'absolute', + top: '18px', + left: '240px', + display: 'flex', + alignItems: 'center', + gap: '8px', + zIndex: 30, + }}> <span style={{ color: 'var(--color-text-muted, #8b949e)', fontSize: '13px' }}>/</span> <div style={{ display: 'inline-flex', @@ -315,7 +355,7 @@ export const FlowChrome = forwardRef<FlowChromeHandle, FlowChromeProps>( {/* ── DFD nav card — floating top-left below branding ── */} {showNav && ( - <div style={{ + <div data-ignatius="flow-nav-card" style={{ position: 'absolute', top: '72px', left: '20px', diff --git a/test/checks/test-flow-search.ts b/test/checks/test-flow-search.ts index ce2256f..be0cdcf 100644 --- a/test/checks/test-flow-search.ts +++ b/test/checks/test-flow-search.ts @@ -77,6 +77,13 @@ try { await page.waitForSelector('.viewer-search-bar--flow', { timeout: 10_000 }); await page.waitForTimeout(1000); // let the initial diagram settle + // graph-flow-search CP5 (SC5): the body-text opt-in is a labeled toggle + // switch, not the old bare "Body" pill button — role="switch", aria-checked, + // and a visible "Include descriptions" label. + const bodySwitch = page.getByRole('switch', { name: 'Include descriptions' }); + await bodySwitch.waitFor({ state: 'visible', timeout: 5000 }); + assert(await bodySwitch.getAttribute('aria-checked') === 'false', 'SC5: body switch starts unchecked (aria-checked="false")'); + // --------------------------------------------------------------------- // SC9 (baseline) — capture hash + flow-layout localStorage BEFORE any // search interaction, and re-check after typing/toggling (not after the @@ -111,9 +118,9 @@ try { // Toggle body on/off — exercised here (no title-field-vs-body ambiguity // for this term) purely to prove it doesn't disturb hash/localStorage. - await page.click('.viewer-search-body-toggle'); + await bodySwitch.click(); await page.waitForTimeout(200); - await page.click('.viewer-search-body-toggle'); + await bodySwitch.click(); await page.waitForTimeout(200); const hashAfterSearch = await page.evaluate(() => location.hash); @@ -186,7 +193,8 @@ try { const visaOffRowCount = await page.locator('.viewer-search-result-row').count(); assert(visaOffRowCount === 0, 'SC5: body-only term does not match with the body toggle off', `got ${visaOffRowCount}`); - await page.click('.viewer-search-body-toggle'); // on + await bodySwitch.click(); // on + assert(await bodySwitch.getAttribute('aria-checked') === 'true', 'SC5: body switch reports aria-checked="true" once on'); await page.waitForSelector('.viewer-search-result-row[data-token="proc:Collect-Payment"]', { timeout: 5000 }); const visaOnRow = await page.evaluate(() => { const el = document.querySelector('.viewer-search-result-row[data-token="proc:Collect-Payment"]'); @@ -206,7 +214,7 @@ try { ); assert(true, 'SC6: Enter opens the first dropdown row and navigates there'); - await page.click('.viewer-search-body-toggle'); // back off + await bodySwitch.click(); // back off // --------------------------------------------------------------------- // SC6 — display cap: "+N more" overflow line, verified against the @@ -247,6 +255,80 @@ try { handle.stop(); } +// --------------------------------------------------------------------------- +// SC12 (CP5) — flow-chrome non-collision: the search bar never overlaps the +// DFD breadcrumb chip row, at any breadcrumb depth. Mirrors the +// banner-collision idiom above. test/fixtures/flows-leveling drills 3 levels +// deep (auth → Authenticate → Login), rendering the full breadcrumb chain +// ("Process Flows" root chip + auth + Authenticate + Login = 4 chips) — the +// reported collision scenario. Deep-links straight to the deepest diagram via +// the #dfd= hash param (the same path selectDiagramById/findDiagramPath use +// for a sub-DFD id) rather than clicking through two drill-downs. +// --------------------------------------------------------------------------- + +const LEVELING_MODEL = join(ROOT, 'test/fixtures/flows-leveling'); +const LEVELING_PORT = 3299; +const levelingHandle = serveCommand(LEVELING_MODEL, { port: LEVELING_PORT }); +await new Promise<void>(r => setTimeout(r, 400)); + +const levelingBrowser = await chromium.launch(); +const levelingPage = await levelingBrowser.newPage({ viewport: { width: 1440, height: 900 } }); + +try { + await levelingPage.goto(`http://localhost:${LEVELING_PORT}/#view=flow&dfd=Login`, { waitUntil: 'load' }); + await levelingPage.waitForFunction(() => !!(window as { __IGNATIUS_FLOW_READY__?: unknown }).__IGNATIUS_FLOW_READY__, { timeout: 20_000 }); + await levelingPage.waitForSelector('.viewer-search-bar--flow', { timeout: 10_000 }); + await levelingPage.waitForFunction( + () => (window as { __IGNATIUS_ACTIVE_FLOW_DFD__?: string }).__IGNATIUS_ACTIVE_FLOW_DFD__ === 'Login', + { timeout: 10_000 }, + ); + await levelingPage.waitForTimeout(500); // let the breadcrumb-offset ResizeObserver settle + + const barBox = await levelingPage.locator('.viewer-search-bar--flow').boundingBox(); + const crumbBox = await levelingPage.locator('[data-ignatius="flow-breadcrumbs"]').boundingBox(); + assert(barBox !== null, 'SC12: search bar has a bounding box'); + assert(crumbBox !== null, 'SC12: breadcrumb chip row has a bounding box'); + + if (barBox && crumbBox) { + const intersects = + barBox.x < crumbBox.x + crumbBox.width && + barBox.x + barBox.width > crumbBox.x && + barBox.y < crumbBox.y + crumbBox.height && + barBox.y + barBox.height > crumbBox.y; + assert( + !intersects, + 'SC12: search bar bounding box does not intersect the breadcrumb chip row at full drill depth', + `bar=${JSON.stringify(barBox)} crumbs=${JSON.stringify(crumbBox)}`, + ); + assert( + barBox.y >= crumbBox.y + crumbBox.height, + 'SC12: search bar sits fully below the breadcrumb chip row', + `bar.y=${barBox.y} crumbs.bottom=${crumbBox.y + crumbBox.height}`, + ); + } + + // Playwright's click() actionability check fails if the target point is + // covered by another element — a successful click + type proves the bar is + // visible AND clickable, not just geometrically clear (mirrors the + // banner-collision idiom's clickability proof). + await levelingPage.locator('.viewer-search-input').click(); + await levelingPage.keyboard.type('Login'); + await levelingPage.waitForTimeout(400); + const typedValue = await levelingPage.locator('.viewer-search-input').inputValue(); + assert( + typedValue === 'Login', + 'SC12: search input is visible and clickable at full breadcrumb depth', + `got "${typedValue}"`, + ); +} catch (err) { + console.error('FAIL:', err instanceof Error ? err.message : String(err)); + failures++; +} finally { + await levelingPage.close(); + await levelingBrowser.close(); + levelingHandle.stop(); +} + if (failures > 0) { console.error(`\n${failures} failure(s).`); process.exit(1); diff --git a/test/checks/test-graph-search.ts b/test/checks/test-graph-search.ts index 2aa1bf8..71d3a81 100644 --- a/test/checks/test-graph-search.ts +++ b/test/checks/test-graph-search.ts @@ -50,6 +50,13 @@ try { await page.waitForSelector('.viewer-search-bar--graph', { timeout: 10_000 }); await page.waitForTimeout(1500); // let the initial layout settle + // graph-flow-search CP5 (SC5): the body-text opt-in is a labeled toggle + // switch, not the old bare "Body" pill button — role="switch", aria-checked, + // and a visible "Include descriptions" label. + const bodySwitch = page.getByRole('switch', { name: 'Include descriptions' }); + await bodySwitch.waitFor({ state: 'visible', timeout: 5000 }); + assert(await bodySwitch.getAttribute('aria-checked') === 'false', 'SC5: body switch starts unchecked (aria-checked="false")'); + const totalEntities = await page.evaluate(() => window.__IGNATIUS_CY__!.nodes('[classification]').length); assert(totalEntities > 0, 'model has entity nodes to search', `got ${totalEntities}`); @@ -108,9 +115,9 @@ try { // --------------------------------------------------------------------- // SC9 — hash/localStorage untouched by typing + toggling (no Enter yet). // --------------------------------------------------------------------- - await page.click('.viewer-search-body-toggle'); // on + await bodySwitch.click(); // on await page.waitForTimeout(200); - await page.click('.viewer-search-body-toggle'); // back off + await bodySwitch.click(); // back off await page.waitForTimeout(200); const hashAfterSearch = await page.evaluate(() => location.hash); @@ -201,8 +208,9 @@ try { window.__IGNATIUS_CY__!.nodes('[classification]').filter(n => n.hasClass('search-match')).length); assert(bodyOffMatches === 0, 'SC5: body-only term does not match with the body toggle off', `got ${bodyOffMatches}`); - await page.click('.viewer-search-body-toggle'); // on + await bodySwitch.click(); // on await page.waitForTimeout(200); + assert(await bodySwitch.getAttribute('aria-checked') === 'true', 'SC5: body switch reports aria-checked="true" once on'); const bodyOn = await page.evaluate(() => { const cy = window.__IGNATIUS_CY__!; diff --git a/test/checks/test-shortcuts.ts b/test/checks/test-shortcuts.ts index 2889740..3f06dff 100644 --- a/test/checks/test-shortcuts.ts +++ b/test/checks/test-shortcuts.ts @@ -343,4 +343,72 @@ for (const key of ['g', 'd', 'f', 'l', 'b']) { console.log("PASS T19: '/' editable=true → null"); } +// --------------------------------------------------------------------------- +// T20: Cmd/Ctrl + K → search, on every view — same modifier-gated slot as the +// zoom chords (resolved before the bare-key guards). +// --------------------------------------------------------------------------- +{ + for (const mod of ['ctrlKey', 'metaKey'] as const) { + for (const view of VIEWS) { + const result = resolveShortcut(ev('k', { [mod]: true }), view, false); + assert(result !== null && result.type === 'search', `T20: ${mod}+'k' on ${view} → { type:'search' }`); + console.log(`PASS T20: ${mod}+'k' on view=${view} → { type:'search' }`); + } + } +} + +// --------------------------------------------------------------------------- +// T21: Cmd/Ctrl + K resolves EVEN when editable === true (mirrors T12 for +// zoom — Cmd/Ctrl+K focuses search while typing anywhere, unlike bare '/'). +// --------------------------------------------------------------------------- +{ + for (const mod of ['ctrlKey', 'metaKey'] as const) { + const result = resolveShortcut(ev('k', { [mod]: true }), 'graph', true); + assert( + result !== null && result.type === 'search', + `T21: ${mod}+'k' with editable=true still → { type:'search' }`, + ); + console.log(`PASS T21: ${mod}+'k' editable=true → { type:'search' }`); + } +} + +// --------------------------------------------------------------------------- +// T22: alt/shift + K (and combos) → null (mirrors T13 — ONLY ctrl/meta +// trigger the chord). +// --------------------------------------------------------------------------- +{ + const alt = resolveShortcut(ev('k', { altKey: true }), 'graph', false); + assert(alt === null, "T22: alt+'k' → null"); + const shift = resolveShortcut(ev('k', { shiftKey: true }), 'graph', false); + assert(shift === null, "T22: shift+'k' → null"); + const ctrlAlt = resolveShortcut(ev('k', { ctrlKey: true, altKey: true }), 'graph', false); + assert(ctrlAlt === null, "T22: ctrl+alt+'k' → null"); + const metaShift = resolveShortcut(ev('k', { metaKey: true, shiftKey: true }), 'graph', false); + assert(metaShift === null, "T22: meta+shift+'k' → null"); + console.log("PASS T22: alt/shift (and combos) on 'k' → null"); +} + +// --------------------------------------------------------------------------- +// T23: bare 'k' (no modifier) → null — unmapped, not hijacked; a plain 'k' +// keystroke types normally. +// --------------------------------------------------------------------------- +{ + for (const view of VIEWS) { + const result = resolveShortcut(ev('k'), view, false); + assert(result === null, `T23: bare 'k' on ${view} → null`); + } + console.log("PASS T23: bare 'k' (no modifier) → null on all views"); +} + +// --------------------------------------------------------------------------- +// T24: Cmd/Ctrl+K action shape carries only { type } (exact discriminated +// shape, mirrors T15 for the zoom actions). +// --------------------------------------------------------------------------- +{ + const result = resolveShortcut(ev('k', { metaKey: true }), 'graph', false); + assert(result !== null && result.type === 'search', 'T24: search action shape'); + assert(Object.keys(result).length === 1, "T24: search action has only 'type'"); + console.log("PASS T24: Cmd+'k' action shape { type:'search' }"); +} + console.log('\nAll tests passed.'); diff --git a/test/visual/test-flow-search.ts b/test/visual/test-flow-search.ts index b75227b..f997a7d 100644 --- a/test/visual/test-flow-search.ts +++ b/test/visual/test-flow-search.ts @@ -1,10 +1,12 @@ /** - * Visual verification: Flow search (graph-flow-search CP3). + * Visual verification: Flow search (graph-flow-search CP3/CP5). * * Types "Validate" into the Flows search bar and screenshots the result: the * results dropdown open (Validate Customer, grouped under Create Sales Order) * while the currently-rendered diagram's own nodes — none of which match — * render dimmed (searchTokens folded into FlowDiagramSvg's opacity rules). + * Captures BOTH theme modes (CP5 visual-tightening pass) so the polish can be + * reviewed against dark AND light chrome. * * Run: bun test/visual/test-flow-search.ts * @@ -74,13 +76,23 @@ await page.waitForSelector('.viewer-search-result-row[data-token="proc:Validate- await page.waitForTimeout(300); // let the opacity transition settle await shot('02-flow-search-active.png'); -note(' Captured active search: dropdown open, current diagram nodes dimmed.'); +note(' Captured active search (dark): dropdown open, current diagram nodes dimmed.'); + +note('Toggling to light theme…'); +await page.locator('button[title="Switch to light mode"], button[title="Switch to dark mode"]').first().click(); +await page.waitForTimeout(400); // re-theme settle +await page.locator('.viewer-search-input').focus(); // clicking the toggle stole focus — restore it so the focus ring shows +await page.waitForTimeout(200); + +await shot('03-flow-search-active-light.png'); +note(' Captured active search (light): same state, light theme chrome.'); await browser.close(); serverHandle.stop(); note('\nVisual check complete.'); -note('Review 02-flow-search-active.png — it should show:'); +note('Review 02-flow-search-active.png (dark) and 03-flow-search-active-light.png (light) — each should show:'); note(' - The results dropdown open, listing "Validate Customer" (1.1.1) under "Create Sales Order"'); note(' - Every node in the currently-rendered diagram dimmed to ~0.3 opacity (none match "Validate")'); +note(' - The "Include descriptions" switch in the bar'); process.exit(0); diff --git a/test/visual/test-graph-search.ts b/test/visual/test-graph-search.ts index 16eb988..a65c81f 100644 --- a/test/visual/test-graph-search.ts +++ b/test/visual/test-graph-search.ts @@ -1,9 +1,11 @@ /** - * Visual verification: Graph search (graph-flow-search CP2). + * Visual verification: Graph search (graph-flow-search CP2/CP5). * * Types "Party" into the Graph search bar and screenshots the result: * Party + PartyType highlighted (search-match), every other entity dimmed - * (search-dim), and the "n of N" count readout visible. + * (search-dim), and the "n of N" count readout visible. Captures BOTH theme + * modes (CP5 visual-tightening pass: switch styling, count/focus treatment) + * so the polish can be reviewed against dark AND light chrome. * * Run: bun test/visual/test-graph-search.ts * @@ -75,14 +77,23 @@ const readout = await page.locator('.viewer-search-count').textContent(); note(` Count readout: "${readout}"`); await shot('02-graph-search-active.png'); -note(' Captured active search: Party/PartyType highlighted, rest dimmed.'); +note(' Captured active search (dark): Party/PartyType highlighted, rest dimmed.'); + +note('Toggling to light theme…'); +await page.locator('button[title="Switch to light mode"], button[title="Switch to dark mode"]').first().click(); +await page.waitForTimeout(400); // re-theme settle +await page.locator('.viewer-search-input').focus(); // clicking the toggle stole focus — restore it so the focus ring shows +await page.waitForTimeout(200); + +await shot('03-graph-search-active-light.png'); +note(' Captured active search (light): same state, light theme chrome.'); await browser.close(); serverHandle.stop(); note('\nVisual check complete.'); -note('Review 02-graph-search-active.png — it should show:'); +note('Review 02-graph-search-active.png (dark) and 03-graph-search-active-light.png (light) — each should show:'); note(' - Party and PartyType with a gold border (search-match)'); note(' - Every other entity + non-connecting edges dimmed to ~0.2 opacity'); -note(' - The "n of N" count readout in the search bar'); +note(' - The "n of N" count readout in the search bar, and the "Include descriptions" switch'); process.exit(0); From 28104a223f4aafd1488d1d49a24dac4e6edc7aa6 Mon Sep 17 00:00:00 2001 From: Danilo Alonso <damusix@gmail.com> Date: Tue, 14 Jul 2026 15:50:54 -0400 Subject: [PATCH 12/13] docs(spec): implementation log through CP5 --- docs/spec/graph-flow-search.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index ae2262a..73bc92c 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -170,7 +170,7 @@ M CLAUDE.md — feature-map row (and '/' added to t ### shipped — 2026-07-14 -Built across 4 iterations of the /autopilot subagent loop. Commits (chronological): +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) @@ -179,6 +179,9 @@ Built across 4 iterations of the /autopilot subagent loop. Commits (chronologica - `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:** @@ -189,6 +192,7 @@ Built across 4 iterations of the /autopilot subagent loop. Commits (chronologica - 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:** From 4b69cdf5077aa6ccb44c0d19ee08ec499c7135ed Mon Sep 17 00:00:00 2001 From: Danilo Alonso <damusix@gmail.com> Date: Tue, 14 Jul 2026 16:02:20 -0400 Subject: [PATCH 13/13] chore(signals): refresh after graph-flow-search CP5 --- docs/wiki/index.md | 32 ++++++++++++++++---------------- docs/wiki/scan.md | 36 ++++++++++++++++++------------------ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 5d645d7..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 --- <wiki-type>repo</wiki-type> -<scan-sha>56e97e5f687b6efbbd3a66008d18a631c6ad6f94</scan-sha> +<scan-sha>08b8eeaf632f7cc3e9d655f1b1b1b0369d94bb8c</scan-sha> <wiki-schema>1</wiki-schema> # 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) — 86 raw assertion scripts (PASS/FAIL/throw). Run by `bun run test` and CI. Includes `test-validate-entity.ts` and `test-validate-refs.ts` which pin `key-inherited` as the clean baseline and `broken-demo` as the broken fixture. `test-validate-refs.ts` expects 4 global + 8 entity = 12 total findings (1 additional `body.unknown_link` from `broken-demo/Order.md`'s `[[Cart]]` link). `test-folder-model.ts` (4 assertions) verifies the `data/`+`groups/` folder model contract: entity under `data/` IS parsed; entity outside `data/` is NOT parsed; model with no `groups/` parses with zero groups and no throw; entity with a group defined in `groups/<slug>.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (346L) — 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). `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 `<g>`. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `<title>` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP3, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (310L) — 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). Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (255L) — 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. 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` (88L) — 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. `test-flow-search.ts` (86L) — 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`. +- [`test/checks/`](../../test/checks) — 86 raw assertion scripts (PASS/FAIL/throw). Run by `bun run test` and CI. Includes `test-validate-entity.ts` and `test-validate-refs.ts` which pin `key-inherited` as the clean baseline and `broken-demo` as the broken fixture. `test-validate-refs.ts` expects 4 global + 8 entity = 12 total findings (1 additional `body.unknown_link` from `broken-demo/Order.md`'s `[[Cart]]` link). `test-folder-model.ts` (4 assertions) verifies the `data/`+`groups/` folder model contract: entity under `data/` IS parsed; entity outside `data/` is NOT parsed; model with no `groups/` parses with zero groups and no throw; entity with a group defined in `groups/<slug>.md` resolves the group correctly. `test-api-model.ts` asserts `layoutKey` field is present in `/api/model` response. `test-layout-fingerprint.ts` (255L) and `test-layout-store.ts` (157L) pin the fingerprint / localStorage helper. `test-layout-key-injection.ts` (132L) asserts `window.__LAYOUT_KEY__` in static graph HTML. `test-wikilink.ts` covers the `[[…]]` inline rule. `test-validate-body-links.ts` covers `body.unknown_link` emission. `test-open-browser.ts` covers `browserOpenCommand` argv mapping. `test-titlelize.ts` (83L) covers `titlelize()`. `test-entity-usage-index.ts` (190L) covers `buildEntityUsageIndex()`. `test-cp5-title-override.ts` (106L) covers `title:` frontmatter override on flow externals/stores. `test-cp15-flow-kind-palette.ts` (105L) covers `resolveFlowKindPalette` defaults + YAML overrides. `test-cp16-process-examples.ts` (186L) covers `parseProcessExamples` and `FlowProcess.examples` parse round-trip. `test-cp21-flow-node-usage-index.ts` (234L) covers `buildFlowNodeUsageIndex` token-keyed map (ext:, file:, db: endpoint dedup + direction). `test-model-index.ts` (409L) covers `buildModelIndex` — all 13 maps, empty model, multi-cluster members, fkColumnsByNode derivation. `test-synthetic-model.ts` (90L) asserts `gen-synthetic-model.ts` output parses cleanly via `parseModels` with no global errors. `test-spotlight-connections.ts` covers `buildSpotlightConnections` — self-edge exclusion, bundle merging, out-before-in ordering, both-direction merge, sort by otherId. `test-flow-spotlight-connections.ts` covers `buildFlowSpotlightConnections` — recursive sub-DFD walk, db: → bare entity id cross-domain resolution, array data join, self-edge exclusion, sort by otherCardId (T1–T15). **DFD overhaul checks (CP4a–4e):** `test-cp4a-layout-model-d.ts` (234L) pins the role-split node set (ext→at most 2 copies, store→read/write copies, process→single) and band ordering on the proving model. `test-cp4b-elk-edge-routing.ts` (183L) verifies ELK returns routed `edgeRoutes` with at least startPoint + endPoint per edge. `test-cp4c-single-row-bands.ts` (142L) asserts each of the 5 bands is a single distinct y on dense diagrams. `test-cp4d-frame-alignment.ts` (198L) asserts every `edgeRoutes` endpoint lies within ε of its endpoint node's center-based bounding box. `test-cp4e-elk-renders-in-browser.ts` (105L) browser integration test — no "ELK layout failed" console warning + edge paths match ELK routes. `test-cp4e-terminate-guard.ts` (48L) unit guard on `terminateQuietly` — swallows a throwing `terminateWorker`, still calls it once. `test-elk-flow-positions.ts` (162L) covers `computeElkLayout` — band ordering + position population. `test-leveling.ts` (216L) covers `deriveLevels` — C6 context, C7 L1 store degrees, C12 dotted numbering. `test-flow-leveling.ts` (468L) comprehensive leveling assertions on the proving model. `test-shortcuts.ts` (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 `<g>`. `test-deep-nesting.ts` (177L) — 7-assertion unit test for arbitrary DFD nesting depth: parses [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) via `parseFlows` (which runs `deriveLevels`) and asserts `Authenticate=1.1`, `Login=1.1.1`, `VerifyToken=1.1.1.1`, `CreateSession=1.1.1.2`; no collision between Authenticate and Login; deepest processes have 4 dotted segments; Login inside Authenticate sub-DFD carries `1.1.1`. **Viewer-ux-polish checks:** `test-app-title.ts` (144L) asserts `generateApp` sets the HTML `<title>` from the model display name. `test-modal-history.ts` (187L) — Playwright check: opening an entity modal pushes a history entry (`entity=` in hash), closing clears it, browser Back restores the previous view. `test-zoom-scale.ts` (125L) — unit test for `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale` in [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts): degenerate-dimension guard (returns 1), round-trip identity, 100% maps to native 1:1. `test-zoom-input.ts` (323L) — Playwright check: zoom readout is native-1:1 (at fit, readout is proportional to model size not fixed 100%); Cmd+0 resets, Cmd+-/+ step in/out. `test-process-node-size.ts` (113L) — unit test for `processNodeSize` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts): verifies node height grows with wrapped label line count and width caps at a maximum. `test-spotlight-lines.ts` (207L) — unit test for `separateSpotlightLines` in [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts): K=0 → [], K=1 → no offset, K=2 → symmetric offset on the perpendicular axis, horizontal vs vertical anchor. `test-spotlight-inherited.ts` (388L) — unit test for `buildInheritedConnections` in [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts): key-edge connected-component model; subtype-cluster members reachable, identifying-1:many (FK proper subset) reachable, transitive BFS closure both directions, PartyType absent from SSN lineage (non-key secondary FK excluded), ORM surrogate-PK models → zero lineage, sort by otherId; **T7 asserts `direction === 'out'`** (not `'both'`) on every returned connection. `test-inherited-edges-no-leak.ts` (120L) — asserts ephemeral inherited cy edges are removed on deselect, relayout, and view-switch; DG counts Identity=13/ITIN=17 (PartyType absent). `test-graph-inherited-edges.ts` (240L) — Playwright browser check with **SHIFT+HOVER trigger**: (1) plain click → 0 `edge.inherited` edges (modal opens, no rays); (2) shift+mouseover Identity → dotted `edge.inherited` rays appear; (3) mouseout (shift still held) → 0 inherited edges; (4) shift+hover ITIN → transitive set (more edges than Identity); (5) plain mouseover (no shift) → 0 inherited edges; (6) background tap → 0. Shift state injected synthetically via `evt.originalEvent: { shiftKey: true/false }` matching the `GraphView` handler. [`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts) uses shift+hover (not click) for screenshot capture. `test-navigator-teardown.ts` (76L) — unit test pinning `teardownNavigator` call-order contract: `_removeCyListeners` called before `destroy` to prevent cy `'resize'`/onRender listener leak on a destroyed core. **graph-flow-search checks (CP1–CP5, [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md)):** `test-viewer-search.ts` (324L) — unit tests (T1–T12) for the Graph/Flows title-first search matchers and the recursive cross-diagram flow walker: `entityMatches`/`flowProcessMatches`/`flowExternalMatches`/`flowStoreMatches`/`flowDiagramMatches` title-field + body opt-in behaviour, `searchFlowDiagrams` recursive sub-DFD coverage, synthetic-diagram exclusion (still walks through to leaves), `proc:`/`ext:`/`<kind>:` token construction, per-diagram grouping/ordering, and dottedNumber presence only on process results. `test-graph-search.ts` (318L) — CI-runnable Playwright check (skip-if-dist-absent) for the Graph search bar: dim/highlight (`search-match`/`search-dim`) classes, `n of N` count readout, Enter-to-cycle, body-toggle opt-in, survival across hover/layout-mode changes, and the no-persistence guarantee (search state never touches layout-store/URL hash/model); CP5 adds SC5 assertions against the `role="switch"` "Include descriptions" control (`page.getByRole('switch', ...)`, `aria-checked` false→true) in place of the old `.viewer-search-body-toggle` button-click. Serves [`models/key-inherited`](../../models/key-inherited) (Party/PartyType id match; Person's body-only "honest" fixture). `test-flow-search.ts` (337L) — CI-runnable Playwright check for the Flows search bar: cross-diagram results dropdown, sub-DFD navigation via `selectDiagramById`, in-diagram dim/highlight via `searchTokens`, body-toggle opt-in, the results display cap's "+N more" overflow line, and no-persistence; CP5 adds an SC12 block against the [`test/fixtures/flows-leveling`](../../test/fixtures/flows-leveling) fixture deep-linked to `#view=flow&dfd=Login` — asserts the `.viewer-search-bar--flow` bounding box never intersects `[data-ignatius="flow-breadcrumbs"]` and sits fully below it at full (4-chip) drill depth, plus a click+type clickability proof. Serves [`models/key-inherited`](../../models/key-inherited)'s `order-to-cash` sub-DFD tree; expected match sets are computed live via `parseFlows` + `searchFlowDiagrams` rather than hardcoded. +- [`test/visual/`](../../test/visual) — 64 Playwright screenshot scripts for manual visual inspection. NOT run by `bun run test`. Includes CP1–CP26 visual test scripts. `test-cp2-dfd-edge-labels.ts` covers ELK-routed edge labels (channel-placed, length-gated, truncated `…` preview for long labels, C5/C13 assertions updated for truncated-preview behaviour). `test-dfd-edge-hover.ts` (168L) — visual screenshot capturing the styled edge hover tooltip over a dense diagram. `test-dd-spotlight-grid.ts` (5090L) — covers the DD browse lens spotlight grid; CP7 section reworked to three Shift states (CP7.a NO Shift → 0 inherited, FK solid lines render; CP7.b hold Shift → inherited dotted lines appear, FK persists; CP7.c release Shift → inherited gone, FK persists) plus CP7-TRANSITIVE Identity block (0 → >0 → 0 under Shift). `test-dd-sticky-search.ts` covers the fixed frosted search bar in the browse and read lenses. `test-deep-nesting.ts` (142L) — serves [`test/fixtures/flows-leveling/`](../../test/fixtures/flows-leveling) and asserts the DD process list shows full-depth numbers `1.1.1.1` / `1.1.1.2`. `test-graph-inherited-lines.ts` — visual screenshot of dotted inherited identity-group edges drawn via shift+hover in GraphView; reads per-tier opacity and asserts direct > inherited > unrelated (direct=1.0, inherited=0.5, unrelated=0.2). **graph-flow-search visuals:** `test-graph-search.ts` (99L) — types "Party" into the Graph search bar; screenshots Party + PartyType highlighted (`search-match`), every other entity dimmed (`search-dim`), and the `n of N` count readout; CP5 adds a second capture after toggling to light theme, so both dark and light chrome (including the "Include descriptions" switch) get reviewed. `test-flow-search.ts` (98L) — types "Validate" into the Flows search bar; screenshots the results dropdown open (grouped under its parent diagram) while the currently-rendered diagram's own non-matching nodes render dimmed via `searchTokens`; CP5 adds a matching light-theme second capture. - [`test/fixtures/`](../../test/fixtures) — YAML fixtures and fixture model roots. All fixture model roots use the v0.11.0 folder layout (`data/`, `groups/`, `externals/`, `stores/`; no `_*` prefixes): `flows-leveling/` (servable, 4-level-deep DFD tree), `flows-model/`, `broken-flows-model/`, `broken-flow/`, `flows/`. Per-DFD `_externals`/`_stores` were collapsed to root registries (same-name collisions in `broken-flow` Shopper and `flows-leveling` User resolved to one global definition each). - [`test/notes/`](../../test/notes) — 2 markdown dev notes. @@ -48,9 +48,9 @@ No linter or formatter configured in package.json. | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 58921 | 265 | 70% | -| Markdown | 20404 | 298 | 24% | -| CSS | 2928 | 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% | @@ -228,7 +228,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - `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<string> | null` prop (default `null`) — base tokens that stay at full opacity when no hover is active; `null` means no active search (no dimming from this source). `nodeOpacity`/`edgeOpacity` check `hover`-driven focus first (unchanged precedence), then fall back to `searchTokens` — a pointer hover always wins over search dimming while it's active. Non-matching nodes/edges dim to `DIM_OPACITY` (0.3), the same constant hover-focus dimming already used. An edge counts as matching when either `baseToken(edge.source)` or `baseToken(edge.target)` is in `searchTokens`. `baseToken(id: string): string` — module-private; strips the role-split layout suffixes (`--src`/`--snk`/`--read`/`--write`, via `id.replace(/--(src|snk|read|write)$/, '')`) so a node/edge-endpoint id compares equal to the base token that [`src/app/logic/search.ts`](../../src/app/logic/search.ts)'s `searchFlowDiagrams` produces. -[`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (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: @@ -251,7 +251,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (158L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types. - [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) — exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. Seeds from `location.hash` on mount. Accepts an `onRestoreDfd` callback for DFD deep-link restore. **Entity modal history (#6/#8):** `entity=` in the hash is the single source of truth for the modal stack. Opening an entity calls `openEntity(id)` (→ `history.pushState`; deduped when the hash already carries this same entity — an FK hop back to the same entity or a re-open does not stack a duplicate history entry); closing calls `closeEntity()` (→ `history.replaceState`). `popstate` invokes the `onEntityChange(id | null)` option to reconcile the shell — the shell opens/closes the modal in response without pushing another history entry. GraphView no longer writes `entity=`. Returns `{ view, setView, openEntity, closeEntity }`. - [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) (36L) — exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage. Calls `applyThemeCssVars` on change (also re-fires on `model` identity change, so an SSE-delivered model with the same `themeConfig` identity still reapplies CSS vars). Returns `{ themeMode, toggleTheme }`. - - [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (92L) — 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`, `onSearch` (**graph-flow-search**: fires on [`/`](../..) — focuses the active view's search input). Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported by [`src/app/App.tsx`](../../src/app/App.tsx). + - [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (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. @@ -262,7 +262,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/logic/relationship-key.ts`](../../src/app/logic/relationship-key.ts) (21L) — exports `relationshipRowKey(edge: ModelEdge): string`. Stable, collision-free React key for relationship rows; encodes source, target, and sorted `on` FK pairs to handle dual-FK tables (e.g. `TrackingStatus_Allowed → TrackingStatus` via `from_status` and `to_status`). - [`src/app/logic/spotlight.ts`](../../src/app/logic/spotlight.ts) — pure `buildSpotlightConnections(index: ModelIndex, entityId: string): SpotlightConnection[]`. Exports `SpotlightConnection` (`{ otherId, direction: 'out'|'in'|'both', edges: SpotlightEdge[] }`) and `SpotlightEdge` (`{ direction, predicate, cardinality, identifying }`). Uses `edgesBySource` + `edgesByTarget` only (no `model.edges` scans). Invariants: self-edges excluded; all edges to the same otherId bundled into one connection (out-before-in); direction='both' when bundle contains edges from both sets; result sorted by otherId ascending; unknown entityId → []. - [`src/app/logic/flow-spotlight.ts`](../../src/app/logic/flow-spotlight.ts) — pure `buildFlowSpotlightConnections(diagrams: FlowDiagram[], activeToken: string): FlowSpotlightConnection[]`. Exports `FlowSpotlightConnection` (`{ otherCardId, direction: 'out'|'in'|'both', edges: FlowSpotlightEdge[] }`) and `FlowSpotlightEdge` (`{ direction, data: string }`). Token scheme: `activeToken` is always `"<kind>:<name>"`; entity cards pass `"db:<entityId>"`. otherCardId resolution: `db:<name>` → bare entity id; anything else → raw `"<kind>:<name>"`. Walks all diagrams + sub-DFDs recursively. Array data joined with `", "`. Result sorted by otherCardId ascending. - - [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) (115L) — 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). 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 ([`/`](../..) resolves here). 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 `<path>` has a single arrowhead. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx). - [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) (220L) — pure inherited-connection logic for the DD browse lens and DG graph (#9, key-inheritance-lineage). No DOM, no React, no Bun/Node imports. Exports `buildInheritedConnections(index: ModelIndex, entityId: string): InheritedConnection[]`, `InheritedConnection = { otherId, direction: 'out'|'in'|'both', via: string }`, `INHERITED_IDENTITY = 'identity'`. **`direction` is always `'out'`** — the lineage line points FROM the active card OUTWARD to the lineage member; the union retains `'in'`/`'both'` for shape-compatibility with `SpotlightConnection` but no returned entry ever carries those values. `SpotlightOverlay` renders an `'out'` connection as ONE line with a single arrowhead at the far (member) end. **Key-edge connected-component model:** lineage follows ONLY key edges — an edge where `Object.keys(edge.on)` (FK columns on the child/source side) are ALL ⊆ `pkByNode.get(edge.source)` (child's PK), a non-empty subset test implementing IDEF1X identifying semantics; empirically equivalent to `edge.identifying === true` on key-inherited models. Catches identifying-1:many (FK a proper subset of PK, e.g. `SalesInvoice→Party`) in addition to identifying-1:1 (FK = full PK). Never follows a secondary FK. Identity group = transitive connected component over key edges in BOTH directions, cycle-safe via visited Set; subtype-cluster membership falls out naturally (member→basetype is a key edge). For every group member M ≠ active: emit M as identity link (`via = INHERITED_IDENTITY`) and emit M's direct FK connections with otherId outside the group (via = M id), both de-duped against the active's own direct connections. Does NOT call `buildSpotlightConnections` for de-dup (removed). Bundles to one entry per otherId (first-seen wins); result sorted ascending by otherId; active in no group → []. Imported by [`src/app/components/entity/SpotlightOverlay.tsx`](../../src/app/components/entity/SpotlightOverlay.tsx) and [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx). @@ -274,8 +274,8 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde - [`src/app/components/ui/Modal.tsx`](../../src/app/components/ui/Modal.tsx) — shared modal primitive (title + onClose + children). - [`src/app/components/ui/ZoomControl.tsx`](../../src/app/components/ui/ZoomControl.tsx) (66L) — view-agnostic `ZoomControl` component. Props: `percent`, `onZoomIn`, `onZoomOut`, `onSetPercent(pct)`, `onReset`. Clicking the readout opens an inline text input (commit on Enter/blur, cancel on Escape). CSS class `.zoom-control`. - [`src/app/components/ui/FabMenu.tsx`](../../src/app/components/ui/FabMenu.tsx) — per-view FAB menus. Items are view-gated (graph/dict/flow-specific items not rendered for other views). `kbd-hint` badges (G/D/F/L) appear on the view-switch and layout-toggle items to surface keyboard shortcuts inline. - - [`src/app/components/ui/HelpModal.tsx`](../../src/app/components/ui/HelpModal.tsx) (137L) — view-aware orientation overlay. Answers "what am I looking at?" for the active view (graph/dict/flow). Built on the shared `Modal` primitive. Exports `HelpModal({ view, onClose })`. Three view branches: graph (entity types, explore tips, two modeling styles), dict (lens descriptions, explore tips), flow (DFD symbols, explore tips). Each branch's "How to explore" section documents the surface's search behavior (**graph-flow-search**: graph — "Type to highlight matches and dim the rest; toggle Body 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 — results list by diagram; click one to navigate there. Non-matches dim in the diagram"). Each branch closes with a view-specific `Keyboard` shortcuts section from `shortcutRows(view)`, which always includes a [`/`](../..) → "Focus the search bar" row (in addition to `G · D · F` and `?`). Distinct from `LegendModal` (symbol reference); this is the conceptual orientation map. - - [`src/app/components/ui/SearchBar.tsx`](../../src/app/components/ui/SearchBar.tsx) (140L) — exports `SearchBar` (`forwardRef<SearchBarHandle, SearchBarProps>`) and `SearchBarHandle` (`{ focus(): void }`). Shared search-bar chrome for the Graph and Flows surfaces (graph-flow-search CP2/CP3) — renders `.viewer-search-bar` with a debounced (200ms), echo-guarded `<input type="search">` (the same debounce/echo pattern `DictionaryView`'s own search box uses), a `.viewer-search-body-toggle` "Body" button (`includeBody`), a `matchCount`/`totalCount` readout ("n of N", hidden when `matchCount === null`), and a `children` slot for a results dropdown. Enter flushes any pending debounce then calls `onEnter` (the caller decides what "next" means — Graph cycles matches, Flows opens the first result row); Escape clears the term and blurs. Props: `term`, `onTermChange`, `includeBody`, `onIncludeBodyChange`, `matchCount`, `totalCount`, `onEnter`, `placeholder`, `ariaLabel`, `className?`, `children?`. `App.tsx` mounts two instances tagged `className="viewer-search-bar--graph"` / `"viewer-search-bar--flow"` as CSS hooks; neither currently has a distinct rule in `styles.css` — both render through the shared `.viewer-search-bar` styling. + - [`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<SearchBarHandle, SearchBarProps>`) and `SearchBarHandle` (`{ focus(): void }`). Shared search-bar chrome for the Graph and Flows surfaces (graph-flow-search CP2/CP3) — renders `.viewer-search-bar` with a debounced (200ms), echo-guarded `<input type="search">` (the same debounce/echo pattern `DictionaryView`'s own search box uses), a `.viewer-search-switch` toggle (`role="switch"`, `aria-checked={includeBody}`, wrapped in a `<label>` so the visible "Include descriptions" text is its accessible name — CP5 replaced the old bare `.viewer-search-body-toggle` button with this labeled switch), a `matchCount`/`totalCount` readout ("n of N", hidden when `matchCount === null`), and a `children` slot for a results dropdown. Enter flushes any pending debounce then calls `onEnter` (the caller decides what "next" means — Graph cycles matches, Flows opens the first result row); Escape clears the term and blurs. Props: `term`, `onTermChange`, `includeBody`, `onIncludeBodyChange`, `matchCount`, `totalCount`, `onEnter`, `placeholder`, `ariaLabel`, `className?`, `children?`. `App.tsx` mounts two instances tagged `className="viewer-search-bar--graph"` / `"viewer-search-bar--flow"` as CSS hooks; `--flow` now carries its own rule in `styles.css` (top offset sourced from `--flow-search-bar-top`, CP5 SC12) while `--graph` still renders through the shared `.viewer-search-bar` styling only. - **Flow search components:** - [`src/app/components/flow/FlowSearchResults.tsx`](../../src/app/components/flow/FlowSearchResults.tsx) (73L) — exports `FlowSearchResults({ results, onSelect })`. Renders the flow `SearchBar`'s results-slot dropdown (`.viewer-search-results`). Rows arrive already grouped by diagram — `searchFlowDiagrams` walks one diagram fully (parent, then its sub-DFDs) before moving to the next, so same-diagram rows are always contiguous — and are capped at `DISPLAY_CAP = 20` with a "+N more" line for the remainder. Each row shows a one-letter kind badge (`RESULT_KIND_LABEL`: process→P, external→E, store→S, diagram→D), the process's `dottedNumber` when present, and the result label; clicking a row calls `onSelect(diagramId)`. @@ -339,7 +339,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde [`src/types/cytoscape-fcose.d.ts`](../../src/types/cytoscape-fcose.d.ts) — ambient declarations for `cytoscape-fcose` (no `@types` package); default-exports the plugin's CJS register function typed as `cytoscape.Ext` so `cytoscape.use(fcose)` type-checks, with fCoSE's layout options object cast to `cytoscape.LayoutOptions` at each `cy.layout()` call site since fCoSE isn't in cytoscape's core layout-name union. -[`src/app/styles.css`](../../src/app/styles.css) — full SPA stylesheet (2741L). Key additions: `@media print` block (CP10), `::highlight(dd-search-highlight)` (CP9), `.dict-process-direction` badges, `.flow-minimap-wrapper` (left: 16px, CP19), `.zoom-control` (CP22/CP23), `.kbd-hint` (small keyboard-shortcut badge displayed inside FAB items and the DD lens toggle), `.flow-edge-tooltip` (position:fixed edge hover tooltip; `.flow-edge-tooltip__header`, `.flow-edge-tooltip__list`, `.flow-edge-tooltip__list li`), `.help-toggle` (top-bar `?` button; added to the print-hidden list), `.help-modal` + `.help-section` + `.help-section-title` + `.help-row` + `.help-term` + `.help-desc` + `.help-intro` + `.help-footnote` (help overlay content styles). DD chrome: `.dict-view` (full-width scroll container), `.dict-view-inner`, `.dict-search-bar` (fixed frosted top bar), `.dict-browse-lens`, `.dict-grid-card`, `.dict-flow-grid-card`, `.spotlight-overlay`, `.spotlight-line`, `.spotlight-line--flow`, `.spotlight-chips-container`, chip sub-classes, `.dict-browse-focus-bar`. **graph-flow-search additions:** `.viewer-search-bar` + `.viewer-search-bar-inner` (compact frosted pill, top-center, positioned via `top: var(--search-bar-top, 12px)` — the custom property App.tsx's banner-measuring layout effect sets while the global-error banner is visible) + `.viewer-search-input` + `.viewer-search-body-toggle` (+ `--active` state) + `.viewer-search-count` — the shared Graph/Flows `SearchBar` chrome; `.viewer-search-results` + `.viewer-search-results-group` (+ `-title`) + `.viewer-search-result-row` (+ `-kind`, `-dotted`, `-label`) + `.viewer-search-results-overflow` — the Flows results dropdown (`FlowSearchResults`), same frosted-pill language, nested under `.viewer-search-bar` so it inherits that selector's `@media print` rule. +[`src/app/styles.css`](../../src/app/styles.css) — full SPA stylesheet (2817L). Key additions: `@media print` block (CP10), `::highlight(dd-search-highlight)` (CP9), `.dict-process-direction` badges, `.flow-minimap-wrapper` (left: 16px, CP19), `.zoom-control` (CP22/CP23), `.kbd-hint` (small keyboard-shortcut badge displayed inside FAB items and the DD lens toggle), `.flow-edge-tooltip` (position:fixed edge hover tooltip; `.flow-edge-tooltip__header`, `.flow-edge-tooltip__list`, `.flow-edge-tooltip__list li`), `.help-toggle` (top-bar `?` button; added to the print-hidden list), `.help-modal` + `.help-section` + `.help-section-title` + `.help-row` + `.help-term` + `.help-desc` + `.help-intro` + `.help-footnote` (help overlay content styles). DD chrome: `.dict-view` (full-width scroll container), `.dict-view-inner`, `.dict-search-bar` (fixed frosted top bar), `.dict-browse-lens`, `.dict-grid-card`, `.dict-flow-grid-card`, `.spotlight-overlay`, `.spotlight-line`, `.spotlight-line--flow`, `.spotlight-chips-container`, chip sub-classes, `.dict-browse-focus-bar`. **graph-flow-search additions:** `.viewer-search-bar` + `.viewer-search-bar-inner` (compact frosted pill, top-center, positioned via `top: var(--search-bar-top, 12px)` — the custom property App.tsx's banner-measuring layout effect sets while the global-error banner is visible) + `.viewer-search-input` + `.viewer-search-count` — the shared Graph/Flows `SearchBar` chrome; `.viewer-search-results` + `.viewer-search-results-group` (+ `-title`) + `.viewer-search-result-row` (+ `-kind`, `-dotted`, `-label`) + `.viewer-search-results-overflow` — the Flows results dropdown (`FlowSearchResults`), same frosted-pill language, nested under `.viewer-search-bar` so it inherits that selector's `@media print` rule. **CP5 visual-tightening pass:** the old `.viewer-search-body-toggle` button is replaced by `.viewer-search-switch` (+ `-track`, `-thumb`, `-label`) — a 30×17 `role="switch"` track/thumb pair (accent-colored + thumb-slide on `[aria-checked="true"]`) beside a visible "Include descriptions" text label; `.viewer-search-bar.viewer-search-bar--flow` overrides `top` to `var(--flow-search-bar-top, 64px)` — a rule distinct from the base `.viewer-search-bar`'s `--search-bar-top`, so the flow bar clears the DFD breadcrumb row (measured and written by [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx), CP5 SC12) independently of the banner offset the graph bar uses. ### generators @@ -395,7 +395,7 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) (132L) — design doc for the key-inheritance-lineage feature: problem statement (subtype/dependent-1:1 entities look unrelated to group-mates), two edge-kind taxonomy (subtype-cluster vs dependent-identifying-1:1), transitive BFS closure rationale, DG ephemeral edge approach (why not model edges), DD dotted inherited line, `SPOTLIGHT_LINE_INHERITED` color design. [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md) (133L) — design doc for the viewer-ux-polish batch: 6 fixes (#1 HTML title, #2 spotlight line separation, #3 native-1:1 zoom model, #4 Cmd/Ctrl+/-/0 to canvas, #5 process node sizing, #6/#8 entity modal history via `entity=` hash). Explains the per-fix approach, goals/non-goals, and deferred item (#7 graph search highlight, issue #18) — [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) supersedes that deferral. [`docs/design/help-overlay.md`](../design/help-overlay.md) (62L) — design doc for the view-aware help overlay: problem statement (no in-app orientation for first-time users), approach (view-switched modal on `?` key + top-bar button, conceptual terms + per-view keyboard section), distinction from `LegendModal` (symbol reference), non-goals. -[`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) (82L) — design doc for search on the Graph and Flows views (issue #18): problem statement (no way to find a node by name in either view; Dictionary already has search-as-you-type), goals (Graph search bar with dim/highlight + `n of N` count + Enter-to-pan cycling, Flows search bar with a cross-diagram results dropdown, title-first matching with an opt-in body-text toggle, [`/`](../..) focuses the active view's search input on all three views), dim-don't-filter approach rationale, pure matchers in [`src/app/logic/search.ts`](../../src/app/logic/search.ts) with per-view wiring, shared `SearchBar` component, distinct `search-match`/`search-dim` cytoscape classes chosen over reusing `.faded` (hover's `clearFocusTiers` would erase it), cross-diagram flow matcher walking non-synthetic diagrams recursively, alternatives considered (filter-not-dim, reusing `.faded`, a global command-palette search, flow search scoped to the visible diagram only). Supersedes [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md)'s deferred item #7 (graph search highlight, issue #18). +[`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) (84L) — design doc for search on the Graph and Flows views (issue #18): problem statement (no way to find a node by name in either view; Dictionary already has search-as-you-type), goals (Graph search bar with dim/highlight + `n of N` count + Enter-to-pan cycling, Flows search bar with a cross-diagram results dropdown, title-first matching with an opt-in toggle switch labeled "Include descriptions" — chosen over a terse "Body" button because the label states what it does, [`/`](../..) or Cmd/Ctrl+`k` focuses the active view's search input on all three views), dim-don't-filter approach rationale, pure matchers in [`src/app/logic/search.ts`](../../src/app/logic/search.ts) with per-view wiring, shared `SearchBar` component, distinct `search-match`/`search-dim` cytoscape classes chosen over reusing `.faded` (hover's `clearFocusTiers` would erase it), cross-diagram flow matcher walking non-synthetic diagrams recursively, the flow bar's chrome non-collision requirement against the DFD breadcrumb chips/nav card (CP5, SC12), alternatives considered (filter-not-dim, reusing `.faded`, a global command-palette search, flow search scoped to the visible diagram only). Supersedes [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md)'s deferred item #7 (graph search highlight, issue #18). [`docs/spec/folder-model.md`](../spec/folder-model.md) (117L) — implementation contract for folder-model migration (#16): success criteria (entity scan confined to `data/`, optional `groups/`, root `externals/`+`stores/`, per-DFD override removed, fixture migration, count baselines, skill + docs update), checkpoints CP1–CP4, implementation log (squashed to d024c43, 2026-06-17, 577 PASS). [`docs/spec/app-tsx-decomposition.md`](../spec/app-tsx-decomposition.md) (246L) — implementation contract for App.tsx decomposition. [`docs/spec/src-root-organization.md`](../spec/src-root-organization.md) (82L) — implementation contract for [`src/`](../../src) directory split. @@ -422,11 +422,11 @@ Skill writes real files and runs `ignatius validate <model-dir> 2>&1` to verify [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (69L) — implementation contract for the arbitrary DFD nesting depth fix: success criteria pin `renumberDiagram` recursion + full-prefix invariant, `test-deep-nesting.ts` assertions, `flows-leveling` fixture servability, skill F8 recursion update, and `bun run test` + `build:cli` green gate. [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (155L) — implementation contract for the DFD viewer overhaul. Success criteria C1–C18 including: C4 (5-band ordering invariant), C6 (context diagram: 1 process, external-only flows), C7 (L1 overview: all leaf-activity processes + stores with degree ≥ 2, no degree=1 stores), C12 (dotted numbering), C13 (inline labels: length gate ≤ 22 chars), C14 (external role-split: at most 2 copies per external), C15 (ELK-routed edge geometry from `sections`), C16 (single-row bands), C17 (node centers in positions), C18 (ELK runs in browser — `terminateQuietly` guard). Checkpoints 4a–4e. [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) (83L) — implementation contract for DFD edge-hover data reveal (issue #14). CP1–CP4 build order. Success criteria: `normalizeEdgeData` pure helper; `dataLines: string[]` on `FlowElementData` edge variant; `position:fixed` HTML hover tooltip listing full data items under `source → target` header; long labels render truncated `…` preview inline (not suppressed); native `<title>` removed; `data-contract`/`data-contract-type` unchanged; dim/highlight focus preserved; CI Playwright check + visual screenshot; `bun run test` exit 0. -[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (142L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). +[`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (154L) — implementation contract for keyboard navigation shortcuts: `resolveShortcut` pure resolver, `useKeyboardShortcuts` hook contract, `DictionaryViewHandle.toggleLens`, `kbd-hint` badge placement, `?` key → `{type:'help'}` action and `onHelp` callback contract, test-plan (unit resolver + Playwright browser check). 2026-07-14 addendum: [`/`](../..) key → `{type:'search'}` action and `onSearch` callback added to the keymap/resolver, resolved through the ordinary bare-key guards (no Shift needed, unlike `?`). 2026-07-14 addendum (CP5): Cmd/Ctrl+`k` joins the modifier-gated slot (section renamed "Modifier-gated zoom" → "Modifier-gated zoom + search") as a second, always-on `{type:'search'}` route resolved before the editable guard — focuses search even while typing elsewhere, unlike bare [`/`](../..); gated on ctrl/meta only, bare `k` unmapped; the success-criteria checklist gained a line pinning `test-shortcuts.ts`'s Cmd/Ctrl+k coverage (editable-bypass, alt/shift → null, bare `k` → null). [`docs/spec/help-overlay.md`](../spec/help-overlay.md) (58L) — implementation contract for the help overlay: `HelpModal` component contract (view prop, Modal primitive, three view branches + shared Keyboard section), `{type:'help'}` `ShortcutAction` resolution order (after editable guard, before modifier guard, gated off ctrl/meta/alt), `showHelp` state in shell, `.help-toggle` top-bar button placement, `.help-*` CSS classes, test-plan (`test-help-overlay.ts`: `?` button + key open, Escape closes, view-aware content, editable guard). 2026-07-14 addendum: Graph and Flows "How to explore" sections gained a Search row, and every view's Keyboard section now lists [`/`](../..) (focus that view's search input). [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) (372L) — implementation contract for key-inheritance-lineage: `buildInheritedConnections` key-edge connected-component algorithm (FK ⊆ PK, both directions, cycle-safe, identifying-1:many covered), DG ephemeral `edge.inherited` lifecycle (SHIFT+HOVER trigger — not click/select), DD `SpotlightOverlay` dotted inherited lines SHIFT-GATED in `DictionaryView`, `SPOTLIGHT_LINE_INHERITED` constant placement, test-plan (`test-spotlight-inherited.ts`, `test-inherited-edges-no-leak.ts`, `test-graph-inherited-edges.ts`, `test-navigator-teardown.ts`). Design doc [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) also rewritten to the key-edge rule. -[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) (185L) — implementation contract for Graph/Flows search: SC1–SC11 (Graph `search-match`/`search-dim` classes + `n of N` count + Enter-cycle via `navigateToEntity`, resilience across hover/lineage/relayout/SSE-refresh, per-bar body-toggle title-vs-body matching per kind, Flows cross-diagram matcher over non-synthetic diagrams + results dropdown + `selectDiagramById` navigation including sub-DFDs, base-token role-split-suffix matching, [`/`](../..) → `{type:'search'}` shortcut wired through `DictionaryViewHandle.focusSearch()`, no persistence/layout/network side effects, bundle-only parity pinned as SC11), CP1–CP4 build order (pure matchers, Graph wiring, Flows wiring, [`/`](../..) shortcut + docs). Change log: checkpoint-table column-schema correction, CP4 scope extended to cover the keymap spec surfaces ([`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md), [`docs/spec/help-overlay.md`](../spec/help-overlay.md)). Implementation log: shipped 2026-07-14 across 7 commits via the /autopilot subagent loop, closing issue #18. -[`docs/guides/commands.md`](../guides/commands.md) (155L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) key (focus the active view's search bar — Graph, Dictionary, Flows) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. +[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) (199L) — implementation contract for Graph/Flows search: SC1–SC12 (Graph `search-match`/`search-dim` classes + `n of N` count + Enter-cycle via `navigateToEntity`, resilience across hover/lineage/relayout/SSE-refresh, per-bar toggle-switch (`role="switch"`, "Include descriptions" label) title-vs-body matching per kind, Flows cross-diagram matcher over non-synthetic diagrams + results dropdown + `selectDiagramById` navigation including sub-DFDs, base-token role-split-suffix matching, two routes to `{type:'search'}` — [`/`](../..) after the editable guard and Cmd/Ctrl+`k` before it, like the zoom chords — wired through `DictionaryViewHandle.focusSearch()`, no persistence/layout/network side effects, bundle-only parity pinned as SC11, SC12 pins flow-view chrome non-collision between the search bar and the DFD breadcrumb chips/nav card), CP1–CP5 build order (pure matchers, Graph wiring, Flows wiring, [`/`](../..) shortcut + docs, CP5 switch-control/Cmd-Ctrl-K/chrome-clearance/visual-tightening pass). Change log: checkpoint-table column-schema correction, CP4 scope extended to cover the keymap spec surfaces ([`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md), [`docs/spec/help-overlay.md`](../spec/help-overlay.md)), CP5 (switch control + visual tightening + Cmd/Ctrl+K + SC12 chrome non-collision) added from live user feedback mid-build. Implementation log: shipped 2026-07-14 across 5 /autopilot iterations (10 commits total including the CP5 iteration), closing issue #18. +[`docs/guides/commands.md`](../guides/commands.md) (155L) — user guide for CLI subcommands and keyboard shortcuts; documents the [`/`](../..) or Cmd/Ctrl+`K` chord (focus the active view's search bar — Graph, Dictionary, Flows; CP5 added Cmd/Ctrl+K as a second route that works even while a text field is already focused elsewhere) and the `?` key (open the help overlay) alongside the existing g/d/f/l/b keyboard shortcut table. [`docs/glossary.md`](../glossary.md) (52L) — canonical vocabulary: DG, DD, DFD, DE, DS, EE, Process, Data Flow. [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (118L) — research notes on SSADM DFD rules. [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) (129L) — research notes on DFD layout engines and Yourdon leveling used in specifying the dfd-overhaul approach; primary source for the ELK algorithm selection and band-partitioning option names. diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 56e97e5..08b8eea 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -4,7 +4,7 @@ ├── .cursor/ (1) │ └── rules/ (1) -│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (1836e40, 181L, 22730ch, 22957B) +│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (7e4582d, 181L, 22826ch, 23053B) ├── .github/ (1) │ └── workflows/ (2) │ ├── ci.yml (8718b72, 56L, 1568ch, 1568B) @@ -24,7 +24,7 @@ │ │ ├── dict-navigation.md (0c0f0f7, 100L, 5253ch, 5278B) │ │ ├── example-instance-tables.md (ba05799, 135L, 9385ch, 9441B) │ │ ├── folder-model.md (0c51512, 104L, 5558ch, 5591B) -│ │ ├── graph-flow-search.md (71e93a9, 82L, 7857ch, 7897B) +│ │ ├── graph-flow-search.md (6220a4f, 84L, 8535ch, 8581B) │ │ ├── graph-position-persistence.md (7d5bf76, 118L, 7012ch, 7070B) │ │ ├── help-overlay.md (f573c6b, 62L, 3274ch, 3291B) │ │ ├── ignatius-project-config.md (854740b, 107L, 6806ch, 6838B) @@ -42,7 +42,7 @@ │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) │ ├── guides/ (10) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (ee6a7f8, 155L, 8502ch, 8540B) +│ │ ├── commands.md (e11811d, 155L, 8666ch, 8704B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) │ │ ├── flows.md (ec7f7cd, 148L, 8533ch, 8577B) │ │ ├── folder-format.md (ec20419, 159L, 5499ch, 5517B) @@ -71,12 +71,12 @@ │ │ ├── dict-polish.md (eef2bdc, 87L, 6588ch, 6618B) │ │ ├── example-instance-tables.md (a7c76ab, 140L, 15242ch, 15296B) │ │ ├── folder-model.md (9221504, 117L, 11731ch, 11793B) -│ │ ├── graph-flow-search.md (fc4ccd6, 156L, 13782ch, 13954B) +│ │ ├── graph-flow-search.md (1080c63, 199L, 18217ch, 18435B) │ │ ├── graph-position-persistence.md (ae7bdcd, 106L, 8067ch, 8126B) │ │ ├── help-overlay.md (605fbef, 58L, 4378ch, 4390B) │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) -│ │ ├── keyboard-nav-shortcuts.md (06d68b5, 142L, 11569ch, 11654B) +│ │ ├── keyboard-nav-shortcuts.md (279fe27, 154L, 13210ch, 13317B) │ │ ├── noorm-flow-discovery.md (4b7f5a8, 83L, 10554ch, 10666B) │ │ ├── noorm-modeling-skill.md (947190e, 204L, 22457ch, 22582B) │ │ ├── process-flows.md (e5e6472, 682L, 83302ch, 83868B) @@ -222,7 +222,7 @@ │ │ │ └── theme-css-vars.ts (61ca750, 114L, 6255ch, 6267B) │ │ ├── hooks/ (4) │ │ │ ├── useHashRoute.ts (c50964c, 121L, 5142ch, 5150B) -│ │ │ ├── useKeyboardShortcuts.ts (33998d8, 92L, 3462ch, 3478B) +│ │ │ ├── useKeyboardShortcuts.ts (5962b6b, 93L, 3529ch, 3545B) │ │ │ ├── useModelData.ts (4877d58, 158L, 5483ch, 5609B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) │ │ ├── logic/ (11) @@ -233,7 +233,7 @@ │ │ │ ├── flow-spotlight.ts (0725869, 136L, 5096ch, 5114B) │ │ │ ├── relationship-key.ts (21bbb6e, 21L, 950ch, 956B) │ │ │ ├── search.ts (3c76b8b, 272L, 10130ch, 10138B) -│ │ │ ├── shortcuts.ts (bf68ab5, 115L, 4433ch, 4465B) +│ │ │ ├── shortcuts.ts (f81008d, 124L, 5035ch, 5071B) │ │ │ ├── spotlight-inherited.ts (8fa6183, 220L, 9495ch, 9873B) │ │ │ ├── spotlight-lines.ts (0138798, 100L, 3696ch, 3724B) │ │ │ └── spotlight.ts (d4aec20, 101L, 3534ch, 3540B) @@ -246,7 +246,7 @@ │ │ ├── hash-router.ts (04e4fa6, 95L, 2804ch, 2806B) │ │ ├── index.html (e560504, 14L, 396ch, 396B) │ │ ├── main.tsx (881fa67, 12L, 293ch, 293B) -│ │ └── styles.css (49b4ec5, 2741L, 62529ch, 63637B) +│ │ └── styles.css (859d44d, 2817L, 65439ch, 66563B) │ ├── cli/ (7) │ │ ├── cli.ts (284a888, 336L, 11989ch, 14833B) │ │ ├── discover.ts (2a355d6, 176L, 5910ch, 5940B) @@ -256,7 +256,7 @@ │ │ ├── update.ts (9e8f3fe, 221L, 7942ch, 8196B) │ │ └── version.ts (8ca92d2, 6L, 312ch, 314B) │ ├── flow-view/ (5) -│ │ ├── FlowChrome.tsx (caf68e2, 409L, 15329ch, 15750B) +│ │ ├── FlowChrome.tsx (c81ca71, 449L, 17265ch, 17698B) │ │ ├── FlowDiagramSvg.tsx (77a60a8, 1750L, 71330ch, 73341B) │ │ ├── elk-flow-layout.ts (2c68f80, 353L, 14722ch, 15545B) │ │ ├── flow-layout.ts (57004f3, 603L, 24035ch, 24826B) @@ -326,7 +326,7 @@ │ │ ├── test-flow-endpoints.ts (8e80450, 316L, 13756ch, 13778B) │ │ ├── test-flow-fingerprint.ts (be5f9d0, 393L, 14910ch, 14982B) │ │ ├── test-flow-leveling.ts (626cefc, 468L, 18579ch, 18637B) -│ │ ├── test-flow-search.ts (d4b2cb4, 255L, 13784ch, 13812B) +│ │ ├── test-flow-search.ts (d300fdf, 337L, 17894ch, 17934B) │ │ ├── test-flow-serve.ts (272afb2, 186L, 7205ch, 7707B) │ │ ├── test-flow-spotlight-connections.ts (9e58dcd, 349L, 16296ch, 16376B) │ │ ├── test-flow-wikilink.ts (699f762, 49L, 2157ch, 2165B) @@ -334,7 +334,7 @@ │ │ ├── test-graph-branding.ts (3edc892, 174L, 8180ch, 8426B) │ │ ├── test-graph-bundle-mode.ts (669847e, 213L, 8975ch, 8983B) │ │ ├── test-graph-inherited-edges.ts (6c93c7e, 240L, 10375ch, 10683B) -│ │ ├── test-graph-search.ts (45f3d0c, 310L, 14809ch, 14851B) +│ │ ├── test-graph-search.ts (1c9b1be, 318L, 15347ch, 15391B) │ │ ├── test-hash-router.ts (46f7614, 219L, 6942ch, 6998B) │ │ ├── test-help-overlay.ts (8d52edc, 140L, 5469ch, 5697B) │ │ ├── test-inherited-edges-no-leak.ts (0c8a942, 120L, 5250ch, 5348B) @@ -356,7 +356,7 @@ │ │ ├── test-relationship-row-key.ts (4642966, 81L, 4077ch, 4097B) │ │ ├── test-semantic-colors.ts (60b1f25, 71L, 2206ch, 2206B) │ │ ├── test-serve-port.ts (6e3e5fb, 52L, 1928ch, 2192B) -│ │ ├── test-shortcuts.ts (0280979, 346L, 15964ch, 16132B) +│ │ ├── test-shortcuts.ts (8e6d06c, 414L, 19280ch, 19484B) │ │ ├── test-spotlight-connections.ts (0806e1c, 282L, 10879ch, 10931B) │ │ ├── test-spotlight-inherited.ts (8f7b2c4, 388L, 18139ch, 18383B) │ │ ├── test-spotlight-lines.ts (e0cb78a, 207L, 9741ch, 9775B) @@ -465,9 +465,9 @@ │ ├── test-dict-view.ts (d59319f, 223L, 9004ch, 9368B) │ ├── test-export-offline.ts (9d1630a, 308L, 14287ch, 15241B) │ ├── test-flow-live-reload.ts (b173873, 202L, 7775ch, 8463B) -│ ├── test-flow-search.ts (548b28e, 86L, 3239ch, 3253B) +│ ├── test-flow-search.ts (4e8e5b5, 98L, 3961ch, 3979B) │ ├── test-graph-inherited-lines.ts (320bd0d, 330L, 14352ch, 14548B) -│ ├── test-graph-search.ts (bed2099, 88L, 3045ch, 3055B) +│ ├── test-graph-search.ts (4741226, 99L, 3789ch, 3803B) │ ├── test-process-node-size.ts (b43170e, 166L, 6641ch, 7051B) │ └── test-sse-playwright.ts (636e685, 75L, 2474ch, 2476B) ├── trash/ (1) @@ -493,7 +493,7 @@ ├── .gitignore (6d3972b, 53L, 809ch, 811B) ├── .signalsignore (50ea1be, 28L, 1036ch, 1050B) ├── CHANGELOG.md (871fba9, 218L, 17630ch, 17636B) -├── CLAUDE.md (1836e40, 181L, 22730ch, 22957B) +├── CLAUDE.md (7e4582d, 181L, 22826ch, 23053B) ├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B) ├── README.md (727982d, 69L, 4028ch, 4036B) ├── bun-env.d.ts (4404f36, 16L, 284ch, 284B) @@ -511,9 +511,9 @@ ## Languages -- TypeScript: 58921 LOC (70%), 265 files (45%) -- Markdown: 20404 LOC (24%), 298 files (50%) -- CSS: 2928 LOC (3%), 2 files (0%) +- TypeScript: 59159 LOC (70%), 265 files (45%) +- Markdown: 20461 LOC (24%), 298 files (50%) +- CSS: 3004 LOC (3%), 2 files (0%) - YAML: 1340 LOC (1%), 14 files (2%) - Shell: 116 LOC (0%), 1 file (0%) - JSON: 103 LOC (0%), 4 files (0%)