From 424363594c2675450764ebfaa4e4c5b1f5aea52f Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sat, 19 Sep 2026 06:02:54 -0400 Subject: [PATCH] feat: add flow index, level menus, hover delay Large models stalled on hover, since every pointer pass faded the whole view, and a flow was reachable only by drilling from Context. --- .claude/rules/wiki/cli.md | 3 +- .claude/rules/wiki/docs.md | 2 +- .claude/rules/wiki/flow-view.md | 4 +- .claude/rules/wiki/flows.md | 4 +- .claude/rules/wiki/frontend.md | 4 +- .claude/rules/wiki/router.md | 4 +- .claude/rules/wiki/skill.md | 2 +- .claude/rules/wiki/theme.md | 2 +- docs/design/large-model-nav.md | 102 ++++ docs/guides/commands.md | 3 +- docs/guides/flows.md | 31 +- docs/guides/folder-format.md | 2 + docs/spec/dfd-overhaul.md | 10 +- docs/spec/graph-flow-search.md | 10 +- docs/spec/help-overlay.md | 8 +- docs/spec/keyboard-nav-shortcuts.md | 9 +- docs/spec/large-model-nav.md | 217 +++++++++ docs/spec/model-index-routing.md | 8 +- docs/spec/process-flows.md | 10 +- docs/wiki/cli.md | 67 ++- docs/wiki/docs.md | 46 +- docs/wiki/feature-map.md | 1 + docs/wiki/flow-view.md | 92 +++- docs/wiki/flows.md | 52 +- docs/wiki/frontend.md | 90 +++- docs/wiki/index.md | 30 +- docs/wiki/router.md | 31 +- docs/wiki/scan.md | 175 +++---- docs/wiki/skill.md | 6 +- docs/wiki/theme.md | 147 ++++-- .../flows/agent-project-setup/index.md | 4 + .../flows/artifact-management/index.md | 4 + models/llm-memory-db-mssql/flows/index.md | 14 +- .../flows/memory-lifecycle/index.md | 4 + .../flows/note-capture/index.md | 4 + .../flows/tag-administration/index.md | 6 +- .../flows/work-planning/index.md | 4 + models/llm-memory-db-mssql/index.md | 2 +- skills/ignatius-modeling/SKILL.md | 2 +- .../references/dfd-authoring.md | 2 + .../references/flow-templates.md | 13 +- src/app/App.tsx | 4 +- .../components/entity/SpotlightOverlay.tsx | 3 +- src/app/components/ui/HelpModal.tsx | 5 +- src/app/hash-router.ts | 8 +- src/app/hooks/useKeyboardShortcuts.ts | 10 +- src/app/logic/motion.ts | 81 ++++ src/app/logic/shortcuts.ts | 3 + src/app/styles.css | 457 +++++++++++++++++- src/app/views/dict/DictionaryView.tsx | 43 +- src/app/views/flow/FlowsView.tsx | 166 +++---- src/app/views/graph/GraphView.tsx | 133 ++--- src/flow-view/FlowChrome.tsx | 321 ++++++------ src/flow-view/FlowDiagramSvg.tsx | 146 +++--- src/flow-view/FlowIndex.tsx | 120 +++++ src/flow-view/LevelMenu.tsx | 123 +++++ src/flow-view/flow-nav.ts | 185 +++++++ src/flows/flow-derive-levels.ts | 1 + src/flows/flow-parse.ts | 40 ++ src/router/build.ts | 8 +- test/checks/test-flow-diagram-description.ts | 140 ++++++ test/checks/test-flow-nav.ts | 122 +++++ test/checks/test-graph-inherited-edges.ts | 53 +- test/checks/test-hash-router.ts | 7 + test/checks/test-large-model-nav.ts | 257 ++++++++++ test/checks/test-motion.ts | 104 ++++ test/checks/test-shortcuts.ts | 17 + test/visual/screenshot-hover-fade.ts | 21 +- test/visual/screenshot-large-model-nav.ts | 90 ++++ test/visual/screenshot-lineage-highlight.ts | 23 +- test/visual/screenshot-predicate-hover.ts | 14 +- test/visual/test-dd-spotlight-grid.ts | 29 +- test/visual/test-graph-inherited-lines.ts | 52 +- 73 files changed, 3266 insertions(+), 751 deletions(-) create mode 100644 docs/design/large-model-nav.md create mode 100644 docs/spec/large-model-nav.md create mode 100644 src/app/logic/motion.ts create mode 100644 src/flow-view/FlowIndex.tsx create mode 100644 src/flow-view/LevelMenu.tsx create mode 100644 src/flow-view/flow-nav.ts create mode 100644 test/checks/test-flow-diagram-description.ts create mode 100644 test/checks/test-flow-nav.ts create mode 100644 test/checks/test-large-model-nav.ts create mode 100644 test/checks/test-motion.ts create mode 100644 test/visual/screenshot-large-model-nav.ts diff --git a/.claude/rules/wiki/cli.md b/.claude/rules/wiki/cli.md index e57ed82..20c9153 100644 --- a/.claude/rules/wiki/cli.md +++ b/.claude/rules/wiki/cli.md @@ -4,12 +4,13 @@ paths: - "src/cli/**" --- -Domain: cli. citty-based subcommand dispatch (serve/validate/export/index/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update + version reporting +Domain: cli. citty-based subcommand dispatch (serve/validate/export/index/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update with streamed download progress + checksum verification Map: - docs/wiki/cli.md Contracts: - docs/spec/cli-and-outputs.md + - docs/spec/update-download-progress.md Designs: - docs/design/cli-and-outputs.md diff --git a/.claude/rules/wiki/docs.md b/.claude/rules/wiki/docs.md index 3603315..23d42b7 100644 --- a/.claude/rules/wiki/docs.md +++ b/.claude/rules/wiki/docs.md @@ -8,7 +8,7 @@ paths: - "docs/glossary.md" --- -Domain: docs. Design docs, user guides, research notes, and implementation-contract specs — 78 markdown files plus `docs/glossary.md` across `docs/design/`, `docs/guides/`, `docs/research/`, `docs/spec/` +Domain: docs. Design docs, user guides, research notes, and implementation-contract specs — 81 markdown files plus `docs/glossary.md` across `docs/design/`, `docs/guides/`, `docs/research/`, `docs/spec/` Map: - docs/wiki/docs.md diff --git a/.claude/rules/wiki/flow-view.md b/.claude/rules/wiki/flow-view.md index 2f93f77..080bfba 100644 --- a/.claude/rules/wiki/flow-view.md +++ b/.claude/rules/wiki/flow-view.md @@ -4,7 +4,7 @@ paths: - "src/flow-view/**" --- -Domain: flow-view. ELK-driven DFD layout (5-band partitioning, orthogonal edge routing) with a stack-node model for per-process/connected views and three collapse levels (stores/clusters/groups); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming +Domain: flow-view. ELK-driven DFD layout (5-band partitioning, orthogonal edge routing) with a stack-node model for per-process/connected views and three collapse levels (stores/clusters/groups); `flow-nav.ts` resolves exact diagram paths for the new flow index + breadcrumb level menus (`FlowIndex.tsx`/`LevelMenu.tsx`, replacing the old standalone nav card); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming and gates hover transitions through the frontend domain's hover-intent/animation-limit helpers Map: - docs/wiki/flow-view.md @@ -13,12 +13,14 @@ Contracts: - docs/spec/dfd-overhaul.md - docs/spec/dfd-store-clusters.md - docs/spec/graph-flow-search.md + - docs/spec/large-model-nav.md - docs/spec/viewer-ux-polish.md Designs: - docs/design/dfd-edge-hover-data.md - docs/design/dfd-overhaul.md - docs/design/dfd-store-clusters.md - docs/design/graph-flow-search.md + - docs/design/large-model-nav.md Research: - docs/research/dfd-layout-and-leveling.md diff --git a/.claude/rules/wiki/flows.md b/.claude/rules/wiki/flows.md index d4581f4..2a69bfb 100644 --- a/.claude/rules/wiki/flows.md +++ b/.claude/rules/wiki/flows.md @@ -4,7 +4,7 @@ paths: - "src/flows/**" --- -Domain: flows. SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (17 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model +Domain: flows. SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`, now also reading a folder's own `description:` off `index.md`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (17 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model Map: - docs/wiki/flows.md @@ -13,12 +13,14 @@ Contracts: - docs/spec/dfd-overhaul.md - docs/spec/dfd-store-clusters.md - docs/spec/folder-model.md + - docs/spec/large-model-nav.md - docs/spec/process-flows.md Designs: - docs/design/dfd-nesting-depth.md - docs/design/dfd-overhaul.md - docs/design/dfd-store-clusters.md - docs/design/folder-model.md + - docs/design/large-model-nav.md - docs/design/process-flows.md Guides: - docs/guides/flows.md diff --git a/.claude/rules/wiki/frontend.md b/.claude/rules/wiki/frontend.md index ba90af4..faf7c0e 100644 --- a/.claude/rules/wiki/frontend.md +++ b/.claude/rules/wiki/frontend.md @@ -4,12 +4,13 @@ paths: - "src/app/**" --- -Domain: frontend. React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; `flowview=`/`collapse=` hash params drive the flow view mode and collapse level; `StackDialog`/`EdgeContractDialog` cover stack and edge-contract detail; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath +Domain: frontend. React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; `logic/motion.ts` centralizes hover-intent debouncing and a per-view animation-element-limit; `flowview=`/`collapse=` hash params drive the flow view mode and collapse level; new `i` shortcut opens the flow index; `StackDialog`/`EdgeContractDialog` cover stack and edge-contract detail; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath Map: - docs/wiki/frontend.md Contracts: - docs/spec/dfd-store-clusters.md + - docs/spec/large-model-nav.md Designs: - docs/design/app-tsx-decomposition.md - docs/design/branding.md @@ -19,6 +20,7 @@ Designs: - docs/design/graph-flow-search.md - docs/design/graph-position-persistence.md - docs/design/key-inheritance-lineage.md + - docs/design/large-model-nav.md - docs/design/unified-app.md - docs/design/viewer-ux-polish.md - docs/design/wiki-entity-links.md diff --git a/.claude/rules/wiki/router.md b/.claude/rules/wiki/router.md index bc3e283..dac724a 100644 --- a/.claude/rules/wiki/router.md +++ b/.claude/rules/wiki/router.md @@ -4,13 +4,15 @@ paths: - "src/router/**" --- -Domain: router. Generates per-folder `index.md` routers with rolled-up SHA-256 digests, in-folder agent guidance (`AGENTS.md`, `CLAUDE.md` shim, `SKILL.md`), and a position-based `` region parser; backs `ignatius index` and `validate --index` +Domain: router. Generates per-folder `index.md` routers with rolled-up SHA-256 digests, in-folder agent guidance (`AGENTS.md`, `CLAUDE.md` shim, `SKILL.md`), and a position-based `` region parser; a flow folder's own `description:` now folds into its parent row's digest; backs `ignatius index` and `validate --index` Map: - docs/wiki/router.md Contracts: + - docs/spec/large-model-nav.md - docs/spec/model-index-routing.md Designs: + - docs/design/large-model-nav.md - docs/design/model-index-routing.md Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/skill.md b/.claude/rules/wiki/skill.md index a169d48..f2816f6 100644 --- a/.claude/rules/wiki/skill.md +++ b/.claude/rules/wiki/skill.md @@ -4,7 +4,7 @@ paths: - "skills/ignatius-modeling/**" --- -Domain: skill. Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate` +Domain: skill. Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate`; a flow's own `description:` is now authored to `flows//index.md` (Step F6a) Map: - docs/wiki/skill.md diff --git a/.claude/rules/wiki/theme.md b/.claude/rules/wiki/theme.md index 45db526..47ab832 100644 --- a/.claude/rules/wiki/theme.md +++ b/.claude/rules/wiki/theme.md @@ -4,7 +4,7 @@ paths: - "src/theme/**" --- -Domain: theme. ThemeConfig + Branding types, default palettes, flow-kind colors, dark/light merging +Domain: theme. ThemeConfig + Branding types, default palettes, flow-kind colors + per-kind store-cap symbols (`FLOW_STORE_KIND_SYMBOLS`), dark/light merging Map: - docs/wiki/theme.md diff --git a/docs/design/large-model-nav.md b/docs/design/large-model-nav.md new file mode 100644 index 0000000..35fbfde --- /dev/null +++ b/docs/design/large-model-nav.md @@ -0,0 +1,102 @@ +# Large-model navigation + + +## Problem + + +On a large model the viewer stops being usable in two ways. + +**Hover repaints the whole view.** Hovering any node or edge in the Flows view, any entity in the Graph, or any card in the Dictionary browse lens fades everything outside its connections. The fade applied on pointer contact, so moving the pointer across a diagram faded and restored the whole view once per element it crossed, each time with an opacity transition on every element. The DFD also rebuilt its node and edge model on every render (`buildFlowData` ran unmemoized in `FlowDiagramSvg`), and the edge tooltip re-rendered the SVG on every pointer move. + +**Flows have no index.** The CSCLeverage enhanced model has 29 flows and 203 processes. Parsing wraps every flow in one synthetic `Context` root (`deriveLevels`), so the DFD nav card, which only appears with two or more roots, never showed. The only route to a flow was drilling down from `Context` through `0 System`, and nothing on screen said what a flow was for: a top-level flow folder had no file of its own to carry a `description:`. + +A large model is exactly where a user needs to find a flow and look at one element at a time. + + +## Goals / Non-goals + + +- Goals: a hover focus applies only when the pointer rests on one target; a view large enough to stall on its fades drops the animation and jumps to the end state; a browsable index of every flow and process with descriptions; breadcrumbs that switch between the diagrams at their own level; a place for a flow folder's description. +- Non-goals: virtualizing or culling large diagrams; changing layout; a validator rule that requires flow descriptions; fixing the Dictionary's process-id React keys, which collide when two flows share a process name (tracked separately). + + +## Approaches + + +We picked from rendered mocks (codes below). Hover timing, where a pointer that has left a focused element crosses empty space: + +| # | Approach | Pros | Cons | +|---|----------|------|------| +| A1 | Settle: every target change, including leaving to empty space, applies after 300 ms at rest | A sweep does nothing; moving A → B switches in one step with no un-fade/re-fade | Leaving takes 300 ms to clear | +| A2 | Enter waits 300 ms, leave clears at once | Instant clear | A → gap → B restores the whole view, then fades again 300 ms later: two full repaints | + +Flow index: + +| # | Approach | Pros | Cons | +|---|----------|------|------| +| B1 | Side drawer with a collapsible outline and filter | Stays open while working | A list of names; no picture of the hierarchy | +| B2 | Book-style contents page | Reads like a table of contents | Full-screen; hides the diagram | +| B3 | SSADM process-hierarchy chart with a description pane | The standard SSADM picture of the process model; hover reads a description without leaving the chart | Tall on large models (one row per process) | + +Breadcrumb level switching: + +| # | Approach | Pros | Cons | +|---|----------|------|------| +| C1 | Split crumb: the label goes up, a ▾ opens that level's diagrams | Keeps today's click; the ▾ marks which crumbs have a menu | Two targets per crumb | +| C2 | Separator menus (Explorer path bar): each separator lists the children of the crumb to its left | Also drills down | The level a separator lists is one off from where the eye lands | +| C3 | The whole crumb opens a menu whose first row goes up | One target | Going up takes two clicks | + +Where a flow folder's description lives: + +| # | Approach | Pros | Cons | +|---|----------|------|------| +| D1 | `description:` frontmatter in the folder's index file (`flows//index.md`) | The router file already exists per folder; `ignatius index` rewrites only its `` regions, so frontmatter survives; the same value fills the router's empty folder rows | The index file is partly generated, so authors edit a file the tool also writes | +| D2 | A new per-folder file | Nothing generated in it | A new file kind; the folder model forbids `_*` files and a second `.md` would be read as a process | +| D3 | A `flows:` map in `ignatius.yml` | One place | Descriptions drift away from the folders they describe | + + +## Recommendation + + +A1, B3, C1, and D1. + +A1, because the cost is the repaint, not the delay: A2 still repaints twice on every move between neighbours. Every hover site feeds one helper, `createHoverIntent` in `src/app/logic/motion.ts`, which holds the settled target and applies a new one after `HOVER_INTENT_MS` (300) at rest. + +The view repaints only when a new target has held the pointer for 300 ms, or on a deliberate action; every other pointer move is free. + +```mermaid +stateDiagram-v2 + [*] --> Settled + Settled --> Waiting: pointer reaches another target or empty space + Waiting --> Waiting: pointer reaches a third target, wait restarts + Waiting --> Settled: pointer returns to the shown target, no repaint + Waiting --> Settled: 300 ms on one target, repaint + Settled --> Settled: Shift or a dialog, applyNow repaints at once +``` + +Pressing Shift over a Graph node and opening a dialog are deliberate actions, so they apply at once through `applyNow`. Above `ANIMATION_ELEMENT_LIMIT` (150 rendered nodes plus edges, or Dictionary sections and cards) the view drops its transitions and smooth scrolls: the fade then covers hundreds of elements in one frame and the animation itself is the cost. The Graph has no hover animation, so it only gets the delay. The Dictionary marks its root `data-motion="off"`, and `scrollBehaviorWithin` reads that marker, so the shell's scroll into the Dictionary gets the same answer without recounting. + +B3 and C1 read one navigation model, `src/flow-view/flow-nav.ts`, built from the leveled tree. Both address a diagram by its id path from the root rather than a bare id, because a sub-DFD's id is its process file name and two flows can share one; a bare-id lookup returns the first match. The Flows core gained `selectDiagramPath` for this. The URL follows suit: `dfd=` carries the path below the derived levels (`invoicing/Submit-PCI`), so a reload, Back/Forward, a live-reload rebuild, or a view toggle resumes at the same diagram. `findDiagramByRef` matches a reference by its trailing ids, so a bare-id link written earlier resolves exactly as before. + +The index, level menus, and descriptions all derive from the leveled tree the parser already builds. + +```mermaid +flowchart LR + A["flows/‹dfd›/index.md description:"] --> B[parseFlows] + B --> C["deriveLevels (L1 process carries it)"] + C --> D["flow-nav: buildFlowIndex, levelEntries"] + D --> E[FlowIndex] + D --> F[LevelMenu] + B --> G["router folder row"] +``` + +The two derived levels leave the breadcrumb. Every trail used to start `Context / 0 System`: two crumbs no author wrote, each the only diagram at its level, so neither could carry a ▾, and the view opened on Context's single box. Now the view opens on the `0 System` overview, the trail reads `☰ Process Flows / ⌂ / 9 Invoicing ▾ / …`, and the house button returns to the overview from any depth in one click. The index drops the same two levels, so the flows are its top rows. The Context diagram stays in the tree (it is the only picture of every external against the system boundary) and opens by `dfd=__context__`. + +D1, because the index file is already per folder and the router already writes a description column for every other kind. A sub-DFD needs no index file: its owning process's `description:` describes it. `Context` and the whole-system process use the model's `description:` from `ignatius.yml`. When a folder has a description, the router folds it into that folder row's hash, so rewording it marks the parent router stale; a folder without one keeps its old digest, so existing models do not go stale on upgrade. + + +## Open questions + + +- A flow index row for a process with no sub-DFD opens the diagram that contains it. Should it also open that process's ⓘ dialog? +- One settled hover still costs one main-thread task: about 80 ms on the CSCLeverage `0 System` overview (103 nodes), mostly React re-rendering every node for the new opacity; the Graph's settle stays under 50 ms. Memoizing the node components, or dimming through the DOM instead of props, would cut the DFD cost. The 300 ms delay and the 150-element limit are one constant each in `motion.ts`. diff --git a/docs/guides/commands.md b/docs/guides/commands.md index c99eaa4..b4b78a5 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -141,6 +141,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 | +| `i` | Open or close the flow index — Flows view | | `/` or `Cmd`/`Ctrl` + `K` | Focus the search bar — Graph, Dictionary, Flows | | `?` | Open the help overlay for the current view | | `←` `→` `↑` `↓` | Scroll the canvas 10px — Graph and Flows views; hold `Shift` for 50px | @@ -151,7 +152,7 @@ Shortcuts are ignored while typing in a search box or any other input, and when ### Help overlay -Every view has a `?` button in the top bar, next to the light/dark toggle. It opens a short, view-aware overview — what you are looking at, how to explore it, and the keys that work here. The Graph explains entity types and key-inheritance lineage; the Dictionary explains its lenses and spotlight; the Flows view explains DFD symbols and drill-down. Press `?` or click the button; press Escape or click outside to close. For the exact diagram symbols, use the **Legend** instead. +Every view has a `?` button in the top bar, next to the light/dark toggle. It opens a short, view-aware overview — what you are looking at, how to explore it, and the keys that work here. The Graph explains entity types and key-inheritance lineage; the Dictionary explains its lenses and spotlight; the Flows view explains DFD symbols, drill-down, the flow index, and the breadcrumb level menus. Press `?` or click the button; press Escape or click outside to close. For the exact diagram symbols, use the **Legend** instead. ### Zoom diff --git a/docs/guides/flows.md b/docs/guides/flows.md index 76a8f09..113e632 100644 --- a/docs/guides/flows.md +++ b/docs/guides/flows.md @@ -22,6 +22,7 @@ models/ gateway-log.md # optional description of a non-entity store flows/ order-to-cash/ + index.md # optional: `description:` of the whole diagram Create-Sales-Order.md # process 1 Create-Sales-Order/ # same-named folder = sub-DFD of process 1 Validate-Customer.md @@ -34,6 +35,19 @@ models/ The file name (minus `.md`) is the process id used everywhere — in `proc:` tokens, in `[[wiki-links]]`, and as the sub-DFD folder name. Name it as an imperative phrase with hyphens for spaces: `Collect Payment` → `Collect-Payment.md`. +### Describing a diagram + + +A top-level diagram describes itself with `description:` frontmatter in its folder's index file, `flows//index.md`. The flow index, the breadcrumb level menus, and the generated router's folder row show it. + +```markdown +--- +description: Order entry through invoicing and cash collection. +--- +``` + +The index file is also the router `ignatius index` writes (see [Generated routers](folder-format.md#generated-routers)). The generator rewrites only its `` regions, so the frontmatter above them survives every regeneration; create the file by hand if the model has no routers yet. A sub-DFD needs no index file: its owning process's `description:` describes it. The `Context` diagram and the whole-system `0` process use the model's `description:` from `ignatius.yml`. + ## A process file @@ -267,12 +281,27 @@ The Flows FAB menu carries three flow-specific controls: a view item labelled by ## Viewing flows -`ignatius serve` shows flows in the **Flows** view (`#view=flow`); the active diagram is deep-linkable via the `dfd=` hash parameter and survives refresh, alongside `flowview=` and `collapse=` for the view and collapse-level settings above. `ignatius export` includes the Flows view in the same single HTML file. Every node carries a ⓘ badge: a `db:` store opens the rich entity dialog, everything else opens its markdown doc. The process dictionary — every process, external, and store with its body and IO tables — is fused into the **Dictionary** view, searchable alongside the entities. +`ignatius serve` shows flows in the **Flows** view (`#view=flow`); the active diagram is deep-linkable via the `dfd=` hash parameter (a flow by its folder name, a sub-diagram by its path, e.g. `dfd=invoicing/Submit-PCI`) and survives refresh, alongside `flowview=` and `collapse=` for the view and collapse-level settings above. `ignatius export` includes the Flows view in the same single HTML file. Every node carries a ⓘ badge: a `db:` store opens the rich entity dialog, everything else opens its markdown doc. The process dictionary — every process, external, and store with its body and IO tables — is fused into the **Dictionary** view, searchable alongside the entities. `ignatius validate` checks flows whenever a `flows/` directory exists, with seventeen `flow.*` rules covering unknown references, column contracts, connection shape, numbering, decomposition balance, and cluster references. See [Validation and findings](validation.md#flow-rules) for the catalog. One rule is configurable: direct process-to-process flows warn by default and can be silenced with `flow_rules: { process_to_process: false }` in `ignatius.yml`. Hovering a data flow edge that carries data (the arrow between two nodes) reveals a styled tooltip listing the full data carried across it, under a `source → target` header. This includes the complete contents of `db:` column lists that are otherwise abbreviated on the canvas when they exceed the inline-label length limit. The tooltip is positioned fixed to the viewport and remains legible at any zoom level. Long data labels (more than 22 characters) show a truncated `…` preview on the canvas — the first ~22 characters followed by `…` — so you can always see at a glance which edges carry hidden data; the full contents are revealed on hover. An authored `label:` replaces this preview outright, on a plain edge and on a stack edge alike. +### Finding a flow + + +The Flows view opens on the `0 System` overview, one box per flow. Leveling derives that overview and the `Context` diagram above it; neither gets a breadcrumb. A house button between the **☰ Process Flows** chip and the first crumb returns to the overview from any depth, and shows as current while you are on it; Back from a top-level flow also returns there. The Context diagram (every external around one system box) opens by `#view=flow&dfd=__context__`. + +| Control | What it does | +|---|---| +| **☰ Process Flows** chip, or `i` | Opens the flow index: every flow and process as an SSADM process-hierarchy chart, in number order, with the flows as the top level. Hover or focus a row to read its description in the side pane; click it to open its diagram (a process without a sub-DFD opens the diagram that contains it). Esc, ✕, `i`, or the chip closes it. | +| ▾ on a breadcrumb | Appears when that crumb's level holds more than one diagram. Lists them with number, description, and process count, the current one marked; a filter box appears above eight entries, and ↑ ↓ Enter pick from the keyboard. Clicking the crumb's label still goes up to that level. | + +### Hover and large diagrams + + +Hovering a node, edge, or label dims everything it does not connect to, and hovering an edge shows its data tooltip. Both apply once the pointer rests on the same element for 300 ms, so moving the pointer across a diagram changes nothing; resting on empty canvas clears them the same way. A diagram that renders more than 150 nodes and edges together drops the fade animation and switches opacity in one step. The same rest time applies to Graph nodes and Dictionary browse cards; pressing Shift while over a Graph node shows its lineage at once. + ## Authoring with the skill diff --git a/docs/guides/folder-format.md b/docs/guides/folder-format.md index b268ff1..bbdeb66 100644 --- a/docs/guides/folder-format.md +++ b/docs/guides/folder-format.md @@ -101,6 +101,8 @@ A new entity here carries `party_id` as its first PK column. That is what makes the router files safe to hand-edit: add a rules block once, and `ignatius index` never touches it. A file with no region gets one appended; a missing file is created. Running `ignatius index` twice over an unchanged model produces byte-identical output. +A flow diagram folder's router is also where the folder describes itself: `description:` frontmatter at the top of `flows//index.md` fills that folder's row in `flows/index.md` and is shown by the app's flow index and breadcrumb menus (see [Describing a diagram](flows.md#describing-a-diagram)). The row's hash folds the description in when there is one, so rewording it marks the parent router stale; a folder without a description keeps the plain folder digest. + One constraint follows from how the generator finds its regions: **a line that starts with `` block, keep it off column 0: inline code, an indented line, or `<ignatius-index>`. A nested opener, an orphan or mismatched closer, an unclosed opener, or a duplicate region makes `ignatius index` refuse the file and name the line and the fix. `ignatius validate --index ` recomputes every digest and reports drift as `index.stale`, writing nothing — the CI gate for "routers match the files on disk". `index.orphaned` warns when a router file left over from a previous `index_file` value is still on disk. A plain `ignatius validate` never pays this hashing cost; `--index` is opt-in. diff --git a/docs/spec/dfd-overhaul.md b/docs/spec/dfd-overhaul.md index b726e22..9574b39 100644 --- a/docs/spec/dfd-overhaul.md +++ b/docs/spec/dfd-overhaul.md @@ -27,7 +27,7 @@ All of the following must be true for the overhaul to be considered done: - [ ] **C6.** Leveling: on the proving model, the auto-derived context diagram has exactly 1 process (the system bubble) and only flows to/from externals. - [ ] **C7.** Leveling: on the proving model, the auto-derived Level-1 overview (a) contains all 6 activity processes, (b) contains every store whose degree across the leaf set is ≥ 2, and (c) contains NO store whose degree across the leaf set is exactly 1 (single-process-local stores are absent from Level-1). - [ ] **C8.** `FlowDiagramSvg` renders identically when passed ELK positions (via the new `elkPositions` prop) vs the old banded positions — no visual regression on `key-inherited/flows/order-to-cash`. Verified AT the wiring checkpoint (CP3) by capturing both screenshots. ELK is the primary position source for all renders; the banded `computeFlowLayout` path is retained ONLY as the fallback when ELK layout fails. `savedPositions` drag overrides win over both. -- [ ] **C9.** Drill-down, breadcrumbs, and `dfd=` deep-link work for the synthesised context and Level-1 diagrams (exercised by a URL-navigability check analogous to `test-cp3-dfd-url-navigability.ts`). +- [ ] **C9.** Drill-down and `dfd=` deep-link work for the synthesised context and Level-1 diagrams (exercised by a URL-navigability check analogous to `test-cp3-dfd-url-navigability.ts`). The two derived levels get no breadcrumb; the Process Flows chip and the flow index stand for them (`docs/spec/large-model-nav.md`). - [ ] **C10.** An unbalanced-boundary fixture fires `flow.unbalanced_decomposition` at the context↔L1 and L1↔leaf boundaries; the proving model fires none. - [ ] **C11.** `bun run typecheck` clean (no new type errors introduced). - [ ] **C12.** Dotted-number correctness: on the proving model each synthesised Level-1 process carries its correct top-level number (`1`, `2`, …, `6`); each leaf diagram's processes are renumbered `N.1`, `N.2`, … where `N` is their parent Level-1 number. Verified via the existing `dottedNumber` / `compareDottedProcesses` scheme on the proving model. @@ -106,6 +106,14 @@ Evidence from `docs/research/dfd-layout-and-leveling.md §5`: ## Change log +### 2026-09-18 — Derived levels leave the breadcrumb + +**What changed:** C9 no longer promises breadcrumbs for the context and Level-1 diagrams. The Flows view opens on the Level-1 overview, and the Process Flows chip plus the flow index stand in for both derived levels (`docs/spec/large-model-nav.md`). + +**Why:** on a 29-flow model every trail started with the same two derived crumbs, neither of which could ever list a sibling. + +**Superseded:** breadcrumbs showed `Context / 0 System` above every flow. + ### 2026-06-13 — spike outcome: drop compound grouping, labels on-demand **What changed:** The SPIKE checkpoint ran (`tmp/elk-spike/`) and its findings amended the plan. (1) Compound store grouping is removed — it more than quintupled crossings and collapsed the bands; store proximity now comes from ELK's natural in-layer ordering. (2) Full column-list edge labels are no longer rendered inline (always-on labels blow diagram width to ~2000–5000px even line-wrapped); the data contract moves to hover/click. (3) The spike gate metric changed from bounding-box *area* ≤ 2× to a *width*/usability bound, because area masked the horizontal blowup. Success criteria, the Recommendation, the Checkpoints table, and the Risks table were rewritten to this truth and renumbered. diff --git a/docs/spec/graph-flow-search.md b/docs/spec/graph-flow-search.md index 73bc92c..4fcdda5 100644 --- a/docs/spec/graph-flow-search.md +++ b/docs/spec/graph-flow-search.md @@ -38,7 +38,7 @@ Dim-don't-filter with pure matchers and per-view wiring, per `docs/design/graph- - 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. +- SC12 — Chrome non-collision on the Flows view: the search bar never overlaps the DFD breadcrumb chips, 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 @@ -139,6 +139,14 @@ M CLAUDE.md — feature-map row (and '/' added to t ## Change log +### 2026-09-18 — SC12 no longer names the nav card + +**What changed:** SC12 drops "or the diagram nav card"; the Flows view has no nav card (`docs/spec/large-model-nav.md`). A breadcrumb level menu drops over the search bar on purpose, since the crumb row stacks above it. + +**Why:** the nav card was removed; the flow index and breadcrumb level menus replace it. + +**Superseded:** SC12 required non-collision with the diagram nav card. + ### 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. diff --git a/docs/spec/help-overlay.md b/docs/spec/help-overlay.md index ab5ca4c..4b81a48 100644 --- a/docs/spec/help-overlay.md +++ b/docs/spec/help-overlay.md @@ -24,7 +24,7 @@ primitive, view-switched on `ViewName`, with static term→description content; - [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, 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, cross-diagram search (results grouped by diagram, non-matches dimmed in the rendered diagram). +- [x] Flows body covers: a DFD intro, symbols (process/store/external), drill-down + inspect, the flow index (Process Flows chip or `I`), breadcrumb level switching (▾), 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. @@ -62,3 +62,9 @@ primitive, view-switched on `ViewName`, with static term→description content; **What changed:** the Graph and Flows Keyboard sections gained a `← ↑ ↓ →` row ("Scroll the canvas; hold Shift to scroll faster"), added inside the existing graph/flow-only block next to the zoom chord row. **Why:** arrow-key canvas panning shipped in the shortcut resolver (`docs/spec/keyboard-nav-shortcuts.md`, 2026-07-14 arrow-key entry); the overlay lists the keys that work on each view. + +### 2026-09-18 — Flow index and level switching rows + +**What changed:** the Flows "How to explore" section gained a Flow index row (Process Flows chip or `I`, hover for descriptions, click to open) and a Switch levels row (the breadcrumb ▾ lists the other diagrams at that level). The Flows Keyboard section lists `I`. + +**Why:** the flow index and breadcrumb level menus shipped (`docs/spec/large-model-nav.md`); the overlay lists what a first-time viewer can do and the keys that work on each view. diff --git a/docs/spec/keyboard-nav-shortcuts.md b/docs/spec/keyboard-nav-shortcuts.md index 7ac5027..a5466b7 100644 --- a/docs/spec/keyboard-nav-shortcuts.md +++ b/docs/spec/keyboard-nav-shortcuts.md @@ -31,11 +31,12 @@ 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'` | +| `i` | open or close the flow index | `view === 'flow'` | | `/` | 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 +`contenteditable` / inside an open `.modal`). `l`, `b`, and `i` resolve to no action 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 @@ -187,3 +188,9 @@ real-browser Playwright check, per the project's "test the actual runtime" lesso **Why:** user feedback on the live branch — the original 5px step felt too slow for practical scrolling. **Superseded:** the "Arrow-key canvas panning" entry above introduced the feature at 5px/25px; that entry is left as-is as the historical record of that commit. + +### 2026-09-18 — `i` opens the flow index + +**What changed:** `resolveShortcut` returns `{ type: 'flowIndex' }` for bare `i` on the flow view and `null` on Graph and Dictionary; it resolves in the ordinary bare-key switch, after both guards, like `l` and `b`. `useKeyboardShortcuts` carries an `onFlowIndex` callback; the shell routes it to `FlowsViewHandle.toggleIndex()`. `test-shortcuts.ts` T30 covers the flow-only binding, capslock, the editable guard, and every modifier. + +**Why:** the flow index (`docs/spec/large-model-nav.md`) is the way into a model with many flows; a key keeps it one stroke away. diff --git a/docs/spec/large-model-nav.md b/docs/spec/large-model-nav.md new file mode 100644 index 0000000..b0feece --- /dev/null +++ b/docs/spec/large-model-nav.md @@ -0,0 +1,217 @@ +# Large-model navigation + + +## Goal + + +On a large model, a hover focus applies only once the pointer rests on one target, a view over the animation limit jumps to its end state, and every flow is reachable from a flow index and from breadcrumb level menus that show each diagram's description. + + +## Non-goals + + +- Virtualizing, culling, or re-laying-out large diagrams. +- A validator rule that requires `description:` on a flow folder. +- Unique React keys for Dictionary process rows when two flows share a process name. +- Opening a process's ⓘ dialog from a flow index row. + + +## Success criteria + + +- [x] `src/app/logic/motion.ts` exports `HOVER_INTENT_MS = 300`, `ANIMATION_ELEMENT_LIMIT = 150` (inclusive: 150 animates, 151 does not), `animationsAllowed`, `scrollBehaviorWithin`, and `createHoverIntent` with `set`, `applyNow`, `cancel`, `applied`. +- [x] `createHoverIntent`: every change of target, including to `null`, applies after the pointer stays on it for the delay; reporting the same target again never restarts the wait; returning to the applied target before the delay applies nothing; moving A → B never applies `null` in between; `applyNow` applies at once and drops the pending target. +- [x] Flows view: node, edge, and chip hover dimming and the edge tooltip go through one hover intent; after the tooltip shows, pointer moves reposition it without re-rendering the SVG; `buildFlowData` is memoized on `diagram` and `flowDataOpts`; opening an edge's contract dialog clears the hover at once. +- [x] Graph view: hover fade, reverse-predicate labels, and Shift lineage apply when a node hover settles; Shift over a node whose hover is still waiting applies it at once with lineage; leaving the canvas clears through the same wait; a plain click clears the hover at once before opening the modal. +- [x] Dictionary browse lens: card spotlight and label reveal apply when a card hover settles; switching lens clears it at once. +- [x] A DFD whose rendered nodes plus edges exceed the limit renders nodes, edges, and chips with no opacity transition. A Dictionary whose visible entities, processes, externals, and stores exceed it marks `.dict-view` `data-motion="off"`: card fades have no transition and every scroll into the Dictionary (sidebar, body links, spotlight chips, the shell's process scroll) jumps. +- [x] A flow folder's `index.md` may carry `description:` frontmatter: `parseFlows` sets `FlowDiagram.description`; a router-only index file (no frontmatter) yields none; malformed frontmatter reports `parse.invalid_yaml`; `deriveLevels` copies it onto the flow's L1 process. +- [x] Router: a flow folder row shows the folder's description; a described row's hash folds the description in, so rewording it changes the parent digest only; an undescribed row's hash is the folder digest unchanged; `ignatius index` keeps the frontmatter. +- [x] `src/flow-view/flow-nav.ts`: `resolveDiagramPath` walks id paths exactly; `levelEntries` lists the sub-DFDs of a parent (or the roots) in dotted-number order with number, label, description, process count; `buildFlowIndex` lists every flow and process in number order with a unique key and the exact path each opens; the derived Context and System levels are not rows, so the flows are the top level. Description fallback: process `description:`, then sub-DFD folder description, then (the whole-system process, and roots) the model `description:`. +- [x] Landing and derived levels: with no `dfd=`, the Flows view opens on the System overview (`defaultDiagramPath`: the Context root's System child; an unleveled tree's first root). The derived Context and System diagrams get no crumb, and a derived diagram has no Back button. A house (Home) button between the Process Flows chip and the first crumb opens the overview from any depth and is marked current (`aria-current="page"`) while the overview is on screen. The overview is the landing and the Back target from a top-level flow; the Context diagram opens only by `dfd=__context__`. +- [x] Breadcrumbs: the root chip reads "☰ Process Flows" and toggles the flow index; each crumb whose level has two or more diagrams shows a ▾ (a flow crumb's menu is headed "Process flows") that opens `LevelMenu` (heading, count, filter above 8 entries, ↑ ↓ Enter Esc, outside click closes, current entry marked); an ancestor crumb's label still drills up; picking an entry navigates by id path. +- [x] Flow index (`FlowIndex`): an SSADM process-hierarchy chart of pills with connectors and a side pane showing the hovered or focused row's description (else the current diagram's, else the model's name and description); a row opens its own sub-DFD, or the diagram that contains it; opening centres the current row; Esc, ✕, the root chip, and `i` close it; a pick closes it. +- [x] `i` on the Flows view resolves to `{ type: 'flowIndex' }` (not on Graph or Dictionary, not while typing, not with a modifier) and toggles the index. The Flows help overlay lists the index, level switching, and `I`. +- [x] The DFD nav card is removed; the flow minimap sits at `left: 16px` like the Graph's. +- [x] Deep links: `dfd=` carries `diagramRef` of the rendered diagram, its id path below the derived Context and System levels joined with `/` (`invoicing/Submit-PCI`; a top-level flow is its bare id, a derived diagram its own id), with `/` left unencoded. Reload, Back/Forward, a live-reload rebuild, and a `flowview=`/`collapse=` toggle resolve it through `findDiagramByRef`, which returns the first path in tree order whose trailing ids match, so a bare-id link resolves as before. The `__IGNATIUS_ACTIVE_FLOW_DFD__` test hook stays the bare diagram id. +- [x] Checks: `test-motion.ts`, `test-flow-diagram-description.ts`, `test-flow-nav.ts`, `test-hash-router.ts`, `test-shortcuts.ts` T30, and the Playwright `test-large-model-nav.ts` (menus by pointer and keyboard, index, URL path across a reload, hover delay, dialog clears hover, animation cutoff); `test-graph-inherited-edges.ts` waits for the hover to settle and covers Shift inside the wait and a click clearing the hover. + + +## Approach + + +One hover-intent helper feeds every hover site, one element-count limit gates animation, and one navigation model built from the leveled tree drives the flow index and the breadcrumb level menus. See `docs/design/large-model-nav.md`. + + +## Change tree + + +``` +src/app/logic/ +├── motion.ts ............... A (hover intent, animation limit, scroll behavior) +└── shortcuts.ts ............ M (i → flowIndex) +src/app/hooks/useKeyboardShortcuts.ts .. M (onFlowIndex) +src/app/App.tsx ............. M (i → FlowsView.toggleIndex; scrollBehaviorWithin) +src/app/hash-router.ts ...... M (dfd= keeps '/' readable) +src/app/components/ +├── entity/SpotlightOverlay.tsx M (scroll behavior) +└── ui/HelpModal.tsx ........ M (index, level switching, I) +src/app/views/ +├── dict/DictionaryView.tsx . M (card hover intent, data-motion, scroll behavior) +├── flow/FlowsView.tsx ...... M (selectDiagramPath, toggleIndex, model meta to chrome) +└── graph/GraphView.tsx ..... M (node hover intent, showHover) +src/app/styles.css .......... M (crumbs, level menu, index, data-motion) +src/flow-view/ +├── FlowChrome.tsx .......... M (split crumbs, index button; nav card removed) +├── FlowDiagramSvg.tsx ...... M (hover intent, tooltip, memo, animate) +├── FlowIndex.tsx ........... A +├── LevelMenu.tsx ........... A +└── flow-nav.ts ............. A +src/flows/ +├── flow-parse.ts ........... M (FlowDiagram.description) +└── flow-derive-levels.ts ... M (L1 process description) +src/router/build.ts ......... M (folder row description + hash) +test/checks/ +├── test-motion.ts .......... A +├── test-flow-diagram-description.ts A +├── test-flow-nav.ts ........ A +├── test-large-model-nav.ts . A +├── test-shortcuts.ts ....... M (T30) +├── test-hash-router.ts ..... M (dfd path round-trip) +└── test-graph-inherited-edges.ts M (settle waits) +test/visual/ +├── screenshot-large-model-nav.ts A (menu, index, hover delay captures) +└── (hovering scripts) ....... M (settle waits after hovers) +models/llm-memory-db-mssql/ M (flow index.md descriptions; routers regenerated) +docs/guides/flows.md ........ M (flow index, level menus, flow descriptions) +docs/guides/folder-format.md M (flow folder index.md description) +docs/guides/commands.md ..... M (i key) +docs/spec/keyboard-nav-shortcuts.md M (change log: i) +docs/spec/help-overlay.md ... M (change log: index rows, I) +docs/spec/model-index-routing.md M (SC7: folder row description) +docs/spec/process-flows.md .. M (top-level navigation) +docs/spec/graph-flow-search.md M (SC12: no nav card) +docs/spec/dfd-overhaul.md ... M (C9: derived levels have no crumb) +docs/wiki/feature-map.md .... M (row) +skills/ignatius-modeling/ ... M (flow folder description) +``` + + +## Outline + + +``` +src/app/logic/motion.ts + HOVER_INTENT_MS — rest time before a hover applies + ANIMATION_ELEMENT_LIMIT — element count above which a view stops animating + animationsAllowed — count against the limit + scrollBehaviorWithin — 'auto' inside a data-motion="off" root, else 'smooth' + createHoverIntent — settle-then-apply target tracker + set — report the target under the pointer + applyNow — apply at once, drop pending + cancel — drop pending + applied — last applied target + +src/flow-view/flow-nav.ts + resolveDiagramPath — id path to diagrams, null on any miss + levelEntries — diagrams one level below a parent, or the roots + buildFlowIndex — root and process hierarchy with paths + defaultDiagramPath — the diagram the Flows view opens on + diagramRef — a diagram's dfd= reference + findDiagramByRef — reference to diagram path, trailing-id match + FlowLevelEntry, FlowIndexNode — navigation records + +src/flow-view/FlowIndex.tsx + FlowIndex — hierarchy chart + description pane + +src/flow-view/LevelMenu.tsx + LevelMenu — a crumb's level dropdown + +src/flow-view/FlowChrome.tsx + FlowChrome — index button, Home button, crumbs with ▾ menus, index, minimap + toggleIndex — handle method for the i key + +src/flow-view/FlowDiagramSvg.tsx + tooltipPlacement — viewport-clamped tooltip position + FlowDiagramSvg — hover intent, direct tooltip moves, memoized flow data, animate flag + +src/app/views/flow/FlowsView.tsx + initFlowGraphCore + selectDiagramById — navigate by dfd= reference + selectDiagramPath — navigate by exact id path + stackFor — crumb labels for a diagram path + showPath — rebuild the crumb stack and render + +src/app/hash-router.ts + serializeHash — dfd= keeps '/' readable + FlowsViewHandle.toggleIndex — keyboard entry + +src/app/views/graph/GraphView.tsx + showHover — render a settled node hover or its absence + showPredicates — forward/reverse predicate labels on a node's edges + +src/flows/flow-parse.ts + readDiagramDescription — index file frontmatter description + FlowDiagram.description + +src/router/build.ts + buildFlowFolder — folder row description and hash +``` + + +## Flows + + +**Flow: resting on a DFD node** + +1. pointer enters a node; `hoverIntent.set('node:')` starts a 300 ms wait, nothing repaints +2. pointer moves on to another node before 300 ms; the wait restarts for that node +3. pointer rests 300 ms; `setHover` dims everything outside the node's edges, with transitions only when the diagram is at or under 150 elements +4. pointer leaves to empty canvas and rests 300 ms; the dim clears + +**Flow: switching flows from a breadcrumb** + +1. user on `9.2 Submit PCI` clicks the ▾ on `9 Invoicing` +2. `LevelMenu` lists the 29 flows under `0 System` with number, description, process count, `9 Invoicing` marked +3. user types to filter and picks `22 Scope Pricing` +4. `onSelectPath([Context, System, scope-pricing])` → `selectDiagramPath` rebuilds the stack and renders + +**Flow: opening a diagram from the index** + +1. user clicks "☰ Process Flows" or presses `i` +2. `FlowIndex` opens scrolled to the current diagram's row +3. hovering a row shows its description in the pane +4. clicking a row calls `selectDiagramPath` with that row's path and closes the index + +**Flow: reloading a deep-linked sub-diagram** + +1. rendering `beta/Submit` writes `#view=flow&dfd=beta/Submit` (`diagramRef`) +2. user reloads; `FlowsView` seeds its start reference from the hash +3. `findDiagramByRef` matches the trailing ids `beta`, `Submit` and returns `Context / System / beta / Submit`, not the same-named `alpha/Submit` + + +## Checkpoints + + +| # | Checkpoint | Files/areas | Agent | Est. files | Verifies | +|---|------------|-------------|-------|------------|----------| +| 1 | Flow folder description through parse, leveling, router | `src/flows/`, `src/router/build.ts` | atomic-implementer (mode: feature) | 4 | `test-flow-diagram-description.ts`, router/leveling checks | +| 2 | Hover intent + animation limit on Flows, Graph, Dictionary | `src/app/logic/motion.ts`, `FlowDiagramSvg.tsx`, `GraphView.tsx`, `DictionaryView.tsx`, `styles.css` | atomic-implementer (mode: feature) | 8 | `test-motion.ts`, hover browser checks | +| 3 | Navigation model, flow index, level menus, `i` | `src/flow-view/`, `FlowsView.tsx`, `shortcuts.ts`, `HelpModal.tsx` | atomic-implementer (mode: feature) | 10 | `test-flow-nav.ts`, T30, `test-large-model-nav.ts` | +| 4 | Docs, skill, feature map | `docs/`, `skills/ignatius-modeling/` | atomic-implementer (mode: feature) | 9 | surfaces agree | + + +## Risks + + +| Risk | Likelihood | Mitigation | +|------|-----------|-----------| +| A check or script reads hover state immediately and sees the pre-hover view | high | Browser checks wait `HOVER_INTENT_MS + 100`; `test/visual` scripts swept for the same wait | +| Authors edit `flows//index.md` while `ignatius index` also writes it | med | The router rewrites only `` regions; `test-flow-diagram-description.ts` asserts the frontmatter survives a write | +| Existing models go `index.stale` after upgrade | low | A folder row's hash changes only when the folder has a description | +| Two flows share a process file name | med | Index and menus navigate by id path; `test-flow-nav.ts` and the browser check cover the collision | + + +## Change log + + diff --git a/docs/spec/model-index-routing.md b/docs/spec/model-index-routing.md index 9fd431c..77ccdc2 100644 --- a/docs/spec/model-index-routing.md +++ b/docs/spec/model-index-routing.md @@ -37,7 +37,7 @@ Per-folder routers, in-folder agent guidance, XML managed regions, per `docs/des - SC5a — **Routers mirror the filesystem, never the declared groups.** A router lists what its own directory actually contains: subdirectory rows and entity rows for the files in that directory. Entity file paths come from the parser's real discovered path, never reconstructed from `group:` plus entity id. A model whose entities sit flat in `data/` gets one `data/index.md` listing them directly; a model that nests gets a router per level. `group:` is a declarative classification that need not match any folder name, so the two must not be conflated. A model must never crash the generator because its layout does not follow the group-as-folder convention. - SC6 — Rows for child folders carry `Kind: folder`; rows for leaves carry the entity classification (`Independent`/`Dependent`/`Subtype`/`Associative`/`Classifier`) or the node kind (`process`/`external`/`store`). Classification is read from the parsed model, never re-derived in the generator. -- SC7 — Every row carries the SHA-256 of its target. A folder's `digest` attribute hashes its row hashes. A parent's row for a child folder carries that child's digest, never the child's rows. Editing one entity file changes exactly the digests on its ancestor path and no others. +- SC7 — Every row carries the SHA-256 of its target. A folder's `digest` attribute hashes its row hashes. A parent's row for a child folder carries that child's digest, never the child's rows. Editing one entity file changes exactly the digests on its ancestor path and no others. A flow or sub-DFD folder row's Description is the `description:` frontmatter of that folder's own index file; when present, the row's hash is `folderDigest([childDigest, description])`, so rewording it changes the parent's digest, and when absent the row carries the child digest unchanged. - SC8 — Writing is region-scoped: a second `ignatius index` run over an unchanged model produces a byte-identical tree (idempotent). Bytes outside every `` region survive a run verbatim, including a hand-authored `` block, prose, and headings. A file with no region gets one appended; a missing file is created. - SC9 — `ignatius validate --index` recomputes digests, writes nothing, and reports drift as `index.stale` through the existing `formatFindingsForStderr` pipeline. A plain `ignatius validate` performs no hashing. `index.orphaned` warns when a router file left behind by an `index_file` change is still on disk. - SC10 — `ignatius index --agents ` writes routers **and**, into the model root only, `AGENTS.md` (canonical guide), `SKILL.md` (with generated YAML frontmatter carrying `name` and `description`), and, when the harness resolves to Claude, a `CLAUDE.md` whose body is an `@AGENTS.md` import plus Claude-specific lines. `harness: auto` resolves to Claude when a `.claude/` directory or a `CLAUDE.md` exists at or above the model root. @@ -268,3 +268,9 @@ Built across 7 checkpoints of the /autopilot subagent loop, a post-loop pass fro - `ignatius index` reports an unreadable target only through `validate --index`, not from the index verb itself. - Symlinks into `.claude/skills/` are verified only for a relative link within one repo; an absolute link to a model outside the repo is untested. - `key-inherited`, `orm-pure`, and `orm-hybrid` are indexed without descriptions or guidance files, so they show structure but not payload. + +### 2026-09-18 — Flow folder rows carry the folder's description + +**What changed:** SC7 gained its last sentence. `parseFlows` reads `description:` from a flow folder's index file frontmatter into `FlowDiagram.description`, and `buildFlowFolder` writes it into the parent router's folder row, folding it into that row's hash only when present. `test-flow-diagram-description.ts` covers the row text, frontmatter surviving a router write, and a description edit dirtying the parent digest and no other. + +**Why:** a flow folder row had no description to show, and the app's flow index and breadcrumb menus (`docs/spec/large-model-nav.md`) need one per flow. The index file is the one per-folder file that already exists; the hash fold keeps `validate --index` able to see a reworded description without making every existing model stale on upgrade. diff --git a/docs/spec/process-flows.md b/docs/spec/process-flows.md index aac2566..2079931 100644 --- a/docs/spec/process-flows.md +++ b/docs/spec/process-flows.md @@ -324,7 +324,7 @@ Extension points: mode dispatch at `src/App.tsx:1067–1119`, elements construct - On startup, read `window.__IGNATIUS_SURFACE__`. When `=== 'flow'`, call `initFlowGraph`; otherwise call the existing ERD path unchanged. `src/index.html` carries `window.__IGNATIUS_SURFACE__ = 'erd'` as a default alongside the existing `window.__IGNATIUS_MODE__ = 'live'` so the live ERD reads a defined surface. The live `/flow` route (path-free — no DFD name) sets `__IGNATIUS_SURFACE__ = 'flow'` in the HTML it returns so the surface is defined before the bundle executes and the `/api/flow` fetch has the correct surface context. - `initFlowGraph` — flow Cytoscape setup is isolated in this extracted function, not interleaved with the existing ERD `useEffect`. **Static mode:** reads `window.__FLOW_MODEL__` (the array of all top-level DFDs). **Live mode:** fetches `/api/flow` once, then re-fetches on every SSE `model-changed` event and re-renders the current DFD in place (the watcher already covers `flows/**`). The surface dispatch reads `window.__IGNATIUS_SURFACE__ === 'flow'` as before. -- **Top-level DFD navigation (the consistency rework).** A model has many DFDs. When more than one top-level diagram is present, `initFlowGraph` renders a DFD selector (a list/index affordance — e.g. the breadcrumb root or a FAB menu) and renders one diagram at a time; choosing another swaps the rendered diagram, **reusing the same client-side `renderDiagram` swap the drill-down already uses**. A single-DFD model renders that one directly with no picker. Selecting a DFD is navigation, exactly as selecting an entity is in the ERD — there is no DFD argument upstream of the viewer. +- **Top-level DFD navigation (the consistency rework).** A model has many DFDs. Every diagram is reachable from the flow index that the breadcrumb root chip opens, and the breadcrumb level menus switch between diagrams at one level (`docs/spec/large-model-nav.md`); one diagram renders at a time, and choosing another swaps the rendered diagram, **reusing the same client-side `renderDiagram` swap the drill-down already uses**. Selecting a DFD is navigation, exactly as selecting an entity is in the ERD — there is no DFD argument upstream of the viewer. - Flow elements construction (inside `initFlowGraph`): map `FlowProcess` → Cytoscape nodes (label carries the composed `dottedNumber` badge); `FlowExternal` → Cytoscape nodes; `db:` store refs → Cytoscape nodes; non-db store refs → Cytoscape nodes; `FlowEdge` → directed Cytoscape edges with the flow label or column list as edge label. - **The flow viewer is a purpose-built DFD render, not the ERD harness reskinned. The visual target is the approved design mock `tmp/mock-e.html` — match it.** Flow styles live in a dedicated flow stylesheet builder, separate from the ERD `buildStyles`; ERD render code is untouched. - **Gane-Sarson notation (supersedes the barrel/cut-rectangle approximations):** process = numbered rounded-rect hub; external = green rectangle; data store = **open-ended rectangle** (left cap-bar with `D#` + name, open right edge) rendered via a custom SVG (e.g. Cytoscape `background-image` data-URI per node kind), NOT a built-in `barrel`. **All data flows render as a single uniform solid arrow** — read vs write is conveyed by arrow direction (store→process reads, process→store writes), per canonical SSADM/Gane-Sarson notation, NOT by line style or colour. Flow labels carry the **data** (column list / data-packet noun), never events or predicates. @@ -466,6 +466,14 @@ The fixtures use entity ids from `models/key-inherited/` as their `db:` store re ## Change log +### 2026-09-18 — Top-level navigation is the flow index and breadcrumb level menus + +**What changed:** the top-level DFD navigation bullet now names the flow index (opened from the breadcrumb root chip) and the breadcrumb level menus as the way to reach and switch diagrams (`docs/spec/large-model-nav.md`), and drops the single-DFD "no picker" sentence: the index is available whatever the diagram count. + +**Why:** leveling wraps every flow in one `Context` root, so the selector conditioned on "more than one top-level diagram" never rendered and a many-flow model was reachable only by drilling down. + +**Superseded:** a DFD selector rendered only when more than one top-level diagram was present. + ### 2026-06-17 — Folder model migration (#16): externals/ and stores/ at model root; no per-DFD override **What changed:** External and store registry locations changed to model-root `externals/` and `stores/` (no underscore, no per-DFD nesting). The per-DFD `_externals/` override capability is removed — there is one global definition per external name. `parseFlows` reads `externals/*.md` and `stores/*.md` once from the model root. Resolved decisions, success criteria, `parseFlows` description, frontmatter contracts, validator rule descriptions, node-doc dialog description, and fixture layout all updated. diff --git a/docs/wiki/cli.md b/docs/wiki/cli.md index ccf4f4d..28f6a17 100644 --- a/docs/wiki/cli.md +++ b/docs/wiki/cli.md @@ -8,7 +8,7 @@ tags: [cli, dispatch, model-resolution] ## What it does -[`src/cli/cli.ts`](../../src/cli/cli.ts) is the single entry point a user or CI job runs: the compiled `dist/ignatius` binary, or `bun src/cli/cli.ts` in a dev checkout. `cli.ts` registers nine subcommand definitions (`serve`/`server` share one, so `server` is an alias, not a tenth). Every other domain in this repo (server, parser, validate, flows, generators, router) is reached only through one of those nine — there is no other production caller of `serveCommand` or `generateApp`. `parseModels`, though, is also called directly by [`src/server/server.ts`](../../src/server/server.ts), and `buildRouters` also directly by `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts) (see Coupling). Two responsibilities exist outside dispatch: finding which directory on disk is "the model" when the user did not say, and keeping the installed binary current against GitHub Releases. +[`src/cli/cli.ts`](../../src/cli/cli.ts) is the single entry point a user or CI job runs: the compiled `dist/ignatius` binary, or `bun src/cli/cli.ts` in a dev checkout. `cli.ts` registers nine subcommand definitions (`serve`/`server` share one, so `server` is an alias, not a tenth). Every other domain in this repo (server, parser, validate, flows, generators, router) is reached only through one of those nine — there is no other production caller of `serveCommand` or `generateApp`. `parseModels`, though, is also called directly by [`src/server/server.ts`](../../src/server/server.ts), and `buildRouters` also directly by `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts) (see Coupling). Two responsibilities exist outside dispatch: finding which directory on disk is "the model" when the user did not say, and keeping the installed binary current against GitHub Releases, streaming the download with a live progress line rather than buffering it. ## How it works @@ -74,7 +74,62 @@ flowchart TD F -->|no| H[done] ``` -Two other subsystems sit off this pipeline. `serveWithPortFallback` ([`src/cli/serve-port.ts`](../../src/cli/serve-port.ts)) wraps `serveCommand`: on `EADDRINUSE` a non-TTY process silently advances to `port + 1` and retries the real bind, while a TTY prompts via `@clack/prompts` `text`, defaulting to the next free port `findAvailablePort` locates by binding and immediately releasing a throwaway `Bun.serve`. `update.ts` drives `ignatius update` against GitHub Releases: `checkForUpdate()` resolves the latest tag from the `releases/latest` redirect `Location` header (no API token needed), and on a dev runtime (`process.execPath` basename is `bun`/`node`) it reports a git-update hint instead of attempting a self-replace, since there is no standalone binary to overwrite. +Two other subsystems sit off this pipeline. `serveWithPortFallback` ([`src/cli/serve-port.ts`](../../src/cli/serve-port.ts)) wraps `serveCommand`: on `EADDRINUSE` a non-TTY process silently advances to `port + 1` and retries the real bind, while a TTY prompts via `@clack/prompts` `text`, defaulting to the next free port `findAvailablePort` locates by binding and immediately releasing a throwaway `Bun.serve`. `update.ts` drives `ignatius update` against GitHub Releases: `checkForUpdate()` resolves the latest tag from the `releases/latest` redirect `Location` header (no API token needed). Its decision path, download mechanism, and progress renderer are each their own shape, below. + +### Self-update decision path + +`runUpdateCommand` exits 0 at every disqualifying condition; `checkForUpdate` and `downloadAndReplace` are the only two calls that can exit 1: + +```mermaid +flowchart TD + A[checkForUpdate] -->|throws| H[exit 1] + A --> B{outdated?} + B -->|no| N[exit 0] + B -->|yes| C{"--check?"} + C -->|yes| N + C -->|no| D{"binary present and not win32?"} + D -->|no| N + D -->|yes| E{confirmed?} + E -->|no| N + E -->|yes| F[downloadAndReplace] + F -->|ok| G[exit 0] + F -->|throws| H +``` + +"Binary present" is `runningBinaryPath()` returning non-null (`process.execPath`'s basename is not `bun`/`node`); "confirmed" is `--yes`, or a TTY `@clack/prompts` `confirm` answered yes. A `win32` target always stops at the disqualifying branch: `downloadAndReplace` never runs there, and the command prints a manual-download message instead. + +### Streaming download and checksum verification + +`downloadAndReplace` streams the release asset straight to a staging file instead of buffering it, hashing each chunk as it writes so the file is never read a second time: + +```mermaid +flowchart TD + A["fetch asset, read response.body"] --> B["write chunk to sink, hasher.update(chunk)"] + B -->|"every 512KB"| B + B -->|stream done| C["sink.end(); final onProgress; hasher.digest('hex')"] + C --> D["fetch checksums.txt (best-effort)"] + D --> E{sha256 match?} + E -->|mismatch| F["unlinkSync(tmp); throw"] + E -->|match or unreachable| G["chmodSync 0o755; renameSync(tmp, target)"] + G -->|rename fails| F +``` + +A genuine checksum mismatch aborts and deletes the staged file; an unreachable `checksums.txt` does not block the install, so verification is best-effort by design, not by accident. The staging file is named `.{basename(target)}.update-{pid}` next to `target` so the final `renameSync` is same-filesystem and atomic; overwriting the running binary this way is safe on Unix because the live process keeps its old inode until it exits. Each of those three throw paths (chunk-read failure, checksum mismatch, rename failure) unlinks the staging file first. The `chmodSync(tmp, 0o755)` call between checksum verification and rename sits outside any try/catch, though: if it throws (e.g. a read-only staging directory), the error propagates without unlinking `tmp`, leaving the staged file behind. + +### Progress renderer state + +`downloadProgressRenderer` is a pure `\r`-rewriting status line that latches shut once the download completes, so a stray final tick can never reprint the 100% line: + +```mermaid +stateDiagram-v2 + [*] --> Streaming: isTTY + [*] --> [*]: off-TTY, returns null + Streaming --> Streaming: received < total, or total unknown
writes \r-line, no newline + Streaming --> Done: received >= total
writes \r-line + final newline + Done --> Done: any later call
writes nothing +``` + +`downloadAndReplace` calls `onProgress` once every `PROGRESS_EMIT_BYTES` (512KB) while streaming, then once more after the loop with `total || received`, which forces a positive total on its final call even when the response carried no `Content-Length`, guaranteeing every download reaches `Done`. Each status line is `padEnd`-ed to `STATUS_LINE_WIDTH` (40 characters) before the `\r` rewrite, so a shorter line fully overwrites a longer one instead of leaving a stray tail on screen. ## Where it lives @@ -86,8 +141,9 @@ Two other subsystems sit off this pipeline. `serveWithPortFallback` ([`src/cli/s | [`src/cli/serve-port.ts`](../../src/cli/serve-port.ts) | `serveWithPortFallback`, `findAvailablePort`, `isAddrInUse` — port-conflict recovery for `serve` | | [`src/cli/open-browser.ts`](../../src/cli/open-browser.ts) | `browserOpenCommand(platform, url)` (pure) and `openBrowser()` (fire-and-forget `Bun.spawn`); dynamically imported by `cli.ts` only when `--open` is passed | | [`src/cli/version.ts`](../../src/cli/version.ts) | `VERSION`, a JSON import of [`package.json`](../../package.json) that Bun inlines at `bun build --compile` time | -| [`src/cli/update.ts`](../../src/cli/update.ts) | `runUpdateCommand`, plus separately-tested pure helpers `parseVersion`, `compareVersions`, `parseTagFromLocation`, `assetForPlatform`, `parseChecksums` | +| [`src/cli/update.ts`](../../src/cli/update.ts) | `runUpdateCommand`, `checkForUpdate`; pure helpers `parseVersion`, `compareVersions`, `parseTagFromLocation`, `assetForPlatform`, `parseChecksums`, `downloadProgressRenderer`; the `ProgressCallback`/`UpdateCheck`/`UpdateOptions` types; internal (unexported) `downloadAndReplace` and `runningBinaryPath` | | [`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md), [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) | Design/spec pair for the CLI and its output modes | +| [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | Contract for `update.ts`'s streamed download and progress renderer | ## Constraints @@ -105,11 +161,14 @@ Other constraints observed in the source: - `dict`, `graph`, and `flow` stay registered as citty subcommands (rather than being removed outright) purely so they can print a redirect message to `export` — deleting them would surface citty's generic "unknown command" error instead. - `@clack/prompts` is imported dynamically, only inside the three files that need a TTY prompt (`resolve-model.ts`, `serve-port.ts`, `update.ts`); importing it eagerly in `cli.ts` would risk its TTY-gated prompts firing inside a spawned, non-interactive process, such as a CI job invoking the compiled binary with no attached terminal. - `update.ts`'s checksum verification is best-effort: a genuine sha256 mismatch against `checksums.txt` aborts the update, but an unreachable `checksums.txt` does not block it. -- The compiled binary or `bun src/cli/cli.ts` is the only production entry point; four [`test/checks/`](../../test/checks) files (`test-discover.ts`, `test-serve-port.ts`, `test-open-browser.ts`, `test-update-helpers.ts`) import individual [`src/cli/`](../../src/cli) modules directly for unit testing, so a signature change to any of those exports breaks a check even when `cli.ts`'s own dispatch logic hasn't changed. +- On a permission-denied write to `target`, matched by regex against the error message (`EACCES`/`EPERM`/`EROFS`, or the words "permission"/"denied"), `runUpdateCommand` prints a `sudo ignatius update --yes` suggestion and a `curl … install.sh` reinstall fallback instead of the generic "update failed" message. +- The compiled binary or `bun src/cli/cli.ts` is the only production entry point; five [`test/checks/`](../../test/checks) files (`test-discover.ts`, `test-serve-port.ts`, `test-open-browser.ts`, `test-update-helpers.ts`, `test-update-progress.ts`) import individual [`src/cli/`](../../src/cli) modules directly for unit testing, so a signature change to any of those exports breaks a check even when `cli.ts`'s own dispatch logic hasn't changed. `test-update-progress.ts` covers `downloadProgressRenderer` only (off-TTY null, mid-stream no-newline, 100%-latch, unknown-total); the network and self-replace paths in `update.ts` are exercised manually against a real release, not by a check. ## Coupling - `cli.ts` calls into **server** (`serveCommand`, reached through `serve-port.ts`), **parser** (`parseModels` in [`src/model/parse.ts`](../../src/model/parse.ts)), **validate** (`validateModel`, `formatFindingsForStderr`, `RULES`, `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts)), **flows** (`parseFlows` in [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts), `validateFlows` in [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts)), and **generators** (`loadEmbeddedBundle` in [`src/generators/embedded-bundle.ts`](../../src/generators/embedded-bundle.ts), `generateApp` in [`src/generators/app.ts`](../../src/generators/app.ts)) — a signature change in any of those exports forces a change in `cli.ts`. - `cli.ts`'s `index` subcommand additionally calls into **router**, the new domain added by this range: `buildRouters` and `writeRouters` ([`src/router/build.ts`](../../src/router/build.ts), [`src/router/write.ts`](../../src/router/write.ts)), and — only under `--agents` — `resolveHarness` ([`src/router/detect.ts`](../../src/router/detect.ts)) and `writeGuidance` ([`src/router/agents.ts`](../../src/router/agents.ts)). `validate --index` reaches the same router domain indirectly, through `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts), which dynamically imports `buildRouters` itself. - `validate`, `index`, and `export` all read `RULES[ruleId].class` from **validate** to decide their exit code — renaming or restructuring the rule-class scheme in [`src/model/validate.ts`](../../src/model/validate.ts) breaks all three subcommands' exit-code logic. +- `update.ts` talks only to GitHub Releases (`github.com/noormdev/ignatius`) and the local filesystem; it has no dependency on any other domain in this repo, and no other domain calls into it outside `cli.ts`'s `update` subcommand. - The **skill** domain ([`skills/ignatius-modeling/`](../../skills/ignatius-modeling)) drives this domain from outside dispatch: `references/verification.md` shells out to `ignatius validate`, `ignatius validate --index`, `ignatius index`, and `ignatius index --agents` as its own write-verification step, coupling the skill's authoring loop to this domain's stderr format and exit codes. + diff --git a/docs/wiki/docs.md b/docs/wiki/docs.md index 92953b5..a14b6ae 100644 --- a/docs/wiki/docs.md +++ b/docs/wiki/docs.md @@ -10,7 +10,7 @@ tags: [design, spec, guide] ## What it does -[`docs/`](..) (excluding the generated [`docs/wiki/`](.)) is ignatius's documentation corpus, split into four directories that each answer a different question about a feature: why it exists, what its contract is, how to drive it, and what background research shaped it. A session that needs one of those answers goes to the directory that owns it, rather than searching the whole tree. [`docs/design/`](../design) states why a feature exists and which approach was chosen over its alternatives. [`docs/spec/`](../spec) is the implementation contract derived from a design: checkpoints, success criteria, and (for three specs so far) a change-tree/outline/flows triad. [`docs/guides/`](../guides) teaches a user how to drive the built feature. [`docs/research/`](../research) records background investigation that fed a design's option table. None of these files execute; every other domain's code and tests point back at them by name for the "why is it built this way" and "what is the contract" questions code alone can't answer. The corpus totals 78 markdown files plus [`docs/glossary.md`](../glossary.md): 30 in [`docs/design/`](../design), 36 in [`docs/spec/`](../spec), 10 in [`docs/guides/`](../guides), 2 in [`docs/research/`](../research). +[`docs/`](..) (excluding the generated [`docs/wiki/`](.)) is ignatius's documentation corpus, split into four directories that each answer a different question about a feature: why it exists, what its contract is, how to drive it, and what background research shaped it. A session that needs one of those answers goes to the directory that owns it, rather than searching the whole tree. [`docs/design/`](../design) states why a feature exists and which approach was chosen over its alternatives. [`docs/spec/`](../spec) is the implementation contract derived from a design: checkpoints, success criteria, and (for four specs so far) a change-tree/outline/flows triad. [`docs/guides/`](../guides) teaches a user how to drive the built feature. [`docs/research/`](../research) records background investigation that fed a design's option table. None of these files execute; every other domain's code and tests point back at them by name for the "why is it built this way" and "what is the contract" questions code alone can't answer. The corpus totals 81 markdown files plus [`docs/glossary.md`](../glossary.md): 31 in [`docs/design/`](../design), 38 in [`docs/spec/`](../spec), 10 in [`docs/guides/`](../guides), 2 in [`docs/research/`](../research). [`README.md`](../../README.md) states the design/spec relationship directly: "Conceptual designs live in [`docs/design/`](../design); the implementation contracts derived from them live in [`docs/spec/`](../spec)." @@ -31,7 +31,7 @@ flowchart LR D -.tracked in.-> E ``` -A design doc that never gets a spec (`markdown-driven-erd.md`) or a spec with no design doc (seven of them, see Where it lives) are both valid end states; `feature-map.md` records the actual per-feature surface set rather than assuming every feature has all four. +A design doc that never gets a spec (`markdown-driven-erd.md`) or a spec with no design doc (eight of them, see Where it lives) are both valid end states; `feature-map.md` records the actual per-feature surface set rather than assuming every feature has all four. ### A spec's body describes only current truth; correction and rename are explicit states @@ -48,19 +48,19 @@ stateDiagram-v2 ### The change-tree / outline / flows triad is opt-in by spec age, not by feature size -[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md), [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md), and [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) are the only three of 36 specs carrying `## Change tree`, `## Outline`, and `## Flows` sections; the other 33 predate the rule that requires them and are not backfilled by an unrelated amendment. 29 of 36 specs also carry a `## Implementation log` (narrative build history: checkpoints landed, out-of-scope work performed, unforeseens, deferred items) — a section distinct from `## Change log`, which records contract amendments, not build narrative. +[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md), [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md), [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md), and [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) are the only four of 38 specs carrying `## Change tree`, `## Outline`, and `## Flows` sections; the other 34 predate the rule that requires them and are not backfilled by an unrelated amendment. 29 of 38 specs also carry a `## Implementation log` (narrative build history: checkpoints landed, out-of-scope work performed, unforeseens, deferred items) — a section distinct from `## Change log`, which records contract amendments, not build narrative. ## Where it lives -### [`docs/design/`](../design) — conceptual/approach docs (30 files) +### [`docs/design/`](../design) — conceptual/approach docs (31 files) | Path | Lines | Covers | |------|-------|--------| | [`docs/design/model-index-routing.md`](../design/model-index-routing.md) | 476 | Per-folder generated routers (`index.md`), rolled-up SHA digests, `` managed regions, `index_file:`/`harness:` config, in-folder `AGENTS.md`/[`CLAUDE.md`](../../CLAUDE.md)/`SKILL.md` agent guidance | | [`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) | 333 | Canonical source for the markdown-driven entity file format; no [`docs/spec/`](../spec) counterpart | -| [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) | 299 | Per-process vs. connected DFD views, prose `label:` on any flow entry, `clusters/` author-cluster files, the `cluster:` token, subtype-family and group collapse levels, adjacency stacking, the stack/contract/subtype/group dialog rules, the stacked-paper "more inside" marker construction | +| [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) | 308 | Per-process vs. connected DFD views, prose `label:` on any flow entry, `clusters/` author-cluster files, the `cluster:` token, subtype-family and group collapse levels, adjacency stacking, the stack/contract/subtype/group dialog rules, the stacked-paper "more inside" marker construction, row-local store-kind identity and coloring inside a mixed stack | | [`docs/design/process-flows.md`](../design/process-flows.md) | 218 | SSADM DFD subsystem: processes, externals, stores, sub-DFDs | | [`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) | 205 | Schema lint + error UX | | [`docs/design/noorm-flow-discovery.md`](../design/noorm-flow-discovery.md) | 179 | `ignatius-modeling` skill's `flow` and `discover` Q&A modes | @@ -79,39 +79,41 @@ stateDiagram-v2 | [`docs/design/graph-position-persistence.md`](../design/graph-position-persistence.md) | 118 | Graph node position persistence | | [`docs/design/dict-navigation.md`](../design/dict-navigation.md) | 100 | Data-dictionary side navigation | | [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) | 100 | DFD edge-hover data reveal | +| [`docs/design/large-model-nav.md`](../design/large-model-nav.md) | 102 | Hover-intent settle-then-apply dimming, the `ANIMATION_ELEMENT_LIMIT` animation cutoff, the SSADM flow index, breadcrumb level menus, and where a flow folder's `description:` lives | | [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) | 93 | DFD viewer overhaul: Yourdon/SSADM leveling, ELK layout, 5-band partitioning | | [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) | 84 | Search on Graph and Flows views | | [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) | 75 | Arbitrary DFD nesting depth fix | | [`docs/design/bidirectional-predicates.md`](../design/bidirectional-predicates.md) | 67 | Bidirectional predicate feature | | [`docs/design/help-overlay.md`](../design/help-overlay.md) | 62 | View-aware help overlay | -| [`docs/design/dd-spotlight-grid.md`](../design/dd-spotlight-grid.md) | 60 | DD browse-lens spotlight grid | +| [`docs/design/dd-spotlight-grid.md`](../design/dd-spotlight-grid.md) | 60 | DD browse-lens spotlight grid; off-screen connection chips scroll to the target, then flash it only once the scroll settles | | [`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) | 59 | Wiki-style `[[Entity]]` body links | | [`docs/design/src-root-organization.md`](../design/src-root-organization.md) | 49 | [`src/`](../../src) top-level subdirectory split | | [`docs/design/noorm-modeling-skill.md`](../design/noorm-modeling-skill.md) | 12 | Rename stub; points to `ignatius-modeling-skill.md` | -### [`docs/spec/`](../spec) — implementation contracts (36 files) +### [`docs/spec/`](../spec) — implementation contracts (38 files) | Path | Lines | Covers | |------|-------|--------| -| [`docs/spec/process-flows.md`](../spec/process-flows.md) | 682 | Largest spec; SSADM DFD: parse, 11 `flow.*` rules, viewer, sub-DFD drill-down, `db:` store dialog | +| [`docs/spec/process-flows.md`](../spec/process-flows.md) | 690 | Largest spec; SSADM DFD: parse, 11 `flow.*` rules, viewer, sub-DFD drill-down, `db:` store dialog | | [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) | 372 | `buildInheritedConnections` key-edge connected-component algorithm, DG/DD lineage reveal | -| [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | 391 | CP1–CP8: `label:` on every flow entry + contract dialog, `flow-clusters.ts` registry + `cluster:` token expansion, five `flow.cluster_*` rules, per-process/connected view stacking, collapse levels, `StackDialog`/`EdgeContractDialog`; five `## Change log` entries plus an `## Implementation log` recording the 9-iteration shipped build and one open followup | -| [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | 270 | Router build/write, fingerprint roll-up, `index_file`/`harness` config, four `config.index_file_*`/`index.*` rules, `--agents` guidance files | +| [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | 411 | CP1–CP8: `label:` on every flow entry + contract dialog, `flow-clusters.ts` registry + `cluster:` token expansion, five `flow.cluster_*` rules, per-process/connected view stacking, collapse levels, row-local store-kind identity and kind-specific numbered caps inside a mixed stack, `StackDialog`/`EdgeContractDialog`; five `## Change log` entries plus an `## Implementation log` recording the 9-iteration shipped build and one open followup | +| [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | 276 | Router build/write, fingerprint roll-up, `index_file`/`harness` config, four `config.index_file_*`/`index.*` rules, `--agents` guidance files, a flow/sub-DFD folder row's description-folded digest | | [`docs/spec/app-tsx-decomposition.md`](../spec/app-tsx-decomposition.md) | 246 | `App.tsx` decomposition | | [`docs/spec/dd-spotlight-grid.md`](../spec/dd-spotlight-grid.md) | 239 | DD browse-lens spotlight grid | | [`docs/spec/dfd-polish-round3.md`](../spec/dfd-polish-round3.md) | 238 | CP18–23 | | [`docs/spec/render-perf-indexing.md`](../spec/render-perf-indexing.md) | 231 | Preset-layout cache-skip, ELK cost scaling, `buildModelIndex` | | [`docs/spec/unified-app.md`](../spec/unified-app.md) | 216 | Unified SPA | | [`docs/spec/ignatius-modeling-skill.md`](../spec/ignatius-modeling-skill.md) | 211 | `ignatius-modeling` skill contract | -| [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) | 199 | Graph/Flows search (SC1–SC12) | +| [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) | 207 | Graph/Flows search (SC1–SC12); SC12 no longer names a diagram nav card, since Flows has none | +| [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) | 217 | `createHoverIntent`/`HOVER_INTENT_MS`/`ANIMATION_ELEMENT_LIMIT` in `motion.ts`, flow folder `description:` through parse/leveling/router, `flow-nav.ts` navigation model, breadcrumb `LevelMenu`, the `FlowIndex` hierarchy chart, the `i` shortcut; carries a `## Change tree`/`## Outline`/`## Flows` triad | | [`docs/spec/unified-app-polish.md`](../spec/unified-app-polish.md) | 194 | CP1–CP13 unified-app-polish batch | -| [`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) | 189 | `resolveShortcut`, `useKeyboardShortcuts` | +| [`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) | 196 | `resolveShortcut`, `useKeyboardShortcuts`, the flow-only `i` binding | | [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) | 180 | viewer-ux-polish batch | | [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) | 170 | Example/sample-row instance tables | | [`docs/spec/dfd-polish-round2.md`](../spec/dfd-polish-round2.md) | 169 | CP14–17 | | [`docs/spec/dfd-polish-round4.md`](../spec/dfd-polish-round4.md) | 159 | CP24–26 | | [`docs/spec/bidirectional-predicates.md`](../spec/bidirectional-predicates.md) | 157 | Bidirectional predicates | -| [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | 155 | DFD viewer overhaul; success criteria C1–C18 | +| [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | 163 | DFD viewer overhaul; success criteria C1–C18 | | [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) | 144 | CLI output modes and theme system | | [`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) | 141 | Schema lint + error UX | | [`docs/spec/folder-model.md`](../spec/folder-model.md) | 117 | Folder-model migration | @@ -124,13 +126,14 @@ stateDiagram-v2 | [`docs/spec/noorm-flow-discovery.md`](../spec/noorm-flow-discovery.md) | 83 | `flow`/`discover` skill modes; skill-markdown-only | | [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) | 83 | DFD edge-hover data reveal | | [`docs/spec/src-root-organization.md`](../spec/src-root-organization.md) | 82 | [`src/`](../../src) directory split | +| [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | 80 | `downloadProgressRenderer` + a streamed, incrementally-hashed `downloadAndReplace` in [`src/cli/update.ts`](../../src/cli/update.ts); no design-doc counterpart | | [`docs/spec/wiki-entity-links.md`](../spec/wiki-entity-links.md) | 79 | Wiki-entity links | | [`docs/spec/derive-classification.md`](../spec/derive-classification.md) | 72 | 5-rule classification derivation; no design-doc counterpart | +| [`docs/spec/help-overlay.md`](../spec/help-overlay.md) | 70 | Help overlay; Flows body now covers the flow index and breadcrumb level switching | | [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) | 69 | DFD nesting-depth fix | -| [`docs/spec/help-overlay.md`](../spec/help-overlay.md) | 64 | Help overlay | | [`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) | 12 | Rename stub; points to `ignatius-modeling-skill.md` | -Seven specs ship without a design-doc counterpart: `dict-polish.md`, `derive-classification.md`, `render-perf-indexing.md`, `unified-app-polish.md`, `dfd-polish-round2.md`, `dfd-polish-round3.md`, `dfd-polish-round4.md`. Exactly one design doc ships without a spec: `markdown-driven-erd.md`. +Eight specs ship without a design-doc counterpart: `dict-polish.md`, `derive-classification.md`, `render-perf-indexing.md`, `unified-app-polish.md`, `dfd-polish-round2.md`, `dfd-polish-round3.md`, `dfd-polish-round4.md`, `update-download-progress.md`. Exactly one design doc ships without a spec: `markdown-driven-erd.md`. ### [`docs/guides/`](../guides) — user-facing how-to (10 files) @@ -138,9 +141,9 @@ All ten are linked from [`README.md`](../../README.md)'s docs table. Six were up | Path | Lines | Covers | |------|-------|--------| -| [`docs/guides/folder-format.md`](../guides/folder-format.md) | 275 | ★ `ignatius.yml`, the six top-level folders including `clusters/`, entity/column/relationship authoring, `index_file:`/`harness:` config, generated routers, `description:` frontmatter | -| [`docs/guides/commands.md`](../guides/commands.md) | 174 | ★ The CLI subcommands including `index`/`index --agents`, `validate --index`, and the full keyboard-shortcut table | -| [`docs/guides/flows.md`](../guides/flows.md) | 280 | ★ DFDs: processes, externals, stores, sub-DFDs, `description:` on process/external/store, plus labels/stacks/clusters/groups — the contract dialog, the closed endpoint-prefix set and its one `cluster:` exception, the per-process vs. connected view split, the stores/clusters/groups collapse level, adjacency stacking and its `ignatius.yml` switch, and the Flows FAB controls | +| [`docs/guides/folder-format.md`](../guides/folder-format.md) | 277 | ★ `ignatius.yml`, the six top-level folders including `clusters/`, entity/column/relationship authoring, `index_file:`/`harness:` config, generated routers, `description:` frontmatter, a flow folder's `description:` folding into its router row's hash | +| [`docs/guides/commands.md`](../guides/commands.md) | 176 | ★ The CLI subcommands including `index`/`index --agents`, `validate --index`, and the full keyboard-shortcut table including `i` (open or close the flow index) | +| [`docs/guides/flows.md`](../guides/flows.md) | 309 | ★ DFDs: processes, externals, stores, sub-DFDs, `description:` on process/external/store/flow folder, plus labels/stacks/clusters/groups — the contract dialog, the closed endpoint-prefix set and its one `cluster:` exception, the per-process vs. connected view split, the stores/clusters/groups collapse level, adjacency stacking and its `ignatius.yml` switch, the Flows FAB controls, the "☰ Process Flows" flow index and breadcrumb ▾ level menus, and the 300 ms hover-settle / 150-element animation cutoff | | [`docs/guides/validation.md`](../guides/validation.md) | 142 | ★ The linter, severity tiers, the Config-rules/Index-rules tables (`config.index_file_*`, `index.stale`, `index.orphaned`, `index.unreadable_target`), and the five `flow.cluster_*` rules | | [`docs/guides/getting-started.md`](../guides/getting-started.md) | 93 | ★ Install, build from source, serve the first model; command list now names `index` | | [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) | 73 | ★ The `/ignatius-modeling` skill's Q&A modes; the `flow` mode's step list now names the cluster-membership decision and the flow label alongside the `db:`-or-`kind:` store decision; verification loop now runs `ignatius validate --index` | @@ -169,7 +172,7 @@ Canonical vocabulary table: DG (Data Graph), DD (Data Dictionary), DFD (Data Flo | Constraint | Detail | |------------|--------| | Spec body is forward-only | `docs/spec/.md` must describe only the current decision; superseded content moves to a dated `## Change log` entry with a **Superseded:** line. Leaving old text in the body instead means a subagent implementing from the spec reads a contradicted or stale contract as current truth | -| Change-tree/outline/flows apply forward only | The three required sections apply to specs drafted after the rule shipped; only 3 of 36 specs (`graph-flow-search.md`, `model-index-routing.md`, `dfd-store-clusters.md`) carry them. Backfilling them onto a pre-existing spec via an unrelated amendment would bundle an unrelated structural change into that amendment's `## Change log` entry, misstating what the amendment actually changed | +| Change-tree/outline/flows apply forward only | The three required sections apply to specs drafted after the rule shipped; only 4 of 38 specs (`graph-flow-search.md`, `model-index-routing.md`, `dfd-store-clusters.md`, `large-model-nav.md`) carry them. Backfilling them onto a pre-existing spec via an unrelated amendment would bundle an unrelated structural change into that amendment's `## Change log` entry, misstating what the amendment actually changed | | The `cluster:` prefix is the one exception to the closed endpoint-prefix set | [`docs/guides/flows.md`](../guides/flows.md)'s Endpoints section states the `db:`/`ext:`/`proc:`/`cache:`/`queue:`/`file:`/`doc:`/`manual:`/`other:` prefix set is closed, then names `cluster:` as intercepted and expanded before endpoint parsing runs; [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md)'s Checkpoints table (CP8's Verifies cell) requires every closed-prefix-set passage across the guides and the `ignatius-modeling` skill references to name this one exception. A guide or skill passage that repeats the closed-set list without this exception either teaches an author that `cluster:` is unsupported, or contradicts the other doc stating the same closed set | @@ -179,12 +182,13 @@ Canonical vocabulary table: DG (Data Graph), DD (Data Dictionary), DFD (Data Flo | Docs surface | Coupled domain | Coupling | |---|---|---| | [`docs/design/model-index-routing.md`](../design/model-index-routing.md) + [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | **router** (new) | The pair is the sole source for [`src/router/`](../../src/router) (`region.ts`, `fingerprint.ts`, `build.ts`, `write.ts`, `detect.ts`, `agents.ts`); the spec's Change tree also names edits to **parser** ([`src/model/parse.ts`](../../src/model/parse.ts)), **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)), **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)), **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)), and **skill** (`skills/ignatius-modeling/**`) | +| [`docs/design/large-model-nav.md`](../design/large-model-nav.md) + [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) | **flow-view**, **frontend**, **flows**, **router** | The pair's Change tree adds [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) (`resolveDiagramPath`, `levelEntries`, `buildFlowIndex`), [`src/flow-view/FlowIndex.tsx`](../../src/flow-view/FlowIndex.tsx), and [`src/flow-view/LevelMenu.tsx`](../../src/flow-view/LevelMenu.tsx), plus edits to [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (split crumbs, index button) and `FlowDiagramSvg.tsx` (hover intent, memoized flow data) for **flow-view**; new [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) (`createHoverIntent`, `HOVER_INTENT_MS`, `ANIMATION_ELEMENT_LIMIT`, `scrollBehaviorWithin`) plus edits to `GraphView.tsx`, `DictionaryView.tsx`, and `App.tsx` for **frontend**; `description:` frontmatter reads in [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (`FlowDiagram.description`) and `flow-derive-levels.ts` (copied onto the L1 process) for **flows**; and a folder-row description plus its hash fold in [`src/router/build.ts`](../../src/router/build.ts) (`buildFlowFolder`) for **router** | +| [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | **cli** | No design-doc counterpart; both checkpoints target [`src/cli/update.ts`](../../src/cli/update.ts) alone — `downloadProgressRenderer` (Checkpoint 1) and a streamed, incrementally-hashed `downloadAndReplace` (Checkpoint 2) | | [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | **flow-view**, **frontend** | Success criteria C4/C16/C17 cited by name in [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts); all six (C4/C5/C13/C15/C16/C17) checked directly by [`test/checks/test-cp4b-elk-edge-routing.ts`](../../test/checks/test-cp4b-elk-edge-routing.ts) and siblings | | [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) | **frontend** | SC5 cited by name in [`src/app/logic/search.ts`](../../src/app/logic/search.ts); CP1 checked by [`test/checks/test-viewer-search.ts`](../../test/checks/test-viewer-search.ts) | | [`docs/spec/derive-classification.md`](../spec/derive-classification.md) | **parser**, **validate** | Cited by name in [`test/checks/test-validate-entity.ts`](../../test/checks/test-validate-entity.ts) for classification-derivation rules | | [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) | **skill** | Names [`skills/ignatius-modeling/references/entity-flow.md`](../../skills/ignatius-modeling/references/entity-flow.md) and directs it to add Step E7b — Examples, between E7 (Columns) and E8 (Reference table) | | [`docs/spec/process-flows.md`](../spec/process-flows.md) | **skill** | Its `flow.*` frontmatter/token grammar is matched by [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) | | [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) | **theme**, **skill** | Its worked example is pointed to by [`skills/ignatius-modeling/references/model-flow.md`](../../skills/ignatius-modeling/references/model-flow.md) | -| [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) + [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | **flows**, **flow-view**, **frontend**, **skill** | The pair's Change tree names a new [`src/flows/flow-clusters.ts`](../../src/flows/flow-clusters.ts) plus edits to `flow-parse.ts`/`flow-validate.ts` (**flows**); `flow-layout.ts`/`elk-flow-layout.ts`/`FlowDiagramSvg.tsx` for per-process/connected-view stacking and collapse levels (**flow-view**); `App.tsx`/`FlowsView.tsx` and new `StackDialog.tsx`/`EdgeContractDialog.tsx` for the view/collapse toggles and dialogs (**frontend**); and `dfd-authoring.md`/`flow-templates.md`/`discover-flow.md`/`verification.md` for the new authoring and validation surface (**skill**) — [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) documents that same skill-side coupling from the user's side, naming the cluster-membership decision and the flow label as steps in the `flow` mode's Q&A | +| [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) + [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | **flows**, **flow-view**, **frontend**, **skill** | The pair's Change tree names a new [`src/flows/flow-clusters.ts`](../../src/flows/flow-clusters.ts) plus edits to `flow-parse.ts`/`flow-validate.ts` (**flows**); `flow-layout.ts`/`elk-flow-layout.ts`/`FlowDiagramSvg.tsx` for per-process/connected-view stacking, collapse levels, and row-local store-kind coloring (**flow-view**); `App.tsx`/`FlowsView.tsx` and new `StackDialog.tsx`/`EdgeContractDialog.tsx` for the view/collapse toggles and dialogs (**frontend**); and `dfd-authoring.md`/`flow-templates.md`/`discover-flow.md`/`verification.md` for the new authoring and validation surface (**skill**) — [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) documents that same skill-side coupling from the user's side, naming the cluster-membership decision and the flow label as steps in the `flow` mode's Q&A | | [`docs/wiki/feature-map.md`](feature-map.md) | all domains | Hand-authored feature-to-doc-to-skill cross-reference table; not generated by this signals pipeline, maintained separately | - diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index 68f4f21..78f35d9 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -55,6 +55,7 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Glossary of app terms (DG/DD/DFD/DE/DS/EE; DS⊃DE) | — | — | `../glossary.md` | — | | Store clusters (flow labels on every entry + label-height-aware inter-band clearance + stable grab-offset-preserving chip drag; contract dialog; per-process view default with connected view as toggle; collapse level stores/clusters/groups; mixed stacks keep per-row kind colors and kind-local numbered caps `D#`/`C#`/`Q#`/`F#`/`Do#`/`M#`/`O#`; working unnumbered C/G row disclosures; `clusters/.md` author clusters + `cluster:` token, the one exception to the closed endpoint-prefix set; five `flow.cluster_*` validation rules; adjacency stacking, `flow_view: { adjacency_stacks: false }`) | dfd-store-clusters | dfd-store-clusters | flows (Stores; Labels, stacks, clusters, and groups), folder-format (`clusters/`), validation (cluster rules) | SKILL core rule (labels and clusters always), dfd-authoring (store-kind visual identity, How the diagram reads, F4a cluster step + stack row count, F5 label rules), flow-templates (`cluster:` entry in the process template, labelled worked example + `clusters/settlement.md`, cluster checklist), discover-flow (Gate 5), verification (cluster rule table + stack legibility check) | | Model index routing (generated per-folder `index.md` routers with name/kind/description/link tables; rolled-up SHA digests; `` and `` managed regions beside hand-authored ``; `index_file:` and `harness:` keys in `ignatius.yml`; `ignatius index [--agents]` writes routers and in-folder `AGENTS.md` / [`CLAUDE.md`](../../CLAUDE.md) shim / `SKILL.md` guidance; `ignatius validate --index` reports drift as `index.stale`; `description:` on all five file kinds is the router payload; [`models/llm-memory-db-mssql`](../../models/llm-memory-db-mssql) is the fully realized exemplar) | model-index-routing | model-index-routing | folder-format | SKILL core rule (description-always, reserved `index_file`), entity-flow E1b, dfd-authoring F6a, conventions (reserved filename), verification (`validate --index` in the loop + `config.index_file_*` / `index.*` rule rows) | +| Large-model navigation (hover focus applies after the pointer rests 300 ms on one target, on DFD nodes/edges/chips + edge tooltip, Graph nodes incl. reverse predicates and Shift lineage, and Dictionary browse cards — `createHoverIntent` in [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts); a DFD over 150 rendered nodes + edges drops its fade transitions and a Dictionary over 150 visible items marks `data-motion="off"` so card fades and scrolls jump; flow index = SSADM process-hierarchy chart with description pane, opened by the ☰ Process Flows chip or `i`; breadcrumb ▾ level menus list same-level diagrams with number, description, process count; navigation by id path (`flow-nav.ts`); flow folder `description:` in `flows//index.md` frontmatter → `FlowDiagram.description`, the L1 process, and the router folder row; DFD nav card removed) | large-model-nav | large-model-nav, keyboard-nav-shortcuts, help-overlay, model-index-routing (SC7) | flows (Describing a diagram; Finding a flow; Hover and large diagrams), folder-format (Generated routers), commands (`i`) | SKILL core rule (description-always incl. flow folders), dfd-authoring F6a, flow-templates (flow folder `index.md`) | ◆ **Process flows — implemented and first-class, now an in-app view.** The `ignatius flow` CLI subcommand has been removed; flows are the **Flows** view inside the unified SPA (`serve`) and are included in the single `export -o model.html` file. The process dictionary is fused into the unified **Dictionary** view (no separate `/flow-dict` route). The **flow viewer is a custom SVG renderer** ([`src/flow-view/`](../../src/flow-view), separate from the ERD's Cytoscape): Gane-Sarson notation (open-ended `D#` stores, numbered process hubs, green externals), banded DFD layout (hub-and-spoke to stores/externals, no process-to-process, shared store bridging). Every node carries a ⓘ badge — a **`db:` store** opens the rich `SelectedEntityModal` (attributes, relationships, examples) shared with graph nodes; a process / external / non-`db` store opens the plain markdown doc dialog. Flow bodies parse `[[wiki-links]]` that route in-dialog across both flow nodes and ERD entities. [`models/key-inherited`](../../models/key-inherited) carries demo DFDs (`order-to-cash` with a sub-DFD + `refund`). Skill coverage: the `ignatius-modeling` skill's `flow` mode authors flow markdown (see the skill-modes row above). Guide coverage: [`docs/guides/flows.md`](../guides/flows.md) (folder format, process frontmatter, endpoints, sub-DFDs, viewing) plus the flow rule catalog in [`docs/guides/validation.md`](../guides/validation.md). See [`docs/spec/process-flows.md`](../spec/process-flows.md) Non-goals + the design's Open questions for the deferred set (queue-payload validation, usage index). diff --git a/docs/wiki/flow-view.md b/docs/wiki/flow-view.md index b26edbc..5c974ac 100644 --- a/docs/wiki/flow-view.md +++ b/docs/wiki/flow-view.md @@ -1,6 +1,6 @@ --- type: Domain -description: ELK/banded layout, stack-node grouping, and SVG rendering for DFD diagrams — positions, edge routes, chips, chrome. +description: ELK/banded layout, stack-node grouping, SVG rendering, and breadcrumb/index navigation for DFD diagrams. tags: [flow-view, flows, frontend] --- @@ -8,7 +8,9 @@ tags: [flow-view, flows, frontend] ## What it does -[`src/flow-view/`](../../src/flow-view) turns a parsed `FlowDiagram` into on-screen positions, routed edges, and a rendered SVG. Without this domain the app has no DFD canvas: `flows` has parsed data with nowhere to draw, and the frontend's Flows tab is empty. Layout runs two ways — an async elkjs pass with 5-band partitioning and orthogonal edge routing, or a synchronous hand-rolled banded fallback used while ELK resolves or after it fails — and both feed the same custom SVG renderer, which also owns pan/zoom/drag, the minimap, breadcrumbs, edge-hover tooltips, and search-driven dimming. +[`src/flow-view/`](../../src/flow-view) turns a parsed `FlowDiagram` into on-screen positions, routed edges, a rendered SVG, and the chrome a user drills through it with. Without this domain the app has no DFD canvas: `flows` has parsed data with nowhere to draw, and the frontend's Flows tab is empty. Layout runs two ways — an async elkjs pass with 5-band partitioning and orthogonal edge routing, or a synchronous hand-rolled banded fallback used while ELK resolves or after it fails — and both feed the same custom SVG renderer, which also owns pan/zoom/drag, the minimap, breadcrumbs, edge-hover tooltips, and search-driven dimming. + +On a model with dozens of flows, drilling down one level at a time is the only way in unless the diagram exposes shortcuts: `flow-nav.ts` turns the leveled DFD tree into breadcrumb sibling menus and a searchable process-hierarchy index, so a user can jump sideways or straight to any process without re-walking the whole path. It also resolves `dfd=` deep links, which carry a path reference rather than a bare id, so a reload or a link lands on the exact sub-DFD even when two unrelated flows share a process file name. The same large-model concern drives the hover behavior: `FlowDiagramSvg` now settles a hover through a shared intent delay before dimming anything, and drops fade transitions entirely once a diagram's element count passes the shared animation cutoff, so a pointer crossing a big diagram no longer repaints once per element it passes over. A store is not always drawn one-to-one with an entity. When a process touches two or more stores in one direction, or (in the connected view) two or more stores share a cluster tag, author cluster, subtype family, group, or read/write/kind signature, they collapse into one **stack node** — a single box whose rows can represent individual stores or nested groupings, at a collapse level the caller controls. Two view modes decide how this grouping runs: **per-process** (the default) stacks each process's own reads and writes; **connected** keeps one node per store and only groups where clusters/subtypes/groups/adjacency say to. Both are global settings owned by the frontend shell, not this domain. @@ -53,7 +55,7 @@ flowchart TD B -->|"'connected'"| E["buildConnectedViewGrouping: cluster/subtype/group/adjacency passes, per-store otherwise"] ``` -`computeElkLayout` takes the same `opts` (`ComputeElkLayoutOpts extends BuildFlowDataOpts`) so ELK's node/edge id set always matches what `FlowDiagramSvg` builds against. +`computeElkLayout` takes the same `opts` (`ComputeElkLayoutOpts extends BuildFlowDataOpts`) so ELK's node/edge id set always matches what `FlowDiagramSvg` builds against. `FlowDiagramSvg` now calls `buildFlowData` inside a `useMemo` keyed on `[diagram, flowDataOpts]`, since every hover and drag re-renders the component and rebuilding the whole node/edge model on each of those made hovering a large DFD stall. ### Connected-view grouping order @@ -110,33 +112,89 @@ Suppression is scoped to edges whose source resolves to a `'stack'` node — a p A grouped row (`cluster`/`subtype`/`group`, never a plain `store` row) draws a "more inside" affordance in `StackNode`: the row's own bottom edge closes fully, then two sheets filled with the box colour are drawn behind it at `STACK_ROW_PEEK_GAP`-scaled offsets, each showing only a stair-stepped slice of its left edge, its own bottom edge, and a short mark where its top edge pokes out past the front box — never a full left edge or cap divider, so only the front row reads as a complete box. When the last row is grouped the drawn box ends on the back sheet's bottom edge (`stackRowLayout` drops the trailing clearance) and no separate closing line is drawn. +### Selecting a diagram from a known path + +A breadcrumb crumb's ▾ menu, a level-menu pick, and a flow-index row all already know a full id path (they were built by walking the tree), so each calls `onSelectPath(ids)`, which resolves it with `resolveDiagramPath(roots, ids)`: an exact walk down from the roots, matched id by id against each level's `subDfds`. A miss at any step returns `null` and the caller keeps the current diagram — this path never falls back to a bare-id lookup, because a sub-DFD's id is its process file's name and two unrelated flows can share one ([`docs/spec/large-model-nav.md`](../spec/large-model-nav.md)). + +```mermaid +flowchart TD + A["breadcrumb pick, level-menu pick, or flow-index row click"] --> B["onSelectPath(ids: string[])"] + B --> C["resolveDiagramPath(roots, ids)"] + C --> D{"each id found in the current level's subDfds?"} + D -->|"yes, every step"| E["FlowDiagram[] path, root first"] + D -->|"no, or path is empty"| F["null — caller keeps the current diagram"] +``` + +`levelEntries(parent, roots, modelDescription)` produces the sibling list a breadcrumb's ▾ or the flow index's side pane shows for one level: the roots when `parent` is `null`, otherwise `parent.processes` in dotted-number order paired with their `subDfds` entries. `buildFlowIndex(roots, modelDescription)` builds the whole tree at once, then drops the derived Context and System rows (`withoutDerivedLevels`) so the flows are the top level: one `FlowIndexNode` per root diagram, its processes nested beneath, each node's `key` the full id path joined with `/` (unique even where process ids repeat) and its `path`/`opensOwnDiagram` set to the process's own sub-DFD when it has one, else the diagram that contains it. Both `levelEntries` and `buildFlowIndex` read a process's description in the same fallback order: its own `description:`, else its sub-DFD folder's `index.md` description, else — only for the synthetic whole-system process (`SYSTEM_PROCESS_ID`) — the model's own description. `FlowIndex`'s side pane shows the hovered row's description, else the focused row's, else the current diagram's, else — when nothing is hovered, focused, or open on the current diagram — the model's own name and description. + +### Resolving a dfd= reference + +A `dfd=` value is a path reference (`diagramRef`), not a bare id: `diagramRef(path)` drops the path's derived Context/System ids and joins what's left with `/` (`invoicing/Submit-PCI`), falling back to the path's own last id when every id in it is derived (a Context or System deep link). Reading it back — on load, on reload, on popstate, and after a `flowview=`/`collapse=` toggle rebuild — goes through `findDiagramByRef`, which walks the tree in order and returns the first path whose trailing ids match the reference's segments; a one-segment reference is a bare id, so links written before references carried paths still resolve. + +```mermaid +flowchart TD + A["dfd= value on load, reload, popstate, or view/collapse toggle"] --> B["findDiagramByRef(roots, ref)"] + B --> C{"a path's trailing ids match ref's segments?"} + C -->|yes| D["first such path, in tree order"] + C -->|no dfd=, or no match| E["defaultDiagramPath(roots): the System overview, or the first root when unleveled"] +``` + +`window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the rendered diagram's own bare id, never the path reference — it stays a stable test hook independent of how deep the diagram sits in the tree. + +### Hover settle path + +Dimming and the edge tooltip apply only after the pointer rests on one target; a pass across many elements repaints once instead of once per element crossed. + +```mermaid +sequenceDiagram + participant Pointer + participant Svg as FlowDiagramSvg + participant Intent as "createHoverIntent (motion.ts)" + + Pointer->>Svg: pointerEnter node/edge + Svg->>Intent: set("node:" / "edge:") + Note over Intent: pending until the pointer rests HOVER_INTENT_MS + Intent-->>Svg: apply(key) + Svg->>Svg: setHover({kind, id}), position edgeTooltip + Svg->>Svg: recompute nodeOpacity/edgeOpacity, dim non-connected elements +``` + +`FlowDiagramSvg` never times the delay itself: `hoverIntent.set(key)` on pointer-enter/leave and `hoverIntent.applyNow(null)` on opening the contract dialog are the only calls it makes; `createHoverIntent` and `HOVER_INTENT_MS` live in the frontend domain's [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) and are shared verbatim with the Graph and Dictionary views. The same module's `animationsAllowed(nodes.length + edges.length)` gates the `animate` flag threaded into `EdgePath`/`EdgeChip` and every node's hover style: past the shared element-count cutoff, opacity changes apply with no `transition`, matching the other two views' large-model behavior. + ## Where it lives | Path | Exports | Role | |---|---|---| -| [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) (367L) | `computeElkLayout`, `buildElkGraph`, `nodeSize`, `bandOf`, `isDbEdge`, `SHORT_LABEL_MAX`, `isInlineLabel`, `terminateQuietly`, `ElkLayoutResult`, `ComputeElkLayoutOpts` | Async ELK layout: 5-band partitioning (source-ext=0, input-store=1, process-row=2, output-store=3, sink-ext=4), `ORTHOGONAL` edge routing. Per-process view sets `elk.separateConnectedComponents: 'false'` so disconnected stacks still obey band ordering. No Bun/Node-only APIs at module top level — browser-safe. | -| [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (1761L) | `buildFlowData`, `computeFlowLayout`, `assignStoreNumbers`, `normalizeEdgeData`, `resolveChipLines`, `estProcessLineWidth`, `processNodeSize`, `stackNodeSize`, `stackRowLayout`, `stackRowBodyText`, `storeBodyWidth`, `measureText`, `layoutKeyForView`, `PROC_MIN_W`, `PROC_MIN_H`, `PROC_TEXT_LEFT`, `PROC_TEXT_RIGHT_PAD`, `PROC_LINE_H`, `PROC_TEXT_PAD_Y`, `STORE_ROW_H`, `STORE_CAP_W`, `STORE_STROKE_W`, `CHIP_TRUNCATE_MAX`, `STACK_ROW_PEEK_GAP`, `STACK_ROW_PEEK_RESERVE`, plus the `NodePos`/`StackMember`/`StackRow`/`ProcessNodeData`/`ExternalNodeData`/`StoreNodeData`/`StackNodeData`/`FlowNodeData`/`FlowElementData`/`BuildFlowDataOpts`/`FlowRenderData`/`StoreSplitMap` types | Renderer-agnostic layout: node/edge construction for all three views, the store split (read/write) map, external routing (max two aggregated copies per external), and the synchronous banded fallback (`computeFlowLayout`). `buildPerProcessStores`, `buildConnectedViewGrouping`, `buildStackRows`, and `resolveStackEdgeLabel` are internal, not exported. | -| [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (2213L) | `FlowDiagramSvg`, `DARK_PALETTE`/`LIGHT_PALETTE` (`FlowPalette`), `ElkPositionMap`, `MinimapData`, `FlowDiagramSvgProps`, `nodeBounds`, `sizingInfo`, `suppressDuplicateChips`, `deoverlapChips`, `chipAnchor`, `elkChannelChip`, `computeEdgeAnchors`, `chipDims`, `boxesOverlap`, `FlowNode`, `FlowEdge`, `StackSizeInfo`, `EdgeAnchors`, `Box`, `Pt` | The SVG renderer: process/external/store/stack node drawing, pan/zoom/drag, edge-hover tooltip, chip placement and dedup, search-driven dimming (`searchTokens`, `baseToken`). Consumes `elkPositions`/`elkEdgeRoutes` from `computeElkLayout` and `flowDataOpts` passed straight through to `buildFlowData` — the two must agree on `opts` or their node/edge id sets diverge. | -| [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (449L) | `FlowChrome`, `FlowChromeHandle`, `FlowChromeProps`, `BreadcrumbEntry` | Floating chrome around the SVG: breadcrumb chips, the DFD nav card (shown when more than one top-level diagram exists), and the bottom-left minimap (`FlowMinimap`, driven by an imperative `FlowChromeHandle` ref: `setStack`, `setDiagrams`, `setMinimap`, `setMinimapPanTo`). A `breadcrumbRef` + `ResizeObserver` writes the breadcrumb row's measured bottom edge into `--flow-search-bar-top` on `document.documentElement`. | +| [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) (382L) | `computeElkLayout`, `buildElkGraph`, `nodeSize`, `bandOf`, `isDbEdge`, `SHORT_LABEL_MAX`, `isInlineLabel`, `terminateQuietly`, `ElkLayoutResult`, `ComputeElkLayoutOpts` | Async ELK layout: 5-band partitioning (source-ext=0, input-store=1, process-row=2, output-store=3, sink-ext=4), `ORTHOGONAL` edge routing. Per-process view sets `elk.separateConnectedComponents: 'false'` so disconnected stacks still obey band ordering. No Bun/Node-only APIs at module top level — browser-safe. Reads no `FlowDiagram.description`; layout is unaffected by it. | +| [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (1800L) | `buildFlowData`, `computeFlowLayout`, `assignStoreNumbers`, `normalizeEdgeData`, `resolveChipLines`, `estProcessLineWidth`, `processNodeSize`, `stackNodeSize`, `stackRowLayout`, `stackRowBodyText`, `storeBodyWidth`, `measureText`, `layoutKeyForView`, `PROC_MIN_W`, `PROC_MIN_H`, `PROC_TEXT_LEFT`, `PROC_TEXT_RIGHT_PAD`, `PROC_LINE_H`, `PROC_TEXT_PAD_Y`, `STORE_ROW_H`, `STORE_CAP_W`, `STORE_STROKE_W`, `CHIP_TRUNCATE_MAX`, `STACK_ROW_PEEK_GAP`, `STACK_ROW_PEEK_RESERVE`, plus the `NodePos`/`StackMember`/`StackRow`/`ProcessNodeData`/`ExternalNodeData`/`StoreNodeData`/`StackNodeData`/`FlowNodeData`/`FlowElementData`/`BuildFlowDataOpts`/`FlowRenderData`/`StoreSplitMap` types | Renderer-agnostic layout: node/edge construction for all three views, the store split (read/write) map, external routing (max two aggregated copies per external), and the synchronous banded fallback (`computeFlowLayout`). `buildPerProcessStores`, `buildConnectedViewGrouping`, `buildStackRows`, and `resolveStackEdgeLabel` are internal, not exported. Reads no `FlowDiagram.description`. | +| [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) (185L) | `resolveDiagramPath`, `defaultDiagramPath`, `diagramRef`, `findDiagramByRef`, `levelEntries`, `buildFlowIndex`, `FlowLevelEntry`, `FlowIndexNode` | Pure, browser-safe: `resolveDiagramPath` walks an exact id path from the roots for breadcrumb/level-menu/index selection; `diagramRef`/`findDiagramByRef` build and resolve a `dfd=` path reference by trailing-id match; `defaultDiagramPath` picks the System overview (or the first root when unleveled) for a diagram-less load; `levelEntries`/`buildFlowIndex` turn the leveled `FlowDiagram` tree into breadcrumb sibling lists and the full process-hierarchy index, the latter dropping the derived Context/System rows. Sole consumer of `FlowDiagram.description`/`FlowProcess.description` in this domain. Imports `CONTEXT_DIAGRAM_ID`/`SYNTHETIC_DIAGRAM_IDS`/`SYSTEM_PROCESS_ID` from `src/flows/flow-derive-levels` and `compareDottedProcesses` from `src/app/logic/search` for process ordering. | +| [`src/flow-view/FlowIndex.tsx`](../../src/flow-view/FlowIndex.tsx) (120L) | `FlowIndex` | The process-hierarchy tree overlay opened from the breadcrumb's index chip or the `i` key: a scrollable tree (`buildFlowIndex` output) beside a preview pane that shows the hovered row's number/description/diagram-opens hint, else the focused row's, else the current diagram's, else the model's own name and description. | +| [`src/flow-view/LevelMenu.tsx`](../../src/flow-view/LevelMenu.tsx) (123L) | `LevelMenu` | The dropdown a breadcrumb's ▾ opens: `levelEntries` rendered as a listbox with number/label/process-count/description per row, a filter input above `FILTER_MIN_ENTRIES` (8) entries, arrow-key/Enter selection, and outside-pointerdown/Escape close. | +| [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (2234L) | `FlowDiagramSvg`, `DARK_PALETTE`/`LIGHT_PALETTE` (`FlowPalette`), `ElkPositionMap`, `MinimapData`, `FlowDiagramSvgProps`, `nodeBounds`, `sizingInfo`, `suppressDuplicateChips`, `deoverlapChips`, `chipAnchor`, `elkChannelChip`, `computeEdgeAnchors`, `chipDims`, `boxesOverlap`, `FlowNode`, `FlowEdge`, `StackSizeInfo`, `EdgeAnchors`, `Box`, `Pt` | The SVG renderer: process/external/store/stack node drawing, pan/zoom/drag, edge-hover tooltip, chip placement and dedup, search-driven dimming (`searchTokens`, `baseToken`). Consumes `elkPositions`/`elkEdgeRoutes` from `computeElkLayout` and `flowDataOpts` passed straight through to `buildFlowData` (now memoized) — the two must agree on `opts` or their node/edge id sets diverge. Routes every hover through `createHoverIntent` and gates transitions through `animationsAllowed`, both from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts). | +| [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (452L) | `FlowChrome`, `FlowChromeHandle`, `FlowChromeProps`, `BreadcrumbEntry` | Floating chrome around the SVG: breadcrumb chips, each non-leaf crumb's ▾ opening a `LevelMenu` of that level's diagrams (`levelEntries`), the "Process Flows" chip opening a `FlowIndex` overlay, and the bottom-left minimap (`FlowMinimap`, driven by an imperative `FlowChromeHandle` ref: `setStack`, `setDiagrams`, `setMinimap`, `setMinimapPanTo`, `toggleIndex`). A house (Home) button between the "Process Flows" chip and the first crumb opens the System overview (`defaultDiagramPath`) from any depth and is marked current (`aria-current="page"`) while the overview is on screen; on a derived diagram the "← Back" button is hidden. A `breadcrumbRef` + `ResizeObserver` writes the breadcrumb row's measured bottom edge into `--flow-search-bar-top` on `document.documentElement`. | | [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) (74L) | `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale`, `Size`, `Box` | Pure zoom/fit math, no DOM/React/Bun imports. Implements the native-1:1 zoom model: 100% means one diagram world-unit renders as one CSS pixel, not "fits the container." | ## Constraints - `processNodeSize` and `stackNodeSize` are the single sizing source for both ELK and the SVG box for process and stack nodes — `elk-flow-layout.ts`'s `nodeSize` and `FlowDiagramSvg`'s `nodeBounds` both call them, so ELK never lays out a process or stack against a box different from what's drawn. Store width is not unified: `nodeSize`'s store branch estimates the kind-specific cap plus label independently of `FlowDiagramSvg`'s `storeWidth(name) = STORE_CAP_W + storeBodyWidth(name)`, so the two can differ slightly. -- `SHORT_LABEL_MAX` (`elk-flow-layout.ts`) derives from `CHIP_TRUNCATE_MAX` (`flow-layout.ts` = 22) rather than redeclaring it — one numeric source gates both ELK-side inline-chip eligibility and the on-canvas chip truncation. If the two diverged, ELK could reserve inline-chip layout space for a label the renderer then truncates on canvas, or the reverse, so the chip's estimated footprint would stop matching what's drawn. +- `SHORT_LABEL_MAX` (`elk-flow-layout.ts`) derives from `CHIP_TRUNCATE_MAX` (`flow-layout.ts` = 22) rather than redeclaring it — one numeric source gates both ELK-side inline-chip eligibility and the on-canvas chip truncation, so forking the two constants apart could let ELK label a chip inline that the renderer then truncates, or vice versa. - `STORE_STROKE_W = 1.4` is the single canonical stroke width for a store/stack box's outline and dividers; `STACK_ROW_PEEK_RESERVE` derives its clearance from it, so a future stroke-width change can't silently reopen the visual fusion between a grouped row's peek marks and the next row's own divider. - Node positions are always centers (ELK top-left + half size), never top-left — `computeElkLayout` converts explicitly so ELK routes line up with the renderer's center-based `nodeBounds`. -- ELK receives node + edge geometry only, never label dummy nodes — label dummies split a band across two sub-layers; the renderer places all label placement itself (inline chip or truncated preview) in the inter-band channel. -- A stack node's official size (`stackNodeSize`, used by ELK and edge anchoring) counts only `STORE_ROW_H` per row; a grouped row's stacked-paper peek reserve is bottom padding on the *drawn* box only (`stackRowLayout`), so two stacks with the same row count always share the same official height regardless of whether one has a grouped row. -- The per-process view suppresses the duplicate-store marker on every stack row (`suppressDuplicateMarker`) — a shared store repeats in every process's own stack by design there, so the marker would cover nearly every row and stop marking anything exceptional. The underlying `duplicated` flag is untouched and still drawn in the connected view. -- `layoutKeyForView(baseKey, view)` appends the active view name to a diagram's fingerprint-derived layout key, so a drag saved in the per-process view never applies to the connected view or vice versa; collapse level is excluded from the key because it changes row content, not node ids. An empty `baseKey` (diagram not found in the fingerprint map) stays empty rather than gaining a view suffix with no fingerprint behind it. +- ELK receives node + edge geometry only, never label dummy nodes — the renderer places all label placement itself (inline chip or truncated preview) in the inter-band channel, so ELK's own layout never routes around label boxes the renderer doesn't draw, which would otherwise force a band to split across sub-layers and break the single-row-per-band guarantee (C16, [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md)). +- A stack node's official size (`stackNodeSize`) counts only `STORE_ROW_H` per row; a grouped row's stacked-paper peek reserve is bottom padding on the *drawn* box only (`stackRowLayout`), so two stacks with the same row count always share the same official height regardless of whether one has a grouped row. +- The per-process view suppresses the duplicate-store marker on every stack row (`suppressDuplicateMarker`) — a shared store repeats in every process's own stack by design there, so the marker would cover nearly every row. The underlying `duplicated` flag is untouched and still drawn in the connected view. +- `layoutKeyForView(baseKey, view)` appends the active view name to a diagram's fingerprint-derived layout key, so a drag saved in the per-process view never applies to the connected view or vice versa; collapse level is excluded because it changes row content, not node ids. - Chip dedup (`suppressDuplicateChips`) only ever hides chips whose edge source resolves to a `'stack'` node — a plain process or store can legitimately send an identical label to two different destinations, and both must stay visible. -- `buildFlowData`'s per-store branch (`opts.view` omitted) is byte-for-byte identical to its pre-stack behavior: a store both read and written by different processes still splits into `--read`/`--write` copies. +- `buildFlowData`'s per-store branch (`opts.view` omitted) is byte-for-byte identical to its pre-stack behavior, so breaking that compatibility guarantee would change the default view's rendered store layout for every existing diagram, not just ones using the new per-process/connected stacking. +- `resolveDiagramPath` addresses a diagram strictly by exact id path from a root — used only where the caller already knows the full path (breadcrumb picks, level-menu picks, flow-index rows) — because sub-DFD ids are process file names and two unrelated flows can share one. A `dfd=` reference is different: it names a diagram by its trailing ids only, so `findDiagramByRef` can match it against any path in the tree, oldest bare-id links included. +- A breadcrumb crumb gets a ▾ only when `levelEntries` for its level returns more than one entry (`hasMenu = siblings.length > 1`); since `FlowChrome`'s `stack.map` never renders a crumb for a derived (Context/System) entry at all, this single-entry case only ever applies to a non-derived crumb, e.g. a single-root unleveled tree or any level with exactly one sibling diagram. +- `animationsAllowed`/`ANIMATION_ELEMENT_LIMIT` and `createHoverIntent`/`HOVER_INTENT_MS` are owned by [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) (frontend domain), not redeclared here — `FlowDiagramSvg` only calls them, so the cutoff and delay stay identical across Graph, Dictionary, and Flows. +- The `data-ignatius` attributes emitted by `FlowChrome`/`FlowIndex`/`LevelMenu` (`flow-crumb`, `flow-crumb-menu-button`, `flow-index`, `flow-index-row`, `flow-index-pane`, `flow-level-menu`, `flow-level-menu-item`) are the selectors [`test/checks/test-large-model-nav.ts`](../../test/checks/test-large-model-nav.ts) drives via Playwright; renaming one without updating that test breaks it silently at the DOM level, not at compile time. ## Coupling -- **flows** ([`src/flows/`](../../src/flows)): every layout/render entry point takes a parsed `FlowDiagram` as input (`import type { FlowDiagram, FlowEdge, FlowStoreRef } from '../flows/flow-parse'` in `flow-layout.ts` and `elk-flow-layout.ts`) — type-only, no runtime dependency. flow-view never parses, validates, or levels diagrams itself; a shape change to `FlowDiagram`/`FlowStoreRef`/edge endpoint kinds forces a review of all four flow-view files that import them. -- **frontend** ([`src/app/`](../../src/app)): [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) is the sole orchestrator — it owns `flowViewParams` (`{ view, collapseLevel }`, default `{ view: 'per-process', collapseLevel: 'clusters' }`), calls `computeElkLayout(diagram, flowDataOpts)`, catches ELK failures so the renderer falls back to the banded layout, and calls `layoutKeyForView`. [`src/app/hash-router.ts`](../../src/app/hash-router.ts) owns the `FlowViewMode`/`FlowCollapseLevel` types and their `flowview=`/`collapse=` URL params — flow-view only consumes them through `BuildFlowDataOpts`, never defines or persists them. [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) imports `DARK_PALETTE`/`LIGHT_PALETTE` from `FlowDiagramSvg.tsx` directly. `FlowDiagramSvg.tsx` imports `PositionMap` from [`src/app/views/graph/layout-store`](../../src/app/views/graph/layout-store.ts) for drag persistence. `StackDialog` and the edge contract dialog live in [`src/app/components/flow-node/`](../../src/app/components/flow-node), outside this domain — `FlowDiagramSvg`'s `onOpenStack`/`onOpenContract` callbacks are the only link. +- **flows** ([`src/flows/`](../../src/flows)): every layout/render entry point takes a parsed `FlowDiagram` as input (`import type { FlowDiagram, FlowEdge, FlowStoreRef } from '../flows/flow-parse'` in `flow-layout.ts` and `elk-flow-layout.ts`) — type-only, no runtime dependency. `flow-nav.ts` additionally imports `CONTEXT_DIAGRAM_ID`/`SYNTHETIC_DIAGRAM_IDS`/`SYSTEM_PROCESS_ID` from `src/flows/flow-derive-levels` and reads `FlowDiagram.description`/`FlowProcess.description`, which [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) populates from a DFD folder's `index.md` frontmatter (`readDiagramDescription`) and a process file's own `description:`. `FlowChrome.tsx` also imports `SYNTHETIC_DIAGRAM_IDS` directly, to tell whether the on-screen diagram is a derived level. flow-view never parses, validates, or levels diagrams itself; a shape change to `FlowDiagram`/`FlowStoreRef`/edge endpoint kinds or to the description/id fields forces a review of every flow-view file that imports them. +- **frontend** ([`src/app/`](../../src/app)): [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) is the sole orchestrator — it owns `flowViewParams` (`{ view, collapseLevel }`, default `{ view: 'per-process', collapseLevel: 'clusters' }`), calls `computeElkLayout(diagram, flowDataOpts)`, catches ELK failures so the renderer falls back to the banded layout, calls `layoutKeyForView`, and passes `modelName`/`modelDescription` into `FlowChrome` for the flow index's title and root/whole-system descriptions. It also owns diagram selection: `selectDiagramById` (popstate and flow search, via `findDiagramByRef`) and `selectDiagramPath` (breadcrumb/level-menu/index picks, via `resolveDiagramPath`) both call a shared `showPath`, which rebuilds the breadcrumb stack with `stackFor` and writes the new `diagramRef` out through `onDiagramChange`; the initial stack seeds from `findDiagramByRef(allDiagrams, startDiagramId)` falling back to `defaultDiagramPath`. [`src/app/hash-router.ts`](../../src/app/hash-router.ts) owns the `FlowViewMode`/`FlowCollapseLevel` types, the `flowview=`/`collapse=` URL params, and the `dfd=` param, whose value it keeps as a path reference with `/` left unencoded (`serializeHash` special-cases it rather than letting `URLSearchParams` percent-encode the separator). [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) is the source of `createHoverIntent`/`HOVER_INTENT_MS` and `animationsAllowed`/`ANIMATION_ELEMENT_LIMIT` that `FlowDiagramSvg` consumes for hover settle and the animation cutoff — shared verbatim with the Graph and Dictionary views; its internals belong to the frontend domain page, not here. [`src/app/logic/search.ts`](../../src/app/logic/search.ts) supplies `compareDottedProcesses` (used by `flow-nav.ts` for level ordering) and `searchFlowDiagrams`, whose base tokens `FlowDiagramSvg.tsx`'s `baseToken` strips to match against. [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) imports `DARK_PALETTE`/`LIGHT_PALETTE` from `FlowDiagramSvg.tsx` directly. `FlowDiagramSvg.tsx` imports `PositionMap` from [`src/app/views/graph/layout-store`](../../src/app/views/graph/layout-store.ts) for drag persistence. `StackDialog` and the edge contract dialog live in [`src/app/components/flow-node/`](../../src/app/components/flow-node), outside this domain — `FlowDiagramSvg`'s `onOpenStack`/`onOpenContract` callbacks are the only link, and opening the contract dialog calls `hoverIntent.applyNow(null)` to drop a stale tooltip; opening a stack does not. - **theme** ([`src/theme/`](../../src/theme)): `flow-layout.ts` and `FlowDiagramSvg.tsx` import `FlowKindKey`/`FlowKindEntry` from `src/theme/theme-defaults` for kind-colored store/external fills. -- [`src/app/logic/search.ts`](../../src/app/logic/search.ts)'s `searchFlowDiagrams` produces the same base tokens (role-split suffixes stripped) that `FlowDiagramSvg.tsx`'s `baseToken` strips to match against — a change to either suffix scheme has to stay in sync with the other. -- **docs**: [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) / [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) is the source of the stack-node model, the per-process/connected views, the collapse level, and the mixed-label chip rule. [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) / [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) is the source of the 5-band ELK layout contract (cited by name in `elk-flow-layout.ts`'s `bandOf`). [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) is the evidence base behind the ELK pipeline. [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) / [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) is the source of the edge-hover tooltip (`dataLines`, `tooltipLines`). [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) is the source of the native-1:1 zoom model in `zoom-scale.ts`. [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) / [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) is the source of the `searchTokens` dimming feature. +- **docs**: [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) / [`docs/design/large-model-nav.md`](../design/large-model-nav.md) is the source of the breadcrumb level menus, the flow index, the `dfd=` path-reference format, the hover-intent delay, and the animation cutoff. [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) / [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) is the source of the stack-node model, the per-process/connected views, the collapse level, and the mixed-label chip rule. [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) / [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) is the source of the 5-band ELK layout contract (cited by name in `elk-flow-layout.ts`'s `bandOf`). [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) is the evidence base behind the ELK pipeline. [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) / [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) is the source of the edge-hover tooltip (`dataLines`, `tooltipLines`). [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) is the source of the native-1:1 zoom model in `zoom-scale.ts`. [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) / [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) is the source of the `searchTokens` dimming feature. +- **Tests**: [`test/checks/test-flow-nav.ts`](../../test/checks/test-flow-nav.ts) exercises `flow-nav.ts` directly (path resolution, `diagramRef`/`findDiagramByRef`, level entries, index construction, id-collision handling). [`test/checks/test-large-model-nav.ts`](../../test/checks/test-large-model-nav.ts) drives a served fixture through Playwright to check the breadcrumb menus, the flow index, the hover-intent delay, and the animation cutoff end to end. [`test/checks/test-hash-router.ts`](../../test/checks/test-hash-router.ts) covers the `dfd=` path round-trip through `hash-router.ts`. [`test/checks/test-flow-diagram-description.ts`](../../test/checks/test-flow-diagram-description.ts) covers `FlowDiagram.description` at its source in the flows domain ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)), not in this domain. diff --git a/docs/wiki/flows.md b/docs/wiki/flows.md index ca316de..b63cee7 100644 --- a/docs/wiki/flows.md +++ b/docs/wiki/flows.md @@ -8,7 +8,7 @@ tags: [flows, parser, validate] ## What it does -[`src/flows/`](../../src/flows) turns a model's `flows/*/` process markdown into the SSADM data-flow-diagram tree that the app renders, validates, and cross-references. Without this domain the app has no DFD view at all: `flow-view` has nothing to lay out, `validate`'s `flow.*` rules have nothing to check, and the entity dialog's Processes tab has nothing to list. Seven modules split the job: parse the leaf diagrams, expand a `clusters/.md` author-defined group into member edges, synthesize the context/L1 diagrams above the leaves, validate the whole tree against 17 `flow.*` rules, fingerprint each diagram's topology for layout caching, index which processes read or write each store/external, and turn slugs into display titles. Every module past the parser is pure and browser-safe — no Bun/Node I/O outside `flow-parse.ts` — so `flow-view` and the frontend can call them directly on data already in memory. +[`src/flows/`](../../src/flows) turns a model's `flows/*/` process markdown into the SSADM data-flow-diagram tree that the app renders, validates, and cross-references. Without this domain the app has no DFD view at all: `flow-view` has nothing to lay out, `validate`'s `flow.*` rules have nothing to check, and the entity dialog's Processes tab has nothing to list. Eight modules split the job: parse the leaf diagrams (including each diagram folder's own `description:`), expand a `clusters/.md` author-defined group into member edges, synthesize the context/L1 diagrams above the leaves, validate the whole tree against 17 `flow.*` rules, fingerprint each diagram's topology for layout caching, index which processes read or write each store/external, and turn slugs into display titles. Every module except `flow-parse.ts` and `flow-clusters.ts` is pure and browser-safe — so `flow-view` and the frontend can call them directly on data already in memory. `flow-clusters.ts`'s `cluster:` token names an entirely different concept than the `cluster.*` entity-subtype rules in [`src/model/validate.ts`](../../src/model/validate.ts). `flow.cluster_*` (this domain) is an author-declared group of *entities* under `clusters/.md`, expanded into `db:` edges on a DFD; `cluster.*` (the parser/validate domain) is a basetype/subtype grouping of entity records with a discriminator column. The two share the English word and nothing else — [`src/model/validate.ts`](../../src/model/validate.ts) declares `cluster.*` at lines 50-52 and `flow.cluster_*` at lines 67-71, within the same `RuleId` union but separated by the 12 diagram-scoped `flow.*` rules. @@ -25,7 +25,7 @@ flowchart LR E --> F["FlowValidationResult{flowErrors, cleanedFlowModel}"] ``` -`parseClusters` (`src/flows/flow-clusters.ts:49`) runs unconditionally, even when no `flows/` folder exists, so a clusters-only model still reports its registry. `deriveLevels` runs unconditionally inside `parseFlows` (`src/flows/flow-parse.ts:843`); `validateFlows` (`src/flows/flow-validate.ts:821`) is invoked separately by [`src/cli/cli.ts`](../../src/cli/cli.ts) and [`src/server/server.ts`](../../src/server/server.ts) once they also have the entity `Model`. [`src/generators/app.ts`](../../src/generators/app.ts) never calls it: it only receives an already-validated `FlowModel` as a parameter. +`parseClusters` (`src/flows/flow-clusters.ts:49`) runs unconditionally, even when no `flows/` folder exists, so a clusters-only model still reports its registry. `deriveLevels` runs unconditionally inside `parseFlows` (`src/flows/flow-parse.ts:883`); `validateFlows` (`src/flows/flow-validate.ts:821`) is invoked separately by [`src/cli/cli.ts`](../../src/cli/cli.ts) and [`src/server/server.ts`](../../src/server/server.ts) once they also have the entity `Model`. [`src/generators/app.ts`](../../src/generators/app.ts) never calls it: it only receives an already-validated `FlowModel` as a parameter. ### Reserved-name skip during folder scans @@ -38,11 +38,11 @@ flowchart TD B -->|no| D[parse frontmatter as process / external / store / cluster] ``` -Skip sites: `src/flows/flow-parse.ts:426` (externals), `:531` (process files), `:758` (stores), `src/flows/flow-clusters.ts:59` (clusters). +Skip sites: `src/flows/flow-parse.ts:430` (externals), `:568` (process files), `:798` (stores), `src/flows/flow-clusters.ts:59` (clusters). ### `cluster:` token expansion -A process `inputs`/`outputs` entry whose `from`/`to` is `cluster:` never reaches `parseEndpoint` — `buildEdgeFromInput`/`buildEdgeFromOutput` (`src/flows/flow-parse.ts:341`, `:360`) intercept the `cluster:` prefix and hand off to `expandClusterEdges` (`src/flows/flow-clusters.ts:92`), which turns the entry's `data:` map (entity id → columns) into one `db:` edge per mapped member. The closed `FlowEndpoint['kind']` set (`src/flows/flow-parse.ts:29-33`) never gains a `'cluster'` member — expansion happens before any `FlowEndpoint` is constructed for the entry. +A process `inputs`/`outputs` entry whose `from`/`to` is `cluster:` never reaches `parseEndpoint` — `buildEdgeFromInput`/`buildEdgeFromOutput` (`src/flows/flow-parse.ts:345`, `:364`) intercept the `cluster:` prefix and hand off to `expandClusterEdges` (`src/flows/flow-clusters.ts:92`), which turns the entry's `data:` map (entity id → columns) into one `db:` edge per mapped member. The closed `FlowEndpoint['kind']` set (`src/flows/flow-parse.ts:29-33`) never gains a `'cluster'` member — expansion happens before any `FlowEndpoint` is constructed for the entry. ```mermaid flowchart TD @@ -59,7 +59,7 @@ An empty `data:` map wins over an unresolved slug: `expandClusterEdges` checks ` ### Level derivation: leaves wrapped in two synthetic diagrams -`deriveLevels` (`src/flows/flow-derive-levels.ts:406`) never mutates or re-parses leaves; it wraps the flat leaf array the parser produced inside a context (Level 0) diagram and an L1 overview diagram. +`deriveLevels` (`src/flows/flow-derive-levels.ts:407`) never mutates or re-parses leaves; it wraps the flat leaf array the parser produced inside a context (Level 0) diagram and an L1 overview diagram. ```mermaid flowchart TD @@ -77,9 +77,22 @@ flowchart TD Context collects every external↔process boundary edge across all leaves and re-targets the process end at the single `systemProc`, deduped per `(extId, direction)` (`deriveContext`, `flow-derive-levels.ts:147`). L1 gets one process per leaf plus any store whose degree (distinct referencing leaves) is `>= 2` (`buildStoreDegreeMap` / `collectPromotedStores`, `flow-derive-levels.ts:56-91`); degree-1 stores stay local to their leaf and never appear at L1. `renumberDiagram` prefixes the L1 parent number onto each process's existing relative `dottedNumber` and recurses into `subDfds` at any depth, so a process 3 levels deep under leaf `N` gets `N.a.b.c` rather than losing its ancestry. `deriveLevels` spreads the input `FlowModel` (`{ ...flowModel, diagrams: [contextDiagram] }`), so `FlowModel.clusters` passes through unchanged — leveling never touches the cluster registry. +### Diagram description: a folder's index file reaches its L1 process + +A `flows//index.md` (or any nested sub-DFD folder's own index file) may carry `description:` in its frontmatter; `readDiagramDescription` (`src/flows/flow-parse.ts:490`) reads it and `parseDiagramFolder` (`:517`) sets it as `FlowDiagram.description` on the diagram it returns, whether that diagram is a top-level flow folder or a nested sub-DFD. `deriveL1` (`flow-derive-levels.ts:235`) then copies each top-level leaf's `description` onto that leaf's L1 process (`:265`) — a nested sub-DFD's own description is never copied anywhere by leveling, and the context/L1 diagram objects themselves never get a `description` of their own. + +```mermaid +flowchart LR + A["flows/<dfd>/index.md description:"] --> B["readDiagramDescription (exists? starts with '---\\n'?)"] + B --> C["parseDiagramFolder: FlowDiagram.description"] + C --> D["deriveL1: L1 process.description = leaf.description"] +``` + +`readDiagramDescription` checks `content.startsWith('---\n')` before calling `parseFrontmatter`, rather than calling it unconditionally the way process/external/store parsing does. Because `flows//index.md` is also the file `ignatius index` regenerates (owning only its `` regions), a router-only index file with no hand-authored frontmatter at all yields `undefined` silently — it is never reported as `parse.invalid_yaml`. A missing index file (`file.exists()` false) yields the same `undefined`. Frontmatter that does start with the delimiter but fails to parse (bad YAML) still reports `parse.invalid_yaml` against the index path, same as every other frontmatter parse failure in this domain. [`test/checks/test-flow-diagram-description.ts`](../../test/checks/test-flow-diagram-description.ts) (imports `parseFlows` and `SYSTEM_PROCESS_ID` from this domain, plus `buildRouters`/`writeRouters` from [`src/router/`](../../src/router)) exercises: a described folder reaching `FlowDiagram.description` and its L1 process; an undescribed folder staying `undefined` with no error; a sub-DFD folder with no index file never inheriting its owning process's description; and malformed index frontmatter reporting `parse.invalid_yaml`. + ### `parseProcessExamples`: defensive parsing of the `examples:` block -`parseProcessExamples` (`src/flows/flow-parse.ts:276`) converts a process's `examples:` frontmatter into `{ in: FlowExample[]; out: FlowExample[] }`. It never throws on malformed input; every layer of the shape either returns a fully-typed empty value or drops the offending element. +`parseProcessExamples` (`src/flows/flow-parse.ts:280`) converts a process's `examples:` frontmatter into `{ in: FlowExample[]; out: FlowExample[] }`. It never throws on malformed input; every layer of the shape either returns a fully-typed empty value or drops the offending element. ```mermaid flowchart TD @@ -91,9 +104,9 @@ flowchart TD E --> F ``` -A malformed or absent `examples:` block never produces a partial result: either both `in` and `out` arrays come back, or the whole field is `undefined` and `FlowProcess.examples` is omitted (`parseDiagramFolder`, `flow-parse.ts:651`, only sets `examples` when `parsedExamples` is truthy). +A malformed or absent `examples:` block never produces a partial result: either both `in` and `out` arrays come back, or the whole field is `undefined` and `FlowProcess.examples` is omitted (`parseDiagramFolder`, `flow-parse.ts:688`, only sets `examples` when `parsedExamples` is truthy). -Inside `parseExampleList` (`:282`), a non-array `in`/`out` value yields `[]`; an item that isn't a record is skipped outright rather than raising a parse error; a kept item copies `from`/`to`/`label` only when each is a string, and always hands `item['rows']` to `parseExampleRows` regardless of whether the item carried any of those three fields. `parseExampleRows` (`:296`) applies the same defensive pattern one level down: a non-array `rows:` value or an absent `rows:` key yields `rows: []` rather than an error, a non-record row is skipped, and within a kept row only the scalar-valued keys (`string`, `number`, or `boolean`) are copied into the `FlowExampleRow` — a key whose value is a nested object or array is silently dropped. Rows are never padded to a common key set: two rows in the same `in`/`out` entry can declare different columns, and each `FlowExampleRow` reflects only the keys its own frontmatter row had. [`test/checks/test-cp16-process-examples.ts`](../../test/checks/test-cp16-process-examples.ts) exercises the null-guard and the missing-rows branch directly against synthetic input (the live [`models/key-inherited`](../../models/key-inherited) fixture has no such entries) alongside a live-fixture pass over `Collect-Payment`'s `examples:` block that checks the happy path and the heterogeneous-column case. +Inside `parseExampleList` (`:286`), a non-array `in`/`out` value yields `[]`; an item that isn't a record is skipped outright rather than raising a parse error; a kept item copies `from`/`to`/`label` only when each is a string, and always hands `item['rows']` to `parseExampleRows` regardless of whether the item carried any of those three fields. `parseExampleRows` (`:300`) applies the same defensive pattern one level down: a non-array `rows:` value or an absent `rows:` key yields `rows: []` rather than an error, a non-record row is skipped, and within a kept row only the scalar-valued keys (`string`, `number`, or `boolean`) are copied into the `FlowExampleRow` — a key whose value is a nested object or array is silently dropped. Rows are never padded to a common key set: two rows in the same `in`/`out` entry can declare different columns, and each `FlowExampleRow` reflects only the keys its own frontmatter row had. [`test/checks/test-cp16-process-examples.ts`](../../test/checks/test-cp16-process-examples.ts) exercises the null-guard and the missing-rows branch directly against synthetic input (the live [`models/key-inherited`](../../models/key-inherited) fixture has no such entries) alongside a live-fixture pass over `Collect-Payment`'s `examples:` block that checks the happy path and the heterogeneous-column case. ### Validation: cluster-registry checks run once, before any diagram walk @@ -131,10 +144,10 @@ Class B (`flow.unknown_store`, `flow.unknown_external`, `flow.unknown_process`, | Path | Exports | Role | |---|---|---| -| [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (849L) | `parseFlows`, `parseProcessExamples`, `resolveEndpoint`, `FlowModel`/`FlowDiagram`/`FlowProcess`/`FlowExternal`/`FlowStoreRef`/`FlowEdge`/`FlowEndpoint`/`FlowExample`/`FlowExampleRow`/`FlowParseResult` types | SSADM DFD parser. Discovers DFD folders under `/flows/`; reads shared `externals/`, `stores/`, and `clusters/` registries once at model root; recurses into same-named sub-folders for nested sub-DFDs; calls `deriveLevels` before returning. The only module in this domain with Bun I/O (`Bun.file`, `Bun.Glob`). | +| [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (889L) | `parseFlows`, `parseProcessExamples`, `resolveEndpoint`, `FlowModel`/`FlowDiagram`/`FlowProcess`/`FlowExternal`/`FlowStoreRef`/`FlowEdge`/`FlowEndpoint`/`FlowExample`/`FlowExampleRow`/`FlowParseResult` types | SSADM DFD parser. Discovers DFD folders under `/flows/`; reads shared `externals/`, `stores/`, and `clusters/` registries once at model root; recurses into same-named sub-folders for nested sub-DFDs; reads each diagram folder's `index.md` `description:` (`readDiagramDescription`) onto `FlowDiagram.description`; calls `deriveLevels` before returning. Has Bun I/O (`Bun.file`, `Bun.Glob`), alongside `flow-clusters.ts`. | | [`src/flows/flow-clusters.ts`](../../src/flows/flow-clusters.ts) (130L) | `parseClusters`, `expandClusterEdges`, `toClusterDataMap`, `FlowCluster` type | Reads `/clusters/*.md` into a slug → `FlowCluster` map; expands a `cluster:` input/output entry into one `db:` edge per mapped member, tagging unresolved cases with `clusterIssue`. Has Bun I/O (`Bun.file`, `Bun.Glob`), called only from `flow-parse.ts`. | -| [`src/flows/flow-markdown.ts`](../../src/flows/flow-markdown.ts) (39L) | `md`, `isRecord`, `parseFrontmatter`, `normalizedLabel` | Shared frontmatter-delimiter parsing and the wikilink-enabled `MarkdownIt` instance, so `flow-parse.ts` (processes, externals, stores) and `flow-clusters.ts` (clusters) never fork the YAML regex or renderer config. `isRecord` also backs every defensive check in `parseProcessExamples`. | -| [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) (434L) | `deriveLevels`, `CONTEXT_DIAGRAM_ID`, `SYSTEM_PROCESS_ID`, `SYNTHETIC_DIAGRAM_IDS` | Wraps flat leaves in a context + L1 synthetic diagram pair; store promotion by degree; recursive renumbering. Pure. | +| [`src/flows/flow-markdown.ts`](../../src/flows/flow-markdown.ts) (39L) | `md`, `isRecord`, `parseFrontmatter`, `normalizedLabel` | Shared frontmatter-delimiter parsing and the wikilink-enabled `MarkdownIt` instance, so `flow-parse.ts` (processes, externals, stores, diagram descriptions) and `flow-clusters.ts` (clusters) never fork the YAML regex or renderer config. `isRecord` also backs every defensive check in `parseProcessExamples`. | +| [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) (435L) | `deriveLevels`, `CONTEXT_DIAGRAM_ID`, `SYSTEM_PROCESS_ID`, `SYNTHETIC_DIAGRAM_IDS` | Wraps flat leaves in a context + L1 synthetic diagram pair; store promotion by degree; recursive renumbering; copies each top-level leaf's `description` onto its L1 overview process. Pure. | | [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) (853L) | `validateFlows`, `FlowError`, `FlowRulesConfig`, `FlowValidationResult` | 17 `flow.*` rules (12 diagram-scoped + 5 `flow.cluster_*`), Class A/B split, cleaned-model rebuild. Pure. | | [`src/flows/flow-fingerprint.ts`](../../src/flows/flow-fingerprint.ts) (90L) | `buildFlowLayoutKeys`, `layoutFlowFingerprint` | Hand-rolled FNV-1a 32-bit hash over sorted resolved `kind:name` ids and edge pairs, per diagram and recursively across the whole tree. Pure. | | [`src/flows/flow-usage-index.ts`](../../src/flows/flow-usage-index.ts) (244L) | `buildEntityUsageIndex`, `buildFlowNodeUsageIndex`, `ProcessUsage` | `buildEntityUsageIndex` is the legacy `db:`-only index keyed by bare entity id; `buildFlowNodeUsageIndex` is the token-keyed superset (`"ext:Customer"`, `"file:gateway-log"`, `"db:Payment"`) covering every non-`proc` endpoint kind. Both recurse into `subDfds` and merge into a `'read' \| 'write' \| 'readwrite'` direction. Pure. `ProcessUsage` is consumed directly by seven [`src/app/`](../../src/app) files (`FlowNodeModal.tsx`, `EntityModal.tsx`, `ProcessesTable.tsx`, `ProcessesSection.tsx`, `EntityCard.tsx`, `FlowsView.tsx`, `DictionaryView.tsx`). | @@ -142,16 +155,17 @@ Class B (`flow.unknown_store`, `flow.unknown_external`, `flow.unknown_process`, ## Constraints -- Endpoint tokens are always `kind:name` strings (`ext:Customer`, `db:Payment`, `file:gateway-log`, `proc:CreateOrder`); a bare name with no colon is parsed as `kind: 'proc'` (`parseEndpoint`, `flow-parse.ts:183`) and stays `proc` unless `checkAmbiguousEndpoints` (`flow-validate.ts:333`) finds the bare name in two or more of the external/store/process namespaces and fires `flow.ambiguous_endpoint`. `resolveEndpoint()` (`flow-parse.ts:227`) implements the same namespace check but is exercised only by [`test/checks/test-flow-endpoints.ts`](../../test/checks/test-flow-endpoints.ts), never called from production code. `cluster:` is not, and never becomes, a member of `FlowEndpoint['kind']` — `buildEdgeFromInput`/`buildEdgeFromOutput` consume the prefix before any endpoint is parsed. +- Endpoint tokens are always `kind:name` strings (`ext:Customer`, `db:Payment`, `file:gateway-log`, `proc:CreateOrder`); a bare name with no colon is parsed as `kind: 'proc'` (`parseEndpoint`, `flow-parse.ts:187`) and stays `proc` unless `checkAmbiguousEndpoints` (`flow-validate.ts:333`) finds the bare name in two or more of the external/store/process namespaces and fires `flow.ambiguous_endpoint`. `resolveEndpoint()` (`flow-parse.ts:231`) implements the same namespace check but is exercised only by [`test/checks/test-flow-endpoints.ts`](../../test/checks/test-flow-endpoints.ts), never called from production code. `cluster:` is not, and never becomes, a member of `FlowEndpoint['kind']` — `buildEdgeFromInput`/`buildEdgeFromOutput` consume the prefix before any endpoint is parsed. - `flow.cluster_*` (this domain's author-cluster rules on `FlowEdge.clusterIssue`) and `cluster.*` (the entity-subtype rules in [`src/model/validate.ts`](../../src/model/validate.ts) operating on `Model.subtypeClusters`) are unrelated rule families that happen to share a name root; [`src/model/validate.ts`](../../src/model/validate.ts) declares both within the same `RuleId` union (`cluster.*` at lines 50-52, `flow.cluster_*` at lines 67-71), and a reader scanning by prefix alone will conflate them. - An `expandClusterEdges` entry with an empty `data:` map always yields `flow.cluster_no_members`, even when the `clusters/.md` file also doesn't exist — the empty-map check runs before the slug-resolution check (`flow-clusters.ts:107-117`). -- `parseProcessExamples` (`flow-parse.ts:276`) never errors on malformed `examples:` input: an absent/`null`/non-record value returns `undefined` (no `examples` field at all, rather than `{in: [], out: []}`); a non-array `in`/`out` list, a non-record item, or a missing/non-array `rows:` key all degrade to an empty array; and within a kept row only `string`/`number`/`boolean`-valued keys survive, silently dropping any key whose value is an object or array. Rows are never padded to a common key set across an entry — heterogeneous rows are stored exactly as authored. +- `parseProcessExamples` (`flow-parse.ts:280`) never errors on malformed `examples:` input: an absent/`null`/non-record value returns `undefined` (no `examples` field at all, rather than `{in: [], out: []}`); a non-array `in`/`out` list, a non-record item, or a missing/non-array `rows:` key all degrade to an empty array; and within a kept row only `string`/`number`/`boolean`-valued keys survive, silently dropping any key whose value is an object or array. Rows are never padded to a common key set across an entry — heterogeneous rows are stored exactly as authored. +- `readDiagramDescription` (`flow-parse.ts:490`) never reports an error for a missing index file, a missing `description:` key, or an index file whose content doesn't start with `---\n` (the router-only case) — all three collapse to `FlowDiagram.description` staying `undefined`. It reports `parse.invalid_yaml` only when the file does start with the delimiter but the YAML itself fails to parse. It runs for every diagram folder the recursion visits, top-level `flows//` and any nested sub-DFD folder alike, but `deriveLevels` only ever reads it off the top-level leaves; a nested sub-DFD's own description is set on that `FlowDiagram` but never copied onto anything by leveling. - Every process's `id` must equal the `id` of its corresponding `subDfds` entry — `FlowsView`'s drill-down does `currentDiagram.subDfds.find(d => d.id === processId)`, and `deriveLevels`/`renumberDiagram` preserve this by construction. If the ids ever diverge, `.find()` returns `undefined`; `handleDrill` logs a `console.warn` and returns, so the click on the process silently does nothing in the UI. -- Display labels all fall back to `titlelize(id)` eventually, but the chain length before that varies by resource type: process and external check an explicit `title:` frontmatter override, then the type-specific field (`process:` at `src/flows/flow-parse.ts:562-567`, `external:` at `:441-447`), then `titlelize(id)`; store and cluster have no type-specific field to fall through to, so their override goes straight to `titlelize(id)` (`title:` for stores at `src/flows/flow-parse.ts:769-773`, `label:` for clusters at `src/flows/flow-clusters.ts:65`). A top-level `description:` field on process, external, and store frontmatter is read independently of this label chain and carried onto `FlowProcess.description`, `FlowExternal.description`, and the store body map's `description`. A reader who assumes the two chains behave identically will expect that omitting `title:`/`label:` on a store or cluster still leaves a human-authored fallback in place the way it does for a process or external (whose `process:`/`external:` field supplies one) — it doesn't: without `title:`/`label:`, a store or cluster falls straight to `titlelize(id)`, with no intermediate authored field to catch it. -- `FlowStoreRef.kind` and `FlowExternal.kind` share the vocabulary `'db' | 'cache' | 'queue' | 'file' | 'doc' | 'manual' | 'other'` (externals additionally omit `'db'`), but `kind:` is not equally required on both: a store's `kind:` is its type discriminator, while an external's is optional. Do not treat an external authored without `kind:` as a gap to fill in — it renders with the conventional green fill, not an error or an unstyled node. +- Display labels all fall back to `titlelize(id)` eventually, but the chain length before that varies by resource type: process and external check an explicit `title:` frontmatter override, then the type-specific field (`process:` at `src/flows/flow-parse.ts:601-604`, `external:` at `:448-451`), then `titlelize(id)`; store and cluster have no type-specific field to fall through to, so their override goes straight to `titlelize(id)` (`title:` for stores at `src/flows/flow-parse.ts:810-813`, `label:` for clusters at `src/flows/flow-clusters.ts:65`). A top-level `description:` field on process, external, and store frontmatter is read independently of this label chain and carried onto `FlowProcess.description`, `FlowExternal.description`, and the store body map's `description`. A diagram folder's own `description:` (its `flows//index.md`) is a fourth, separate `description:` source, read by `readDiagramDescription` rather than the per-resource frontmatter parsing above, and lands on `FlowDiagram.description` instead of any process/external/store record. A reader who assumes the two chains behave identically will expect that omitting `title:`/`label:` on a store or cluster still leaves a human-authored fallback in place the way it does for a process or external (whose `process:`/`external:` field supplies one) — it doesn't: without `title:`/`label:`, a store or cluster falls straight to `titlelize(id)`, with no intermediate authored field to catch it. +- `FlowStoreRef.kind` and `FlowExternal.kind` share the vocabulary `'db' | 'cache' | 'queue' | 'file' | 'doc' | 'manual' | 'other'` (externals additionally omit `'db'`), but `kind:` is not equally required on both: a store's `kind:` is its type discriminator, while an external's is optional. An external without `kind:` is not a gap: it renders with the conventional green fill, not an error or an unstyled node. - Externals, stores, and clusters are declared once at `/externals/`, `/stores/`, and `/clusters/`, and shared across every diagram and sub-DFD — there is no per-DFD override. `FlowModel.externals` carries the complete root registry (used by the validator's global-namespace checks); each `FlowDiagram.externals` holds only externals both referenced by that diagram's edges and defined in the root registry. `parseDiagramFolder` never globs for an `externals/` or `stores/` folder nested inside a `flows//` directory: one placed there is silently ignored, not treated as an override, and never raises a parse or validation error. - Structural fingerprints and dotted numbers are never mixed into identity: `layoutFlowFingerprint` deliberately ignores labels, body text, column names, and numbering, so a cosmetic edit never invalidates a cached layout. -- A file named `index.md` inside `flows//`, `externals/`, `stores/`, or `clusters/` is never parsed as a process, external, store, or cluster definition — the `indexFileName` skip applies identically in all four scan sites. A process, external, store, or cluster authored into a file literally named `index.md` is silently dropped: it never parses into the model, and no error or warning flags the loss. +- A file named `index.md` inside `flows//`, `externals/`, `stores/`, or `clusters/` is never parsed as a process, external, store, or cluster definition — the `indexFileName` skip applies identically in all four scan sites. A process, external, store, or cluster authored into a file literally named `index.md` is silently dropped: it never parses into the model, and no error or warning flags the loss. That same file is the one `readDiagramDescription` reads for a diagram's `description:`, so `index.md` is dual-purpose by design in `flows//`: never a process, always the description source. ## Coupling @@ -160,8 +174,8 @@ Class B (`flow.unknown_store`, `flow.unknown_external`, `flow.unknown_process`, - **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)) dynamically imports `parseFlows` for the `validate`, `export`, and `index` commands; each folds flow Class-B errors into the same exit-code-1 decision as entity errors. - **server** ([`src/server/server.ts`](../../src/server/server.ts)) imports `parseFlows`, `validateFlows`, and `buildFlowLayoutKeys` directly for the `/api/flow` route, returning `{ diagrams, entityModel, validation, flowLayoutKeys, clusters }` — `clusters` is `flowModel.clusters`, the full `FlowCluster[]` registry. - **generators** ([`src/generators/app.ts`](../../src/generators/app.ts)) imports `type FlowModel` and `buildFlowLayoutKeys` to embed `window.__FLOW_MODEL__`, `window.__FLOW_LAYOUT_KEYS__`, and `window.__FLOW_CLUSTERS__` (`flowModel.clusters`) into the exported static HTML bundle; a null `flowModel` means no `flows/` directory existed. -- **flow-view** (`src/flow-view/*`) is a separate domain (ELK layout + SVG rendering). `elk-flow-layout.ts`, `flow-layout.ts`, `FlowChrome.tsx`, and `FlowDiagramSvg.tsx` import `type FlowDiagram`/`FlowStoreRef` only, no runtime dependency, but any shape change to `FlowDiagram`, `FlowProcess`, `FlowStoreRef`, or edge endpoint kinds forces a review of all four files. `flow-layout.ts` and the store-stack dialog also import `type FlowCluster` from `flow-clusters.ts` to group stack rows by the same author cluster the DFD edges were expanded from — see [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) / [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) for that grouping's own contract. The frontend reads pre-computed layout keys from `window.__FLOW_LAYOUT_KEYS__` or the `/api/flow` payload rather than importing `flow-fingerprint.ts` directly. -- **router** ([`src/router/build.ts`](../../src/router/build.ts)) imports `FlowDiagram`, `FlowModel`, and `FlowStoreRef` directly and does its own structural walk (`diagram.subDfds.find(d => d.id === process.id)`) to resolve each process's sub-DFD when building router files, duplicating the drill-down lookup pattern documented in Constraints. +- **router** ([`src/router/build.ts`](../../src/router/build.ts)) imports `FlowDiagram`, `FlowModel`, and `FlowStoreRef` directly and does its own structural walk (`diagram.subDfds.find(d => d.id === process.id)`) to resolve each process's sub-DFD when building router files, duplicating the drill-down lookup pattern documented in Constraints. It also reads `diagram.description` directly: a flow folder's router row shows the description, and folds it into that row's hash only when present (`hash = description ? folderDigest([file.digest, description]) : file.digest`), so authoring or rewording a folder's `description:` dirties its parent's digest while a folder without one keeps its prior digest. +- **flow-view** (`src/flow-view/*`) is a separate domain (ELK layout + SVG rendering, and now the navigation model built from the leveled tree). `elk-flow-layout.ts`, `flow-layout.ts`, `FlowChrome.tsx`, and `FlowDiagramSvg.tsx` import `type FlowDiagram`/`FlowStoreRef` only, no runtime dependency, but any shape change to `FlowDiagram`, `FlowProcess`, `FlowStoreRef`, or edge endpoint kinds forces a review of all four files. `flow-layout.ts` and the store-stack dialog also import `type FlowCluster` from `flow-clusters.ts` to group stack rows by the same author cluster the DFD edges were expanded from — see [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) / [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) for that grouping's own contract. [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) (new) imports `type FlowDiagram`/`FlowProcess` and the runtime constant `SYSTEM_PROCESS_ID` from this domain to build the flow index and breadcrumb level menus off the leveled tree; its `describeProcess` reads `process.description ?? sub?.description`, then falls through to `modelDescription` only when `process.id === SYSTEM_PROCESS_ID` (the synthetic whole-system process); an ordinary leaf process with neither its own nor its sub-DFD's description resolves to `undefined` instead. A sub-DFD's own `FlowDiagram.description` is consulted there as a fallback even though `deriveLevels` never copies it anywhere itself. [`test/checks/test-flow-nav.ts`](../../test/checks/test-flow-nav.ts) builds its fixtures with this domain's `deriveLevels`/`CONTEXT_DIAGRAM_ID`/`SYSTEM_PROCESS_ID` but asserts against `flow-nav.ts` exports (`buildFlowIndex`, `levelEntries`, `resolveDiagramPath`), so it belongs to `flow-view`, not this domain. The frontend reads pre-computed layout keys from `window.__FLOW_LAYOUT_KEYS__` or the `/api/flow` payload rather than importing `flow-fingerprint.ts` directly. - **skill** ([`skills/ignatius-modeling/`](../../skills/ignatius-modeling)) authors against this domain's file format without importing it: `references/dfd-authoring.md` and `references/flow-templates.md` author `flows/*.md` and `clusters/*.md` files directly against the `db:`/`ext:`/`:`/`cluster:` token shape and the `label:`/`data:` schema this domain parses, and `references/verification.md` parses `flow.*` and `flow.cluster_*` ruleIds by name in its rule reference table, enumerating `flow.cluster_overlap`, `flow.cluster_entity_unknown`, `flow.unknown_cluster`, `flow.cluster_member_unknown`, `flow.cluster_no_members`, `flow.unknown_attribute`, and `flow.unbalanced_decomposition`. - **frontend** (`src/app/*`): `App.tsx`, `hooks/useModelData.ts`, `logic/doc-resolver.ts`, `logic/search.ts`, `logic/flow-spotlight.ts`, `views/flow/FlowsView.tsx`, `views/dict/DictionaryView.tsx`, `components/process/*`, `components/flow-node/*` (including `StackDialog.tsx`, which imports `type FlowCluster`), and `components/entity/*` import flow types and the usage-index builders directly. `useModelData.ts` holds `flowClusters: FlowCluster[]` state, populated from `window.__FLOW_CLUSTERS__` (static export) or the `/api/flow` payload's `clusters` field (live server). `SYNTHETIC_DIAGRAM_IDS` is imported by `DictionaryView.tsx` to exclude the context/L1 diagrams from the DD sidebar process list. -- **docs**: [`docs/design/process-flows.md`](../design/process-flows.md) / [`docs/spec/process-flows.md`](../spec/process-flows.md) (original DFD design and `flow.*` rule registry contract), [`docs/design/folder-model.md`](../design/folder-model.md) / [`docs/spec/folder-model.md`](../spec/folder-model.md) (root-registry restructure), [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) / [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (arbitrary nesting depth, source of `renumberDiagram`), [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) / [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (leveling half of the layout overhaul), [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) / [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) (the `clusters/` registry and `cluster:` token, source of `flow-clusters.ts`), [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md), [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (canonical SSADM/DFD reference backing the `flow.*` rules), and [`docs/guides/flows.md`](../guides/flows.md) (user-facing authoring guide, documents the `examples:` block's `{in, out}` shape with a worked `Collect-Payment` sample). +- **docs**: [`docs/design/process-flows.md`](../design/process-flows.md) / [`docs/spec/process-flows.md`](../spec/process-flows.md) (original DFD design and `flow.*` rule registry contract), [`docs/design/folder-model.md`](../design/folder-model.md) / [`docs/spec/folder-model.md`](../spec/folder-model.md) (root-registry restructure), [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) / [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (arbitrary nesting depth, source of `renumberDiagram`), [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) / [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (leveling half of the layout overhaul), [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) / [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) (the `clusters/` registry and `cluster:` token, source of `flow-clusters.ts`), [`docs/design/large-model-nav.md`](../design/large-model-nav.md) / [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) (the `FlowDiagram.description` field and its router/L1 propagation, source of `readDiagramDescription`; the flow index and level-menu navigation model this feeds are `flow-view`'s), [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md), [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (canonical SSADM/DFD reference backing the `flow.*` rules), and [`docs/guides/flows.md`](../guides/flows.md) (user-facing authoring guide, documents the `examples:` block's `{in, out}` shape with a worked `Collect-Payment` sample). diff --git a/docs/wiki/frontend.md b/docs/wiki/frontend.md index 5155379..ef85bc6 100644 --- a/docs/wiki/frontend.md +++ b/docs/wiki/frontend.md @@ -8,12 +8,34 @@ tags: [frontend, react, dfd] ## What it does -The unified SPA collapses three surfaces — graph (ERD), dict (data dictionary), and flow (DFDs) — into one React app: `GraphView`, `DictionaryView`, and `FlowsView` stay mounted simultaneously (graph/flow toggle `isActive`; dict toggles CSS `display:none`) so search text, scroll position, and canvas state survive view switches. `useHashRoute` owns the URL hash as the single source of truth for view, selected entity, zoom, pan, DFD deep-links, and — for the flow surface — the per-process/connected rendering mode and the stack collapse level. +The unified SPA collapses three surfaces — graph (ERD), dict (data dictionary), and flow (DFDs) — into one React app: `GraphView`, `DictionaryView`, and `FlowsView` stay mounted simultaneously (graph/flow toggle `isActive`; dict toggles CSS `display:none`) so search text, scroll position, and canvas state survive view switches. `useHashRoute` owns only `view=` and `entity=` in the URL hash; every other hash param is owned by the surface that reads and writes it, and `useHashRoute`'s `popstate` handler only reconciles those surfaces via injected callbacks on Back/Forward. `GraphView` owns `zoom=`/`pan=` directly, through `parseHash`/`serializeHash` from [`src/app/hash-router.ts`](../../src/app/hash-router.ts), bypassing the hook. `FlowsView` owns `dfd=`. `App.tsx` owns `flowview=`/`collapse=`. -[`src/app/App.tsx`](../../src/app/App.tsx) (860 lines) is the entry point. It was decomposed from the original `src/App.tsx` monolith, 5514 lines per `docs/design/app-tsx-decomposition.md:5`, into a layered [`src/app/`](../../src/app) tree: shell → views → components → ui → logic/dom, downward only. [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. +On a large model all three surfaces share one hover-settle and animation-cutoff discipline ([`src/app/logic/motion.ts`](../../src/app/logic/motion.ts), [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md)): a hover focus applies only once the pointer has rested on a target for 300 ms, and a view whose rendered element count passes 150 drops its transitions and scrolls straight to the end state. + +[`src/app/App.tsx`](../../src/app/App.tsx) (862 lines) is the entry point. It was decomposed from the original `src/App.tsx` monolith, 5514 lines per `docs/design/app-tsx-decomposition.md:5`, into a layered [`src/app/`](../../src/app) tree: shell → views → components → ui → logic/dom, downward only. [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. ## How it works +### Hover focus settles before it repaints + +A hover fade or focus repaints only once the pointer has rested on one target for `HOVER_INTENT_MS`, so a pointer sweep across a canvas never triggers it. + +```mermaid +stateDiagram-v2 + [*] --> Settled + Settled --> Waiting: set(target), target != applied + Waiting --> Waiting: set(same pending target), wait unchanged + Waiting --> Waiting: set(third target), wait restarts + Waiting --> Settled: set(target == applied), no repaint + Waiting --> Settled: HOVER_INTENT_MS elapses, applyNow repaints + Settled --> Settled: applyNow(target), repaints at once + Waiting --> Settled: applyNow(target), drops pending, repaints at once +``` + +`createHoverIntent` ([`src/app/logic/motion.ts`](../../src/app/logic/motion.ts)) backs every hover site in the domain: `GraphView`'s node-hover fade, `DictionaryView`'s browse-lens card spotlight, and — via [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx), the flow-view domain — the Flows view's node/edge/chip dimming and edge tooltip. `set(target)` reports whatever the pointer currently sits over; reporting the same target again while its wait is running never restarts the timer, and a target only passed through on the way to another one is never applied, so `A → B` switches focus in one step with no intermediate clear. `applyNow(target)` skips the wait for a deliberate action: in `GraphView`, pressing Shift over a node whose hover is still waiting applies it at once (with lineage), and tapping a node clears the shown hover immediately. Leaving the canvas goes through the normal `set(null)` path instead, so the hover still waits out `HOVER_INTENT_MS` before it clears. + +Above `ANIMATION_ELEMENT_LIMIT` (150, inclusive), `animationsAllowed(renderedElementCount)` returns `false` and a view drops its transitions and smooth scrolls. `GraphView` has no fade transition to drop, so it only ever gets the hover delay. `DictionaryView` sums its currently visible entities (`totalVisible`) and visible processes/externals/stores (`totalFlowVisible`) and marks its root `.dict-view` `data-motion="off"` once the total passes the limit; `scrollBehaviorWithin(el)` reads that marker from any descendant, so `App.tsx`'s process-scroll, `DictionaryView`'s own scroll-to-anchor calls, and `SpotlightOverlay`'s scroll-to-card all jump instead of animating inside a `data-motion="off"` Dictionary. + ### Flow-surface dialog routing A click inside the rendered flow SVG resolves to exactly one of four dialog kinds; `FlowSurface` (in [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx)) enforces a single-dialog-at-a-time invariant across all of them. @@ -35,6 +57,26 @@ Opening any of the five outcomes calls `setOpenResult`, which replaces whatever `EdgeContractDialog` ([`src/app/components/flow-node/EdgeContractDialog.tsx`](../../src/app/components/flow-node/EdgeContractDialog.tsx)) renders the CONTRACT for one data-carrying edge (or every member edge of a stack): a `db:`-endpoint edge resolves each edge's `data` column against the entity model for a Group/Store/Column/Type table sorted by store then by the entity's own column order; any other endpoint kind (`ext:`, `cache:`, `queue:`, …) has no entity-model backing and renders a one-column table of the authored `data` lines instead. `StackDialog` ([`src/app/components/flow-node/StackDialog.tsx`](../../src/app/components/flow-node/StackDialog.tsx)) renders a stack node's row breakdown at the current collapse level: a `store` row is a link that opens the store's entity dialog directly; a `cluster`/`subtype`/`group` row is a `
`/`` disclosure (`.stack-dialog-row-toggle`, [`src/app/styles.css`](../../src/app/styles.css) lines 578-600) that reveals its child rows indented one level, recursively. `StackDialogExtra` renders grouping-source-specific content below the row table: a cluster's markdown body, a subtype row's link to its basetype entity, a group's description, or — for `source: 'adjacency'`, which has no `clusters/` file to draw a body from — a Writers/Readers table of the processes that feed the stack. +### Flow diagram selection resolves to a path before it resolves to a diagram + +A `dfd=` hash value, a flow-search result, and a flow-index or breadcrumb-menu click each name a target diagram differently; `initFlowGraphCore` (in [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx)) folds all three into the same breadcrumb-stack rebuild before anything renders. + +```mermaid +flowchart TD + dfdHash["dfd= hash value / flow search result"] --> selectById["selectDiagramById(ref)"] + selectById --> findRef["findDiagramByRef(allDiagrams, ref)"] + indexRow["FlowIndex / LevelMenu row click (flow-view domain)"] --> selectPath["selectDiagramPath(ids)"] + selectPath --> resolvePath["resolveDiagramPath(allDiagrams, ids)"] + findRef --> found{"path resolved?"} + resolvePath --> found + found -->|no| stay["current diagram stays, no-op"] + found -->|yes| showPath["showPath(path)"] + showPath --> stackFor["stackFor(path) rebuilds the breadcrumb stack"] + stackFor --> render["renderDiagram(path.at(-1))"] +``` + +`findDiagramByRef` and `resolveDiagramPath` live in [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) (flow-view domain; trailing-id-match vs exact-match semantics detailed in [`docs/wiki/flow-view.md`](flow-view.md)'s "Resolving a dfd= reference"): the flow index and breadcrumb level menus call only `selectDiagramPath`, never `selectDiagramById`, because only `resolveDiagramPath`'s exact match avoids landing on the wrong sub-DFD when two flows share a process file name. `stackFor(path)` labels each step with the owning process's dotted number and label, except the root, which is labelled by the diagram's own title. On mount, `initFlowGraphCore` resolves the seeded `dfd=` value through the same `findDiagramByRef` path, falling back to `defaultDiagramPath(allDiagrams)` (flow-view domain — opens the System overview, not the Context diagram) when the seed is absent or unresolvable. `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the rendered diagram's bare `id` on every render, while `onActiveDiagramChange` fires separately with `diagramRef(stack...)`, the path reference `App.tsx` writes into the `dfd=` hash — the two carry different id forms for different consumers (a test hook vs. a deep link). + ### Flow view mode and collapse level `flowview=`/`collapse=` are global settings, not per-diagram: a FAB toggle changes them for every DFD in the session and both are deep-linkable via the URL hash. @@ -48,7 +90,7 @@ flowchart LR state --> remount["FlowsView renderer effect re-mounts (flowView/collapseLevel are effect deps)"] ``` -On load, `App.tsx` resolves each setting hash first, then `localStorage`, then a hardcoded default (`per-process` / `clusters`) — the same precedence pattern `layoutMode` already used for the graph. `nextCollapseLevel` ([`src/app/hash-router.ts`](../../src/app/hash-router.ts)) is a `switch` over the closed `FlowCollapseLevel` union implementing the cycle `stores → clusters → groups → stores`; `FabMenu`'s `collapseActionLabel` names the action by its *target* level ("Collapse to clusters", "Collapse to groups", "Expand to stores") since the last leg of the cycle un-collapses. +On load, `App.tsx` resolves each setting hash first, then `localStorage`, then a hardcoded default (`per-process` / `clusters`), a 3-step hash-then-localStorage-then-default precedence that makes both deep-linkable. `layoutMode` does not share it: its initializer reads only `localStorage.getItem('ignatius-layout-mode')`, with no hash step, so the graph's layout mode is not deep-linkable. `nextCollapseLevel` ([`src/app/hash-router.ts`](../../src/app/hash-router.ts)) is a `switch` over the closed `FlowCollapseLevel` union implementing the cycle `stores → clusters → groups → stores`; `FabMenu`'s `collapseActionLabel` names the action by its *target* level ("Collapse to clusters", "Collapse to groups", "Expand to stores") since the last leg of the cycle un-collapses. A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independent of a fresh FAB toggle: a history entry pushed earlier (e.g. opening an entity) snapshotted whatever `flowview`/`collapse` was current at push time, since the FAB toggle only `replaceState`s the top entry rather than pushing a new one. `useHashRoute`'s `popstate` handler fires `onRestoreFlowView`/`onRestoreCollapseLevel` unconditionally whenever the reconciled hash carries either param, mirroring how it already restores `dfd=`. @@ -58,11 +100,11 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | Path | Role | |---|---| -| [`src/app/App.tsx`](../../src/app/App.tsx) | 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. `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` are shared between the FAB button and the keyboard shortcut `l`. `showHelp` boolean state wires the `?` top-bar button (`.help-toggle`) and `onHelp` passed to `useKeyboardShortcuts`; renders `HelpModal` when true. Owns `flowView`/`collapseLevel` state (seeded hash → localStorage → default `per-process`/`clusters`), `handleToggleFlowView`/`handleCycleCollapseLevel`, and `writeFlowViewHash` (a `history.replaceState` write beside the existing hash params, mirroring how `useHashRoute` writes `view`); both are threaded as props into `FlowsView` and `FabMenu`. **Keyboard pan:** `handleKeyboardPan(dx, dy)` routes the resolver's `{type:'pan'}` action to the active canvas — `graphViewRef.current?.panBy(dx, dy)` on graph, `flowsViewRef.current?.panBy(dx, dy)` on flow, no-op on dict. Interacts with `GraphView`, `DictionaryView`, and `FlowsView` exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). | +| [`src/app/App.tsx`](../../src/app/App.tsx) | 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 (its `scrollIntoView` call uses `scrollBehaviorWithin(el)` from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts), not a hardcoded `'smooth'`). `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` are shared between the FAB button and the keyboard shortcut `l`. `showHelp` boolean state wires the `?` top-bar button (`.help-toggle`) and `onHelp` passed to `useKeyboardShortcuts`; renders `HelpModal` when true. Wires `onFlowIndex: () => flowsViewRef.current?.toggleIndex()` for the `i` shortcut (flow view only). Owns `flowView`/`collapseLevel` state (seeded hash → localStorage → default `per-process`/`clusters`), `handleToggleFlowView`/`handleCycleCollapseLevel`, and `writeFlowViewHash` (a `history.replaceState` write beside the existing hash params, mirroring how `useHashRoute` writes `view`); both are threaded as props into `FlowsView` and `FabMenu`. **Keyboard pan:** `handleKeyboardPan(dx, dy)` routes the resolver's `{type:'pan'}` action to the active canvas — `graphViewRef.current?.panBy(dx, dy)` on graph, `flowsViewRef.current?.panBy(dx, dy)` on flow, no-op on dict. Interacts with `GraphView`, `DictionaryView`, and `FlowsView` exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). | | [`src/app/App.tsx`](../../src/app/App.tsx) (cross-view search) | The shell owns per-surface search state that survives view switches. Graph search: `graphSearchTerm`/`graphSearchIncludeBody` plus `graphSearchCursorRef` (Enter-to-cycle cursor, reset on term/toggle change); `graphSearchMatches` is `null` when inactive or a `Set` (possibly empty) when active — `entityMatches` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts) runs over `model.nodes`. Flow search: `flowSearchTerm`/`flowSearchIncludeBody`; `flowSearchResults` calls `searchFlowDiagrams`; `flowSearchTokens` is threaded into `FlowsView`'s `searchTokens` prop. Neither graph nor flow search state touches the model, layout fingerprint, layout-store, or URL hash. `bannerRef` measures the global-error banner's rendered height via `ResizeObserver` and writes it into the `--search-bar-top` CSS custom property so `SearchBar` sits below the banner. | | [`src/app/App.tsx`](../../src/app/App.tsx) (branding gutter, fix 2026-08-01) | The branding block is `position: fixed`, out of document flow, so no stylesheet can know how much room to leave for it. A `brandingRef`-measuring `useEffect` publishes the block's measured width as the `--branding-gutter` CSS custom property (via `ResizeObserver`, re-published on `[branding, logoSrc]` change since a theme swap can change logo width) so the Dictionary view's full-bleed search bar can indent past it below ~1920px viewport width, where the two previously shared a row and the z-50 branding block painted over the z-30 search input. [`src/app/styles.css`](../../src/app/styles.css)'s `.dict-search-bar-inner` computes `padding-left: max(2rem, calc(16px + var(--branding-gutter, 0px) + 12px - var(--dict-bar-left-slack)))`. | | [`src/app/main.tsx`](../../src/app/main.tsx) | React root mount, reads `window.__MODEL__`/`__THEME_MODE__` globals in static mode. | -| [`src/app/hash-router.ts`](../../src/app/hash-router.ts) | Exports `parseHash`/`serializeHash`, `ViewName`, `FlowViewMode`, `FlowCollapseLevel`, `HashState`, `nextCollapseLevel`. `HashState` carries `view?: 'graph'\|'dict'\|'flow'`, `entity?`, `zoom?`, `pan?`, `dfd?`, `flowview?: 'per-process'\|'connected'`, `collapse?: 'stores'\|'clusters'\|'groups'`. Format: `#view=&entity=&zoom=&pan=,&dfd=&flowview=&collapse=`. All params optional; unknown/malformed values are silently dropped (`VALID_VIEWS`/`VALID_FLOW_VIEWS`/`VALID_COLLAPSE_LEVELS` lookup tables). | +| [`src/app/hash-router.ts`](../../src/app/hash-router.ts) | Exports `parseHash`/`serializeHash`, `ViewName`, `FlowViewMode`, `FlowCollapseLevel`, `HashState`, `nextCollapseLevel`. `HashState` carries `view?: 'graph'\|'dict'\|'flow'`, `entity?`, `zoom?`, `pan?`, `dfd?`, `flowview?: 'per-process'\|'connected'`, `collapse?: 'stores'\|'clusters'\|'groups'`. Format: `#view=&entity=&zoom=&pan=,&dfd=&flowview=&collapse=`. `dfd`'s value is a diagram reference (a bare id, or an id path such as `invoicing/Submit-PCI`) resolved by the flow-view domain's `findDiagramByRef`; `serializeHash` encodes it with `encodeURIComponent` then restores any `%2F` back to `/`, so a path reference stays readable in the address bar instead of percent-encoding its separator. All params optional; unknown/malformed values are silently dropped (`VALID_VIEWS`/`VALID_FLOW_VIEWS`/`VALID_COLLAPSE_LEVELS` lookup tables). | | [`src/app/globals.d.ts`](../../src/app/globals.d.ts) | `window.__MODEL__`, `__THEME_MODE__`, `__IGNATIUS_MODE__` (`'live'\|'static'`), `__LAYOUT_KEY__`, `__FLOW_MODEL__`, `__FLOW_LAYOUT_KEYS__`, `__FLOW_CLUSTERS__` (the model-root `clusters/` registry, sibling to `__FLOW_MODEL__`, read directly by `FlowsView`'s `initFlowGraphCore` to build `buildFlowData`'s connected-view cluster grouping), `__IGNATIUS_CY__`, `__IGNATIUS_CY_GEN__`, `__IGNATIUS_FLOW_READY__`, `__IGNATIUS_FLOW_GEN__`, `__IGNATIUS_ACTIVE_FLOW_DFD__`, `__IGNATIUS_PERF__`. | | [`src/app/index.html`](../../src/app/index.html) | Bun HTML entry point; imported directly by [`src/server/server.ts`](../../src/server/server.ts) (`import index from '../app/index.html'`) as the `Bun.serve()` route. | @@ -73,12 +115,13 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) | Exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode reads `window.__MODEL__`/`__FLOW_MODEL__`/`__FLOW_CLUSTERS__` once on mount. Live mode boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings, flowDiagrams, flowClusters, flowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. `applyFlowPayload` mirrors a non-empty `/api/flow` `clusters` field into both React state (`flowClusters`, returned by the hook) and the `window.__FLOW_CLUSTERS__` global; `App.tsx` does not destructure `flowClusters` from the hook's return, so the live consumer of the cluster registry is `FlowsView`'s `initFlowGraphCore`, which reads `window.__FLOW_CLUSTERS__` directly — the same global-read pattern `__FLOW_MODEL__` uses for the imperative (non-React-tree) renderer. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set `true` in its cleanup; every `setState` inside the boot and SSE-handler promise chains is gated on the flag to survive React StrictMode's dev-mode mount→cleanup→mount double-invoke. | | [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) | Exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. `entity=` in the hash is the single source of truth for the modal stack: `openEntity(id)` pushes history (deduped when the hash already carries the same entity), `closeEntity()` replaces it. `popstate` invokes `onEntityChange(id \| null)` to reconcile the shell without pushing another history entry; it also invokes `onRestoreFlowView(view)`/`onRestoreCollapseLevel(level)` whenever the reconciled hash carries a `flowview=`/`collapse=` value, unconditional on whether it differs from the shell's live state (mirroring how `dfd=` restoration works). Returns `{ view, setView, openEntity, closeEntity }`. | | [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) | Exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage; calls `applyThemeCssVars` on change (also on `model` identity change). Returns `{ themeMode, toggleTheme }`. | -| [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) | Registers exactly ONE global `keydown` listener for the unified SPA shortcuts (g/d/f/l/b/?//` `/Cmd-Ctrl-k/arrows). Stale-closure hazard avoided via a `configRef` updated each render. Editable guard returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported only by `App.tsx`. | +| [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) | Registers exactly ONE global `keydown` listener for the unified SPA shortcuts (g/d/f/l/b/i/?//`/Cmd-Ctrl-k/arrows). `onFlowIndex` (`i`, flow view only) is one of the config callbacks alongside `onView`/`onToggleLayout`/`onToggleLens`/etc. Stale-closure hazard avoided via a `configRef` updated each render. Editable guard returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported only by `App.tsx`. | ### Logic (pure, no DOM/React) | Path | Role | |---|---| +| [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) | Hover timing and the animation cutoff shared by Graph, Dictionary, and (via the flow-view domain) Flows (see How it works). Exports `HOVER_INTENT_MS` (300), `ANIMATION_ELEMENT_LIMIT` (150, inclusive), `animationsAllowed(renderedElementCount)`, `scrollBehaviorWithin(el)` (`'auto'` inside a `[data-motion="off"]` ancestor, else `'smooth'`), and `createHoverIntent(apply, delayMs?)` returning `{ set, applyNow, cancel, applied }`. | | [`src/app/logic/doc-resolver.ts`](../../src/app/logic/doc-resolver.ts) | Exports `buildFlowDocResolver(diagrams, getEntityModel)` and `splitDocToken(token)`. `FlowDocResult` discriminated union: `entity`/`node`/`doc`. Keyed by stable id/slug so `title:` overrides don't break `[[wiki-link]]` resolution. | | [`src/app/logic/flow-node-ids.ts`](../../src/app/logic/flow-node-ids.ts) | Exports `buildAllFlowNodeIds(diagrams, entityModel?)`. Returns `ReadonlySet` merging all process/external/non-db-store ids with ERD entity ids. | | [`src/app/logic/color.ts`](../../src/app/logic/color.ts) | Exports `hexToRgba` and `blendHex`. | @@ -87,7 +130,7 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | [`src/app/logic/relationship-key.ts`](../../src/app/logic/relationship-key.ts) | 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. | | [`src/app/logic/spotlight.ts`](../../src/app/logic/spotlight.ts) | Pure `buildSpotlightConnections(index, entityId): SpotlightConnection[]`, direct (non-inherited) FK connections for the DD browse-lens spotlight overlay. Uses `edgesBySource`/`edgesByTarget` only. Self-edges excluded; all edges to the same otherId bundle into one connection; unknown entityId → `[]`. | | [`src/app/logic/flow-spotlight.ts`](../../src/app/logic/flow-spotlight.ts) | Pure `buildFlowSpotlightConnections(diagrams, activeToken): FlowSpotlightConnection[]`. Token scheme `":"`; entity cards pass `"db:"`. Walks all diagrams + sub-DFDs recursively. | -| [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) | Pure keyboard-shortcut resolver, no DOM/React/Bun/Node imports. Exports `resolveShortcut(e, view, editable): ShortcutAction \| null`, the `ShortcutAction` discriminated union, and `ShortcutKeyEvent`. Keymap: g/d/f view switch, l toggleLayout (graph only), b toggleLens (dict only), `/` search, `?` help, Cmd/Ctrl+`=`/`-`/`0` zoom, Cmd/Ctrl+k search, arrow keys → `{type:'pan', dx, dy}` on graph/flow only. `PAN_STEP = 10`, `PAN_STEP_FAST = 50` (Shift+arrow). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. | +| [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts) | Pure keyboard-shortcut resolver, no DOM/React/Bun/Node imports. Exports `resolveShortcut(e, view, editable): ShortcutAction \| null`, the `ShortcutAction` discriminated union, and `ShortcutKeyEvent`. Keymap: g/d/f view switch, l toggleLayout (graph only), b toggleLens (dict only), i flowIndex (flow only), `/` search, `?` help, Cmd/Ctrl+`=`/`-`/`0` zoom, Cmd/Ctrl+k search, arrow keys → `{type:'pan', dx, dy}` on graph/flow only. `PAN_STEP = 10`, `PAN_STEP_FAST = 50` (Shift+arrow). Key matched on `e.key.toLowerCase()` for capslock-insensitivity. | | [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) | Pure geometry helper separating overlapping spotlight-overlay lines. Exports `separateSpotlightLines(base, directions): SpotlightLineSpec[]`, `SPOTLIGHT_LINE_GAP = 14`. K=1 leaves the base line unchanged; K>1 applies a symmetric perpendicular offset so the centre of mass stays on the base anchor. 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) | Pure key-inheritance LINEAGE logic for the DD browse lens and DG graph. No DOM/React/Bun/Node imports. **Key edge** = an edge whose FK columns (`Object.keys(edge.on)`) are ALL ⊆ the child (source) node's primary key (`index.pkByNode`) — a subset test implementing IDEF1X identifying semantics, catching identifying-1:1 AND identifying-1:many (proper-subset FK). **Associative-entity barrier:** `isAssociative(index, nodeId)` returns true when a node has key edges to 2+ distinct parents whose FK columns together cover its entire PK (a pure junction/link table, e.g. `Project_Tag`) — `buildLineageWithPredecessors`'s BFS treats any such node as a traversal BARRIER (reachable, but the walk stops there) except at the start node itself. `buildLineageWithPredecessors` is a BFS over key edges (undirected) that also records, per member, the nearest key-edge predecessor on the shortest path from the active entity. `buildInheritedConnections(index, entityId): InheritedConnection[]` excludes the entity itself and its direct real-edge neighbors; `via` is `INHERITED_IDENTITY` when the predecessor is the active entity itself, else the nearest key-edge kin id; `direction` is always `'out'`. Result sorted ascending by `otherId`; singleton lineage → `[]`. | @@ -105,7 +148,7 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | `Modal.tsx` | Shared modal primitive (title + onClose + children + optional `headerExtra`). | | `ZoomControl.tsx` | View-agnostic zoom readout. Props: `percent`, `onZoomIn`, `onZoomOut`, `onSetPercent(pct)`, `onReset`. Clicking the readout opens an inline commit-on-Enter/blur text input. | | `FabMenu.tsx` | Per-view FAB menus, items view-gated. `kbd-hint` badges surface keyboard shortcuts inline. Flow-specific items (no `kbd-hint`): a per-process/connected toggle button labelled by the *current* mode's opposite (`"Connected view"` while `flowView === 'per-process'`, else `"Per-process view"`), and a collapse-level cycle button labelled by the *target* level via `collapseActionLabel` (`"Collapse to clusters"`, `"Collapse to groups"`, `"Expand to stores"`). | -| `HelpModal.tsx` | View-aware orientation overlay built on `Modal`. Three branches (graph/dict/flow), each documenting that surface's search behavior and closing with a `shortcutRows(view)`-driven keyboard section. | +| `HelpModal.tsx` | View-aware orientation overlay built on `Modal`. Three branches (graph/dict/flow), each documenting that surface's search behavior and closing with a `shortcutRows(view)`-driven keyboard section. The flow branch's `FLOW_EXPLORE` rows cover the flow index ("Click Process Flows (or press I)…") and breadcrumb level switching ("A ▾ on a breadcrumb lists the other diagrams at that level…"); `shortcutRows('flow')` adds an `I` row ("Open or close the flow index"). | | `SearchBar.tsx` | Exports `SearchBar` (`forwardRef`) and `SearchBarHandle` (`{ focus(): void }`). Shared search-bar chrome for the Graph and Flows surfaces: debounced (200ms) ``, a `role="switch"` "Include descriptions" toggle, a match-count readout, and a `children` slot for a results dropdown. | ### Flow search components (`components/flow/`) @@ -126,14 +169,14 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | `ExamplesAccordion.tsx` | Examples accordion, `variant='modal'\|'dict'`. | | `GridCard.tsx` | Exports `GridCard`, compact entity card for the DD browse lens. | | `FlowNodeGridCard.tsx` | Exports `ProcessGridCard`, `ExternalGridCard`, `StoreGridCard`. | -| `SpotlightOverlay.tsx` | The largest single component in the domain. Exports `SpotlightOverlay`. Position:fixed SVG + chips container over the DD browse grid. FK connections draw SOLID bezier paths (direction-coded stroke, arrowhead placement, predicate + cardinality pills). Flow connections draw DASHED paths (`--spotlight-line-flow`). Inherited (lineage) connections draw DOTTED paths in `--spotlight-line-inherited`, computed via `computeInheritedLines` from `buildInheritedConnections`'s output, with a provenance pill ("shared key" for `INHERITED_IDENTITY`, else "via ``"). Off-screen connections render as clickable directional chips instead of a line. Anchors re-measured every rAF-throttled frame via `ResizeObserver` on the grid container, `window resize`, and scroll on `.dict-view`. Calls `separateSpotlightLines` so overlapping edge bundles render as offset parallel paths. | +| `SpotlightOverlay.tsx` | The largest single component in the domain. Exports `SpotlightOverlay`. Position:fixed SVG + chips container over the DD browse grid. FK connections draw SOLID bezier paths (direction-coded stroke, arrowhead placement, predicate + cardinality pills). Flow connections draw DASHED paths (`--spotlight-line-flow`). Inherited (lineage) connections draw DOTTED paths in `--spotlight-line-inherited`, computed via `computeInheritedLines` from `buildInheritedConnections`'s output, with a provenance pill ("shared key" for `INHERITED_IDENTITY`, else "via ``"). Off-screen connections render as clickable directional chips instead of a line. Anchors re-measured every rAF-throttled frame via `ResizeObserver` on the grid container, `window resize`, and scroll on `.dict-view`. Calls `separateSpotlightLines` so overlapping edge bundles render as offset parallel paths. Scrolling a pinned card into view calls `scrollBehaviorWithin(targetCard)` (falling back to `'auto'` under `prefers-reduced-motion`) instead of a hardcoded `'smooth'`, so the scroll jumps inside a `data-motion="off"` Dictionary. | ### Process components (`components/process/`) | Path | Role | |---|---| | `IoTable.tsx` | Exports `IoTable` (a process's inputs/outputs table used by both the Dictionary and flow-node dialogs) and `resolveIoRowCells` (pure, no React import, exercised directly by unit tests — resolves the Data-cell value(s) for one row: a `db:` endpoint with a `label:` collapses to one labelled row instead of one row per column, an unlabelled `db:` endpoint keeps one row per column, and a non-`db` endpoint is always a single row). Accepts optional `onOpenEntity`/`onOpenToken`/`canOpenToken`: when provided, a `db:` endpoint cell renders as a rich entity link that opens the entity dialog directly and a resolvable non-`db` endpoint opens in-place via the flow token resolver, instead of falling back to dict scroll-to-anchor. Imported by `ProcessCard.tsx` (Dictionary) and `FlowNodeModal.tsx` (flow dialogs, which passes the rich-link props). | -| `KindMarker.tsx` | Exports `FlowKindMarker`. | +| `KindMarker.tsx` | Exports `KindMarker({ ep, processes })`: a `proc` endpoint renders the process's dotted number, an `ext` endpoint renders `"ext"`, and any store kind renders `FLOW_STORE_KIND_SYMBOLS[ep.kind]` (from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)) with a `flow-kind-marker--db` modifier for `db:`. | | `ProcessExamples.tsx` | Exports `FlowProcessExamplesSection`. | | `ProcessCard.tsx` | Process DD card (`DictProcessSection`). | | `ProcessesTable.tsx` | Exports `DictProcessesTable`. | @@ -160,24 +203,24 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | Path | Role | |---|---| -| `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Lineage trigger is SHIFT+HOVER, not click/select:** `cy.on('mouseover', 'node')` branches on `evt.originalEvent?.shiftKey` — shift held draws ephemeral `edge.inherited`-class cy edges via `buildInheritedConnections` plus 3-tier focus opacity; no shift is a plain direct-neighbor dim. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. | +| `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Hover settles before it repaints:** `cy.on('mouseover'/'mouseout', 'node')` feed `graphHover` (a `createHoverIntent` from `logic/motion.ts`) instead of restyling directly; `showHover(nodeId)` is the `apply` callback — it restores the leaving node's forward predicates, then, for a settled non-null target, shows its reverse predicates plus (`shiftHeld`) either lineage via `enterLineageHover` or a plain `applyFocusTiers` fade, falling back to the selected node's tiers or a full clear on `null`. `shiftHeld` (a plain `let` local to the cy-init effect, updated by the `mouseover` handler's event and by document-level `onShiftKeyDown`/`onShiftKeyUp` listeners) and `hoveredNodeIdRef` (a ref) track live Shift and pointer state so `onShiftKeyDown` can call `graphHover.applyNow(hoveredId)` at once when Shift is pressed over a still-waiting hover. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. | | `views/graph/organic-layout.ts` | Organic (fCoSE-based) layout engine built on `cytoscape-fcose`. `ORGANIC_FALLBACK_THRESHOLD = 500` entities before falling back to layered. `buildScratchCore` runs the multi-seed layout search on a headless mirror core so only winning positions touch the live core; `groupRegions: true` (entity count ≥ 150) wraps each color family in an invisible compound parent so fCoSE decomposes large models into per-family sub-layouts. `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline (expand, fan subtype clusters, dock leaves/isolates, deoverlap). `gradeEdgeSpans(cy)` grades every edge's `span` (`'near'\|'mid'\|'far'`) by percentile within the layout's own length distribution, for `styles.ts`'s length-graded de-emphasis. | | `views/graph/navigator.ts` | `mountNavigator`/`teardownNavigator`/`NavigatorInstance`, cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` before `nav.destroy()` to avoid a resize-listener leak that fires on a destroyed core. | | `views/graph/styles.ts` | `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. `.faded` (0.2), `.inherited-dim` (0.5), `edge.inherited` dotted at 0.5 opacity. Length-graded de-emphasis on `edge[span]`. Graph search: `SEARCH_MATCH_BORDER` gold/yellow border on `.search-match`, `.search-dim` at 0.2 opacity, both pushed last in the stylesheet array to win the cascade. | | `views/graph/markers.ts` | Exports `drawWarningBadges(cy, svg, entityIds: Set)`, `createMarkerOverlay(container)`, `updateMarkers(cy, svg, theme, mode?)`. | | `views/graph/wrap-label.ts` | `wrapEntityLabel`: underscores → spaces; names longer than ~13 chars break at PascalCase/acronym/digit boundaries. | | `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). | -| `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); }`. 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, `beforeprint`/`afterprint` print handling, DD sidebar process nesting. **Browse lens** (`'read'\|'browse'`, persisted to localStorage): entity groups + Processes/External Entities/Data Stores sections; spotlight state `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. | -| `views/flow/FlowsView.tsx` | Exports `FlowsView` (forwardRef), `FlowsViewHandle`, `FlowSurface` (the stateful dialog-hosting wrapper around `FlowDiagramSvg`, see How it works), `FlowChromeCallbacks`, and `initFlowGraphCore` (the imperative renderer lifecycle both static and live modes call). `FlowsViewProps` carries `flowView: FlowViewMode` and `collapseLevel: FlowCollapseLevel`, both owned by the shell; a change to either is an effect dependency of the renderer-mount effect, so toggling either re-mounts `initFlowGraphCore` and every diagram in the session picks up the new opts. `renderDiagram` calls `computeElkLayout(diagram, flowDataOpts)` from [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and passes `elkPositions`/`elkEdgeRoutes` into `FlowDiagramSvg`; falls back to a banded `computeFlowLayout` only on ELK failure. `FlowsViewHandle` exposes `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. **`disposed` teardown guard:** `initFlowGraphCore` declares `let disposed = false`; the async `renderDiagram` checks it before and after every `await computeElkLayout(...)` to guard against an orphaned continuation resuming after React StrictMode's dev-mode mount→cleanup→mount. | +| `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); }`. 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, `beforeprint`/`afterprint` print handling, DD sidebar process nesting. **Browse lens** (`'read'\|'browse'`, persisted to localStorage): entity groups + Processes/External Entities/Data Stores sections; spotlight state `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId`. A card's hover goes through `cardHover`, a `createHoverIntent` instance whose `apply` callback sets `hoverId` and, when the settled card is a spotlit-but-not-active card, `labelHoverCardId`; switching lens calls `cardHover.applyNow(null)` to clear at once. The root `.dict-view` gets `data-motion="off"` when `animationsAllowed(totalVisible + totalFlowVisible)` is `false` (see How it works), and every `scrollIntoView` call in the file (`scrollToEntity`, `scrollToSection`, `scrollToMissing`, `scrollToProcess`) uses `scrollBehaviorWithin(el)` instead of a hardcoded `'smooth'`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. | +| `views/flow/FlowsView.tsx` | Exports `FlowsView` (forwardRef), `FlowsViewHandle`, `FlowSurface` (the stateful dialog-hosting wrapper around `FlowDiagramSvg`, see How it works), `FlowChromeCallbacks`, and `initFlowGraphCore` (the imperative renderer lifecycle both static and live modes call). `FlowsViewProps` carries `flowView: FlowViewMode` and `collapseLevel: FlowCollapseLevel`, both owned by the shell; a change to either is an effect dependency of the renderer-mount effect, so toggling either re-mounts `initFlowGraphCore` and every diagram in the session picks up the new opts. `renderDiagram` calls `computeElkLayout(diagram, flowDataOpts)` from [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and passes `elkPositions`/`elkEdgeRoutes` into `FlowDiagramSvg`; falls back to a banded `computeFlowLayout` only on ELK failure. `FlowsViewHandle` exposes `selectDiagramById(ref)` (resolves a `dfd=`-style diagram reference — a bare id or a path such as `invoicing/Submit-PCI` — via `findDiagramByRef` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts)), `toggleIndex()` (delegates to `flowChromeRef.current?.toggleIndex()`, the `i`-key entry point), `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. `initFlowGraphCore` exposes both `selectDiagramById(ref)` and `selectDiagramPath(ids)` (walks `resolveDiagramPath`, exact — so two flows sharing a process file name resolve to the right one); both funnel into an internal `showPath(path)` that replaces the breadcrumb stack via `stackFor(path)` (each step's label is the owning process's dotted number + label, or the diagram's own title at the root) and renders the path's last diagram (see How it works). On mount, the starting diagram resolves the seeded `dfd=` reference the same way, falling back to `defaultDiagramPath(allDiagrams)` (flow-view domain — opens the System overview) when it is absent or unresolvable. `onRegisterHandlers(drillUp, selectDiagram, selectPath)` hands all three selectors up to `FlowChrome` (flow-view domain) for the breadcrumb level menus and the flow index. `FlowChrome` receives `modelName`/`modelDescription` from `getEntityModel()?._meta` for the index's whole-system description fallback. `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the bare `diagram.id` on every render; `onActiveDiagramChange` fires separately with the path reference (`diagramRef(stack...)`) that `App.tsx` writes into the `dfd=` hash. **`disposed` teardown guard:** `initFlowGraphCore` declares `let disposed = false`; the async `renderDiagram` checks it before and after every `await computeElkLayout(...)` to guard against an orphaned continuation resuming after React StrictMode's dev-mode mount→cleanup→mount. | | `views/flow/LegendModal.tsx` | `LegendModal` component; imports `DARK_PALETTE`/`LIGHT_PALETTE`/`FlowPalette` from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). | ### Other | Path | Role | |---|---| -| [`src/app/styles.css`](../../src/app/styles.css) (2830L+) | Full SPA stylesheet. `@media print` block, `::highlight(dd-search-highlight)`, `.dict-process-direction` badges, `.flow-minimap-wrapper`, `.zoom-control`, `.kbd-hint`, `.flow-edge-tooltip`, `.help-toggle`/`.help-modal`/`.help-section*` (help overlay), `.stack-dialog-row-toggle` (the `
` disclosure marker for `StackDialog` rows), DD chrome (`.dict-view`, `.dict-search-bar`, `.dict-browse-lens`, `.dict-grid-card`, `.spotlight-overlay`, `.spotlight-line*`), the shared Graph/Flows `.viewer-search-bar`/`.viewer-search-results` chrome, and the `.dict-search-bar-inner` branding-gutter padding rule. | +| [`src/app/styles.css`](../../src/app/styles.css) (3374L) | Full SPA stylesheet. `@media print` block, `::highlight(dd-search-highlight)`, `.dict-process-direction` badges, `.flow-minimap-wrapper` (now fixed at `left: 16px` — the DFD nav card it used to step aside for is gone), `.zoom-control`, `.kbd-hint`, `.flow-edge-tooltip`, `.help-toggle`/`.help-modal`/`.help-section*` (help overlay), `.stack-dialog-row-toggle` (the `
` disclosure marker for `StackDialog` rows), `[data-motion="off"]` (drops `.dict-grid-card--dim`/`--spotlit` transitions), the flow-view domain's `.flow-crumbs*`/`.flow-crumb*` (breadcrumbs with a ▾ level-menu trigger), `.flow-level-menu*` (a crumb's level dropdown), and `.flow-index*` (the process-hierarchy chart + description pane) — stacked crumbs (z-46) above the flow search bar (45) so a level menu drops over it, the index (56) above the minimap (50) and zoom control (55), all under the FAB (60) — DD chrome (`.dict-view`, `.dict-search-bar`, `.dict-browse-lens`, `.dict-grid-card`, `.spotlight-overlay`, `.spotlight-line*`), the shared Graph/Flows `.viewer-search-bar`/`.viewer-search-results` chrome, and the `.dict-search-bar-inner` branding-gutter padding rule. | | [`src/app/hash-router.ts`](../../src/app/hash-router.ts) (fromFlow flag) | `openEntityById(id, fromFlow = false)` — when `true`, the modal's FK links, body `[[wiki-links]]`, and process-usage links stay in-place over the Flows view instead of switching to graph/dict. | -| [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) (kind-colored stores/externals) | `FlowsView` calls `resolveFlowKindPalette(themeMode, themeConfig?.flowKinds)` and passes the palette into `FlowDiagramSvg`. | +| [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) (kind-colored stores/externals) | `FlowsView` calls `resolveFlowKindPalette(themeMode, themeConfig?.flowKinds)` and passes the palette into `FlowDiagramSvg`. `KindMarker` reads `FLOW_STORE_KIND_SYMBOLS` from the same module for its non-`proc`/`ext` glyph. | | [`src/model/model-index.ts`](../../src/model/model-index.ts) (ModelIndex wiring) | `buildModelIndex(model)` is called once per Model in `App.tsx` via `useMemo`; `modelIndexRef` mirrors the live value for cy-init closures. | | `views/graph/layout-store.ts` (preset-layout cache-skip) | On a repeat graph load whose `layoutKey` matches a saved position set in `layout-store`, cy is constructed with `layout: { name: 'preset' }` and ELK does not run. | @@ -189,24 +232,28 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen - **`useModelData`'s `flowClusters` return value is not read by `App.tsx`.** The live consumer of the cluster registry is `FlowsView`'s `initFlowGraphCore`, which reads `window.__FLOW_CLUSTERS__` directly rather than through a prop. - **`IoTable`'s rich-link props are opt-in.** Without `onOpenEntity`/`onOpenToken`/`canOpenToken`, a `db:` cell falls back to `onScrollToEntity` (dict scroll-to-anchor) and a non-`db` cell renders as plain text — only `FlowNodeModal` passes the rich-link props. - **Layer rule is downward-only by discipline, not build enforcement.** Shell (`App.tsx`) → views (`views/*/`) → components (`components/*/`) → ui (`components/ui/`) → logic/dom (`logic/`, `dom/`); a lower layer importing from a higher one (or `logic/`/`dom/` importing React/DOM) reintroduces the tangled coupling the `src/App.tsx` decomposition ([`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md)) removed. -- **Pure logic modules stay DOM/React-free by discipline.** `logic/spotlight.ts`, `logic/spotlight-inherited.ts`, `logic/spotlight-lines.ts`, `logic/shortcuts.ts`, `logic/search.ts`, `logic/flow-spotlight.ts`, `logic/doc-resolver.ts`, `logic/finding-rows.ts`, `logic/relationship-key.ts`, `logic/color.ts`, `logic/flow-node-ids.ts`, and `components/process/IoTable.tsx`'s `resolveIoRowCells` are browser-safe and independently unit-testable; importing React or a DOM API into any of them breaks that testability and the reuse across `App.tsx`/view components it enables. +- **Pure logic modules stay DOM/React-free by discipline.** `logic/motion.ts`, `logic/spotlight.ts`, `logic/spotlight-inherited.ts`, `logic/spotlight-lines.ts`, `logic/shortcuts.ts`, `logic/search.ts`, `logic/flow-spotlight.ts`, `logic/doc-resolver.ts`, `logic/finding-rows.ts`, `logic/relationship-key.ts`, `logic/color.ts`, `logic/flow-node-ids.ts`, and `components/process/IoTable.tsx`'s `resolveIoRowCells` are browser-safe and independently unit-testable; importing React or a DOM API into any of them breaks that testability and the reuse across `App.tsx`/view components (and, for `motion.ts`, the flow-view domain) it enables. +- **Every hover site in this domain funnels through `createHoverIntent`.** `GraphView`'s node fade and `DictionaryView`'s card spotlight each own one `createHoverIntent` instance; restyling directly from a `mouseover`/`mouseenter` handler instead of through the instance's `apply` callback reintroduces the per-element repaint-on-sweep the hover-intent design ([`docs/design/large-model-nav.md`](../design/large-model-nav.md)) removed. +- **`ANIMATION_ELEMENT_LIMIT` is counted per view, not globally.** `GraphView` never counts it (it has no fade transition to drop); `DictionaryView` counts visible entities plus visible processes/externals/stores; the flow-view domain's `FlowDiagramSvg` counts its own rendered nodes plus edges. A shared global counter would trip the Dictionary's cutoff off changes on an unrelated canvas. - **Views own their imperative handles; the shell never reaches in directly.** `GraphView`, `DictionaryView`, and `FlowsView` are `forwardRef` components exposing a typed `*ViewHandle` interface (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). Bypassing the handle to touch a view's internals from `App.tsx` recreates the tight shell/view coupling the decomposition removed. - **`variant='modal'\|'dict'` is the convention for dual-context display components, not a separate component per host.** `ColumnsTable`, `ChildrenTable`, and `ExamplesAccordion` render the same data differently depending on whether they sit inside `EntityModal` or the Dictionary page. Splitting a variant into its own host-named component (as the pre-decomposition monolith did with `ColumnsTable` vs `DictColumnsTable`) reintroduces the near-clone duplication [`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) documents as the original problem. -- **Ref-mirrors (`modelIndexRef`, `entityModelRef`, `openEntityByIdRef`, `hoveredNodeIdRef` in `GraphView`) exist to avoid stale closures.** They mirror `useState`/`useMemo` values into refs so long-lived imperative callbacks (Cytoscape event handlers, SSE handlers) read the current value; without the mirror, such a callback captures a stale value from whichever render attached it. +- **Ref-mirrors (`App.tsx`'s `openEntityByIdRef`, `entityModelRef`, and `modelIndexRef`; `GraphView`'s own `modelIndexRef` and `hoveredNodeIdRef`) exist to avoid stale closures.** They mirror `useState`/`useMemo` values (or live pointer state) into refs so long-lived imperative callbacks (Cytoscape event handlers, SSE handlers, the deferred `createHoverIntent` apply) read the current value; without the mirror, such a callback captures a stale value from whichever render attached it. `GraphView`'s Shift-held flag is not one of these: `shiftHeld` is a plain `let` local to the cy-init effect, because every reader (`showHover`, `onShiftKeyDown`/`onShiftKeyUp`, the `mouseover` handler) is declared inside that same effect closure and already shares its scope. - **`graphSearchMatches`/`flowSearchTokens` use `null` for no active search and a `Set` (possibly empty) for an active one.** `GraphView` and `FlowsView`/`FlowDiagramSvg` branch on this exact null-vs-Set distinction; substituting a string-emptiness check treats an active-but-empty-result search the same as no search, breaking the dim/highlight behavior. - **`layoutMode`, `flowView`, and `collapseLevel` resolve hash → localStorage → hardcoded default on mount, and each writer persists to localStorage before writing the hash.** Reversing the write order (hash before localStorage) leaves the two out of sync, so a later hash → localStorage → default resolution can restore a superseded value instead of the last one the user set. +- **Flow index and level-menu navigation resolves by exact id path, never by reference matching.** `FlowsView`'s `selectDiagramPath(ids)` walks `resolveDiagramPath` (flow-view domain) against the leveled tree exactly; `selectDiagramById(ref)` instead resolves a `dfd=`-style reference through `findDiagramByRef`'s trailing-id match, which can still land on the wrong sub-DFD for a bare single-segment reference when two flows share a process file name — so the flow-view domain's `FlowIndex`/`LevelMenu` always call the path selector, never the by-id one. ## Coupling -- **model** ([`src/model/parse.ts`](../../src/model/parse.ts), `model-index.ts`, `validate.ts`) — `App.tsx` and most views/components import `Model`, `ModelNode`, `ModelEdge`, `ModelIndex`, `buildModelIndex`, `GroupConfig`, and validation `RULES`/`EntityError` directly. `EdgeContractDialog` and `StackDialog` both import `GroupConfig`/`Model` for column-type and group-label resolution. A change to the `Model` or `ModelIndex` shape forces changes throughout [`src/app/`](../../src/app). -- **flows** ([`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts), `flow-parse.ts`, `flow-validate.ts`, `flow-clusters.ts`) — `DictionaryView` and `FlowsView` import `SYNTHETIC_DIAGRAM_IDS` and flow-diagram types; `logic/flow-node-ids.ts` and `logic/search.ts` build on flow diagram shapes; `useModelData` types its `FlowApiPayload.clusters` as `FlowCluster[]` from `flow-clusters.ts`; `StackDialog` imports `FlowCluster` for its author-cluster body lookup. A change to flow diagram structure, leveling, or the cluster registry shape forces changes here. -- **flow-view** ([`src/flow-view/`](../../src/flow-view) — separate domain, ELK/SVG rendering) — `FlowsView.tsx`, `LegendModal.tsx`, `StackDialog.tsx`, and `EdgeContractDialog.tsx` import from it: `FlowDiagramSvg`, `FlowChrome`, `computeElkLayout`, `screenScaleToPercent`/`percentToScreenScale`, the `DARK_PALETTE`/`LIGHT_PALETTE` constants, `normalizeEdgeData`, and the `StackNodeData`/`StackRow`/`StackMember` types that back the stack dialog. Coupling runs both ways: [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) imports the `PositionMap` type from [`src/app/views/graph/layout-store.ts`](../../src/app/views/graph/layout-store.ts). A change to `FlowDiagramSvg`'s prop contract, `StackNodeData`'s shape, or ELK layout output forces a change in `FlowsView.tsx` and/or the two dialog components. -- **theme** ([`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)) — `App.tsx`, `dom/theme-css-vars.ts`, `hooks/useThemeMode.ts`, `views/graph/styles.ts`, `views/graph/markers.ts`, `views/flow/LegendModal.tsx`, `views/flow/FlowsView.tsx`, `views/dict/DictionaryView.tsx`, and `components/entity/FlowNodeGridCard.tsx` all consume `semanticColors`/`resolveFlowKindPalette`/theme config types. A theme-shape change ripples widely through this domain. +- **model** ([`src/model/parse.ts`](../../src/model/parse.ts), `model-index.ts`, `validate.ts`) — `App.tsx` and most views/components import `Model`, `ModelNode`, `ModelEdge`, `ModelIndex`, `buildModelIndex`, `GroupConfig`, and validation `RULES`/`EntityError` directly. `EdgeContractDialog` and `StackDialog` both import `GroupConfig`/`Model` for column-type and group-label resolution. `FlowsView` reads `Model._meta` (`name`/`desc`) and passes it into `FlowChrome` as the flow index's whole-system description fallback. A change to the `Model` or `ModelIndex` shape forces changes throughout [`src/app/`](../../src/app). +- **flows** ([`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts), `flow-parse.ts`, `flow-validate.ts`, `flow-clusters.ts`) — `DictionaryView` and `FlowsView` import `SYNTHETIC_DIAGRAM_IDS` and flow-diagram types; `logic/flow-node-ids.ts` and `logic/search.ts` build on flow diagram shapes; `useModelData` types its `FlowApiPayload.clusters` as `FlowCluster[]` from `flow-clusters.ts`; `StackDialog` imports `FlowCluster` for its author-cluster body lookup. `FlowDiagram.description` (from a flow folder's `index.md` frontmatter) flows through to the flow-view domain's `FlowIndex`/`LevelMenu`, not through [`src/app/`](../../src/app). A change to flow diagram structure, leveling, or the cluster registry shape forces changes here. +- **flow-view** ([`src/flow-view/`](../../src/flow-view) — separate domain, ELK/SVG rendering, breadcrumbs, and the flow index) — `FlowsView.tsx`, `LegendModal.tsx`, `StackDialog.tsx`, and `EdgeContractDialog.tsx` import from it: `FlowDiagramSvg`, `FlowChrome`, `computeElkLayout`, `screenScaleToPercent`/`percentToScreenScale`, the `DARK_PALETTE`/`LIGHT_PALETTE` constants, `normalizeEdgeData`, and the `StackNodeData`/`StackRow`/`StackMember` types that back the stack dialog. `FlowsView` also imports `resolveDiagramPath` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) for its `selectDiagramPath` handler, and calls `flowChromeRef.current?.toggleIndex()` (the `FlowChrome` imperative handle) to open/close the flow index on `i`. Coupling runs both ways: [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) imports the `PositionMap` type from [`src/app/views/graph/layout-store.ts`](../../src/app/views/graph/layout-store.ts) and, for its own hover-settle and animation-cutoff behavior, `createHoverIntent`/`animationsAllowed` from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts). A change to `FlowDiagramSvg`'s prop contract, `FlowChrome`'s `onSelectPath`/`toggleIndex` contract, `StackNodeData`'s shape, `motion.ts`'s exports, or ELK layout output forces a change in `FlowsView.tsx` and/or the two dialog components. +- **theme** ([`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)) — `App.tsx`, `dom/theme-css-vars.ts`, `hooks/useThemeMode.ts`, `views/graph/styles.ts`, `views/graph/markers.ts`, `views/flow/LegendModal.tsx`, `views/flow/FlowsView.tsx`, `views/dict/DictionaryView.tsx`, `components/process/KindMarker.tsx`, and `components/entity/FlowNodeGridCard.tsx` all consume `semanticColors`/`resolveFlowKindPalette`/`FLOW_STORE_KIND_SYMBOLS`/theme config types. A theme-shape change ripples widely through this domain. - **server** ([`src/server/`](../../src/server)) — [`src/server/server.ts`](../../src/server/server.ts) imports [`src/app/index.html`](../../src/app/index.html) directly as its `Bun.serve()` HTML route; the frontend build/dev flow is driven by the server domain, not a separate bundler step. `/api/flow`'s response shape (including its `clusters` field) is the contract `useModelData`'s `FlowApiPayload` type encodes. - **generators** — no direct import coupling found from [`src/app/`](../../src/app); the generated static HTML/model output is what static mode's `window.__MODEL__`/`window.__FLOW_MODEL__`/`window.__FLOW_CLUSTERS__` globals are populated with, so a change to what the generator embeds can affect `useModelData`'s static-mode read path. ### Related design docs +- [`docs/design/large-model-nav.md`](../design/large-model-nav.md) and [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) — hover-settle timing and the animation cutoff (`logic/motion.ts`), and the flow index/breadcrumb-level-menu navigation model. - [`docs/design/unified-app.md`](../design/unified-app.md) — collapsing graph/dict/flow into one React surface. - [`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) — the original `App.tsx` monolith breakup into [`src/app/`](../../src/app). - [`docs/design/src-root-organization.md`](../design/src-root-organization.md) — where [`src/app/`](../../src/app) sits among the repo's other domains. @@ -222,3 +269,4 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen - [`docs/design/branding.md`](../design/branding.md) — the branding block / logo / footer system. - [`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) — `[[wiki-link]]` resolution in entity/process bodies, implemented by `logic/doc-resolver.ts`. - [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) and [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) — the stack/cluster/collapse-level feature: `StackDialog`, `EdgeContractDialog`, `flowview=`/`collapse=`, and the FAB's per-process/connected and collapse-level controls. + diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 105b0cc..487ec26 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 -9c6f147844079f71030a8682d3a023fa5ade561e +5710610ff0c9fda928e9b5d0f8f6e595a2d1057a 1 # Project signals @@ -35,8 +35,8 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif `bun run test` runs a shell loop over `test/checks/*.ts` in order; exits 1 on first failure. CI ([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)) runs the same `test/checks/*.ts` loop after building the binary. [`test/`](../../test) is not a formal test-framework suite — there are no `*.test.ts` files and nothing imports `bun:test`; a bare `bun test` finds 0 files and exits 1. -- [`test/checks/`](../../test/checks) — 103 raw assertion scripts (PASS/FAIL/throw), run by `bun run test` and CI. Needs `dist/` present (`bun run build:cli` first). Which check covers which behavior is documented per-domain in each `docs/wiki/.md`. -- [`test/visual/`](../../test/visual) — 66 Playwright screenshot scripts for manual visual inspection. NOT run by `bun run test`. +- [`test/checks/`](../../test/checks) — 108 raw assertion scripts (PASS/FAIL/throw), run by `bun run test` and CI. Needs `dist/` present (`bun run build:cli` first). Which check covers which behavior is documented per-domain in each `docs/wiki/.md`. +- [`test/visual/`](../../test/visual) — 67 Playwright screenshot scripts for manual visual inspection. NOT run by `bun run test`. - [`test/fixtures/`](../../test/fixtures) — YAML fixtures and 7 fixture model roots (`flows-leveling/`, `flows-model/`, `broken-flows-model/`, `broken-flow/`, `flows/`, `hub-dfd/`, `subtype-no-basetype/`), all using the v0.11.0 folder layout (`data/`, `groups/`, `externals/`, `stores/`). `broken-flows-model/` and `hub-dfd/` add a `clusters/` folder for the DFD store-cluster feature. - [`test/notes/`](../../test/notes) — 2 markdown dev notes. - [`test/assert.ts`](../../test/assert.ts) — shared assertion helper. @@ -47,9 +47,9 @@ No linter or formatter configured in package.json. | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 69146 | 300 | 70% | -| Markdown | 24478 | 414 | 24% | -| CSS | 3146 | 2 | 3% | +| TypeScript | 70866 | 310 | 70% | +| Markdown | 25027 | 417 | 24% | +| CSS | 3557 | 2 | 3% | | YAML | 1348 | 16 | 1% | | Shell | 116 | 1 | 0% | | JSON | 107 | 4 | 0% | @@ -62,7 +62,7 @@ No linter or formatter configured in package.json. - CI pipeline: install deps → cache Playwright → build bundle + stable-names → compile binary → run all `test/checks/*.ts` → typecheck (`continue-on-error: true`). - Release pipeline: [`.github/workflows/release-please.yml`](../../.github/workflows/release-please.yml) (release-please driven; a `build` job gated on `release_created` compiles the 5 platform binaries + checksums and attaches them to the release in the same push-to-main run). [`install.sh`](../../install.sh) (repo root) is the curl-able CLI installer that pulls those binaries from `releases/latest/download`. - Binary is built locally or in CI via `bun run build:cli`; produces `dist/ignatius`. -- package.json `name` is `ignatius`, version is `0.18.0`. +- package.json `name` is `ignatius`, version is `0.20.2`. --- @@ -70,19 +70,19 @@ No linter or formatter configured in package.json. | Domain | Repo paths | One-liner | Detail | |--------|------------|-----------|--------| -| cli | [`src/cli/`](../../src/cli) | citty-based subcommand dispatch (serve/validate/export/index/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update + version reporting | [`docs/wiki/cli.md`](cli.md) | +| cli | [`src/cli/`](../../src/cli) | citty-based subcommand dispatch (serve/validate/export/index/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update with streamed download progress + checksum verification | [`docs/wiki/cli.md`](cli.md) | | server | [`src/server/server.ts`](../../src/server/server.ts) | Bun.serve with `/api/model` + `/api/flow` (returns `clusters`) + `/events` SSE + fs.watch live-reload; `/dict` and `/flow` redirect to unified SPA hash routes; `/flow-dict` redirects to `/#view=dict` | [`docs/wiki/server.md`](server.md) | | 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; `ModelMeta.flowView` (`flow_view:`/`adjacency_stacks`); `buildModelIndex` — 13 O(1) lookup maps built once per Model | [`docs/wiki/parser.md`](parser.md) | | validate | [`src/model/validate.ts`](../../src/model/validate.ts) | Pure model validator: 38 RuleIds across 8 prefixes (parse/config/entity/body/edge/cluster/index/flow), two severity tiers (A=warn, B=omit); `flow.*` is 17 ids including five `flow.cluster_*` DFD-store-cluster rules; `validateIndex` reuses `buildRouters` to detect router drift | [`docs/wiki/validate.md`](validate.md) | -| flows | [`src/flows/`](../../src/flows) | SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (17 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model | [`docs/wiki/flows.md`](flows.md) | -| flow-view | [`src/flow-view/`](../../src/flow-view) | ELK-driven DFD layout (5-band partitioning, orthogonal edge routing) with a stack-node model for per-process/connected views and three collapse levels (stores/clusters/groups); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming | [`docs/wiki/flow-view.md`](flow-view.md) | -| frontend | [`src/app/`](../../src/app) | React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; `flowview=`/`collapse=` hash params drive the flow view mode and collapse level; `StackDialog`/`EdgeContractDialog` cover stack and edge-contract detail; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath | [`docs/wiki/frontend.md`](frontend.md) | +| flows | [`src/flows/`](../../src/flows) | SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`, now also reading a folder's own `description:` off `index.md`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (17 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model | [`docs/wiki/flows.md`](flows.md) | +| flow-view | [`src/flow-view/`](../../src/flow-view) | ELK-driven DFD layout (5-band partitioning, orthogonal edge routing) with a stack-node model for per-process/connected views and three collapse levels (stores/clusters/groups); `flow-nav.ts` resolves exact diagram paths for the new flow index + breadcrumb level menus (`FlowIndex.tsx`/`LevelMenu.tsx`, replacing the old standalone nav card); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming and gates hover transitions through the frontend domain's hover-intent/animation-limit helpers | [`docs/wiki/flow-view.md`](flow-view.md) | +| frontend | [`src/app/`](../../src/app) | React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; `logic/motion.ts` centralizes hover-intent debouncing and a per-view animation-element-limit; `flowview=`/`collapse=` hash params drive the flow view mode and collapse level; new `i` shortcut opens the flow index; `StackDialog`/`EdgeContractDialog` cover stack and edge-contract detail; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath | [`docs/wiki/frontend.md`](frontend.md) | | generators | [`src/generators/`](../../src/generators) | Unified static HTML export via `generateApp` (single file — graph + dict + flows), embedding `window.__FLOW_CLUSTERS__`; sole static generator | [`docs/wiki/generators.md`](generators.md) | -| theme | [`src/theme/`](../../src/theme) | ThemeConfig + Branding types, default palettes, flow-kind colors, dark/light merging | [`docs/wiki/theme.md`](theme.md) | -| skill | [`skills/ignatius-modeling/`](../../skills/ignatius-modeling) | Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate` | [`docs/wiki/skill.md`](skill.md) | -| docs | [`docs/`](..) (excluding [`docs/wiki/`](.)) | Design docs, user guides, research notes, and implementation-contract specs — 78 markdown files plus [`docs/glossary.md`](../glossary.md) across [`docs/design/`](../design), [`docs/guides/`](../guides), [`docs/research/`](../research), [`docs/spec/`](../spec) | [`docs/wiki/docs.md`](docs.md) | +| theme | [`src/theme/`](../../src/theme) | ThemeConfig + Branding types, default palettes, flow-kind colors + per-kind store-cap symbols (`FLOW_STORE_KIND_SYMBOLS`), dark/light merging | [`docs/wiki/theme.md`](theme.md) | +| skill | [`skills/ignatius-modeling/`](../../skills/ignatius-modeling) | Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate`; a flow's own `description:` is now authored to `flows//index.md` (Step F6a) | [`docs/wiki/skill.md`](skill.md) | +| docs | [`docs/`](..) (excluding [`docs/wiki/`](.)) | Design docs, user guides, research notes, and implementation-contract specs — 81 markdown files plus [`docs/glossary.md`](../glossary.md) across [`docs/design/`](../design), [`docs/guides/`](../guides), [`docs/research/`](../research), [`docs/spec/`](../spec) | [`docs/wiki/docs.md`](docs.md) | | scripts | [`scripts/`](../../scripts) | Build helpers: stable-names.ts, convert-yaml-to-md.ts; perf/diagnostic tooling: probe.ts, screenshot.ts, gen-synthetic-model.ts, perf-harness.ts | [`docs/wiki/scripts.md`](scripts.md) | -| router | [`src/router/`](../../src/router) | Generates per-folder `index.md` routers with rolled-up SHA-256 digests, in-folder agent guidance (`AGENTS.md`, [`CLAUDE.md`](../../CLAUDE.md) shim, `SKILL.md`), and a position-based `` region parser; backs `ignatius index` and `validate --index` | [`docs/wiki/router.md`](router.md) | +| router | [`src/router/`](../../src/router) | Generates per-folder `index.md` routers with rolled-up SHA-256 digests, in-folder agent guidance (`AGENTS.md`, [`CLAUDE.md`](../../CLAUDE.md) shim, `SKILL.md`), and a position-based `` region parser; a flow folder's own `description:` now folds into its parent row's digest; backs `ignatius index` and `validate --index` | [`docs/wiki/router.md`](router.md) | ## Cross-cutting diff --git a/docs/wiki/router.md b/docs/wiki/router.md index c141b9a..a7b4f08 100644 --- a/docs/wiki/router.md +++ b/docs/wiki/router.md @@ -10,7 +10,7 @@ tags: [model, codegen, fingerprint] A model root (`ignatius.yml` plus `data/`, `groups/`, `flows/`, `externals/`, `stores/`) is a flat pile of markdown to any reader without `ignatius serve` running: a filename like `PaymentAllocation.md` says nothing about which group it belongs to or whether opening it answers the question at hand. [`src/router/`](../../src/router) generates a navigable `index.md` into every organizing folder so a reader descends root → section → group → entity through small tables instead of globbing the tree, and each router carries a rolled-up SHA-256 digest so drift is a `validate` finding rather than a file that quietly rots. -`buildRouters` ([`src/router/build.ts`](../../src/router/build.ts)) turns a parsed `Model` + `FlowModel` into one `RouterFile` per folder; `writeRouters` ([`src/router/write.ts`](../../src/router/write.ts)) writes each into a `` region, leaving every other byte in the file untouched. `--agents` additionally writes `AGENTS.md`, a [`CLAUDE.md`](../../CLAUDE.md) shim, and `SKILL.md` into the model root only, so an agent that opens any file under the root picks up the model's conventions with no install step. +`buildRouters` ([`src/router/build.ts`](../../src/router/build.ts)) turns a parsed `Model` + `FlowModel` into one `RouterFile` per folder; `writeRouters` ([`src/router/write.ts`](../../src/router/write.ts)) writes each into a `` region, leaving every other byte in the file untouched. `--agents` additionally writes `AGENTS.md`, a [`CLAUDE.md`](../../CLAUDE.md) shim, and `SKILL.md` into the model root only, so an agent that opens any file under the root picks up the model's conventions with no install step. A flow folder's parent row also carries that folder's own `description:` frontmatter, read by the flows parser, so the description a folder is authored with shows up one level up without a separate lookup. ## How it works @@ -70,13 +70,26 @@ flowchart LR `hashFile` hashes a target's raw bytes, so a whitespace-only edit still dirties a digest. `folderDigest` hashes the ordered list of row hashes for one folder; a parent's row for a child folder carries only that child's `folderDigest`, never its rows, which is why the propagation stops at the ancestor path. +### A flow folder's description dirties its parent row, never its own digest + +**A flow folder's own `folderDigest` never includes that folder's description; only the row the parent table shows for it does.** + +```mermaid +flowchart TD + B["buildFlowFolder builds file: RouterFile for the folder, file.digest from its own rows"] --> D{"diagram.description present?"} + D -->|no| R1["parent row: hash = file.digest, description = ''"] + D -->|yes| R2["parent row: hash = folderDigest([file.digest, description]), description = diagram.description"] +``` + +`buildFlowFolder` reads `diagram.description` (parsed by [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) from that folder's own `index.md` frontmatter) after building the folder's own `RouterFile`. A folder without a description keeps the child row's hash equal to `file.digest`, so a model with no flow descriptions authored is unaffected by this path. A folder with a description folds it into the row's hash with the same `folderDigest` primitive used for ordinary row lists, so rewording the description changes the digest one level up (the folder listing it in its own table) and every ancestor above that, but never the described folder's own digest. + ## Where it lives | File | Exports | Responsibility | |---|---|---| | [`src/router/region.ts`](../../src/router/region.ts) | `readRegion`, `replaceRegion` | Column-0 tag tokenizer, open/close pairing scan, region extraction and in-place replacement | | [`src/router/fingerprint.ts`](../../src/router/fingerprint.ts) | `hashFile`, `folderDigest`, `RouterNode` | SHA-256 of raw file bytes; SHA-256 of an ordered row-hash list; the one-row shape (`name`, `kind`, `description`, `link`, `hash`) every table row is built from | -| [`src/router/build.ts`](../../src/router/build.ts) | `buildRouters`, `RouterFile`, `UnreadableTarget` | Model + `FlowModel` → `RouterFile[]`; `buildDataTree`/`buildDataFolder` mirror each entity's `sourcePath`, never its declared `group:`; `buildFlowFolder` recurses through sub-DFDs; `safeHashFile` collects unreadable targets instead of throwing | +| [`src/router/build.ts`](../../src/router/build.ts) | `buildRouters`, `RouterFile`, `UnreadableTarget` | Model + `FlowModel` → `RouterFile[]`; `buildDataTree`/`buildDataFolder` mirror each entity's `sourcePath`, never its declared `group:`; `buildFlowFolder` recurses through sub-DFDs and folds a folder's `diagram.description` into its parent row's hash and description text; `safeHashFile` collects unreadable targets instead of throwing | | [`src/router/write.ts`](../../src/router/write.ts) | `writeRouters` | Region-scoped write of every `RouterFile`'s `` table and its `` line; creates missing files, creates missing intermediate directories via `Bun.write` | | [`src/router/detect.ts`](../../src/router/detect.ts) | `resolveHarness` | Resolves whether `--agents` writes the [`CLAUDE.md`](../../CLAUDE.md) shim, from `ignatius.yml`'s `harness:` (default `auto`) plus an ancestor filesystem probe for [`.claude/`](../../.claude) or [`CLAUDE.md`](../../CLAUDE.md) | | [`src/router/agents.ts`](../../src/router/agents.ts) | `deriveKeyStyle`, `buildAgentsGuide`, `buildClaudeShim`, `buildSkillMeta`, `buildSkillBody`, `writeGuidance`, `KeyStyle` | `AGENTS.md`/[`CLAUDE.md`](../../CLAUDE.md)/`SKILL.md` content, derives the model's key-style convention (`key-inherited` / `orm-oriented` / `mixed` / `undetermined`) from PK shape, and writes all three guidance files into the model root only | @@ -85,17 +98,19 @@ flowchart LR - `region.ts`'s tokenizer has no fence or code-span exemption: a tag shown as an example inside a hand-authored `` block must be indented or HTML-escaped (`<ignatius-index>`), or it is read as a real boundary. - `SKILL.md`'s YAML frontmatter (`name`, `description`) is the one write the generator makes outside a region; every other byte of every generated file, including the rest of `SKILL.md`, goes through `replaceRegion`. `writeGuidance` (`src/router/agents.ts:181-184`) rebuilds that frontmatter from `buildSkillMeta(model)` on every call without reading the existing `name`/`description` back, so a hand-edit to either field is silently discarded and replaced on the next `ignatius index --agents` run. -- `writeGuidance` writes `AGENTS.md` and `SKILL.md` unconditionally and the [`CLAUDE.md`](../../CLAUDE.md) shim only when its `writeClaude` argument is true; it writes into `root` only, never into a nested folder. +- `writeGuidance` writes `AGENTS.md` and `SKILL.md` unconditionally and the [`CLAUDE.md`](../../CLAUDE.md) shim only when its `writeClaude` argument is true; it writes into `root` only, never into a nested folder, so an agent opening a file under `data/identity/` or `flows/checkout/` finds no local guidance there and has to resolve up to the model root to read `AGENTS.md`, [`CLAUDE.md`](../../CLAUDE.md), or `SKILL.md`. - `buildDataTree` walks `ModelNode.sourcePath`; a node with `sourcePath === undefined` is skipped rather than placed by its declared `group:`. - `buildRouters`'s flow walk starts at `flowModel.diagrams[0]?.subDfds[0]?.subDfds`, assuming `flowModel` is already leveled; an unleveled `FlowModel` has no context/L1 wrapper to descend through and the walk would misalign. +- `buildFlowFolder` treats an empty-string `diagram.description` the same as an absent one (`description ? ... : file.digest`), so a folder's index file with no `description:` frontmatter, or with one that parses to `''`, never changes the parent row's hash away from the folder's own digest — existing models with no flow descriptions authored see no digest change from this path. - `storeRows` includes only `FlowStoreRef` entries with a defined `body` (actually read from a `stores/*.md` file); `db:` tokens and undefined store tokens are excluded. -- `deriveKeyStyle` excludes `Classifier`-classified nodes from its PK-shape count and calls the model `mixed` once the minority signature (key-inherited vs. surrogate) reaches `MIXED_SHARE_THRESHOLD` (0.2) of relevant nodes. -- `resolveHarness`'s `auto` case (`ancestorHasClaudeMarker`) walks from the model root up to the filesystem root via `existsSync`, stopping at the first [`.claude`](../../.claude) directory or [`CLAUDE.md`](../../CLAUDE.md) file. +- `deriveKeyStyle` excludes `Classifier`-classified nodes from its PK-shape count and calls the model `mixed` once the minority signature (key-inherited vs. surrogate) reaches `MIXED_SHARE_THRESHOLD` (0.2) of relevant nodes, so a model whose minority signature sits just under that 20% share still reports as pure `key-inherited` or `orm-oriented`, and `AGENTS.md`'s key-style sentence states a single convention that a fifth of the model's entities can still not follow. +- `resolveHarness`'s `auto` case (`ancestorHasClaudeMarker`) walks from the model root up to the filesystem root via `existsSync`, stopping at the first [`.claude`](../../.claude) directory or [`CLAUDE.md`](../../CLAUDE.md) file, so an unrelated ancestor directory's [`.claude/`](../../.claude) or [`CLAUDE.md`](../../CLAUDE.md) (someone else's tooling that happens to sit above the model root) resolves the harness to true and writes the [`CLAUDE.md`](../../CLAUDE.md) shim even though nobody set `harness: claude` for this model. ## Coupling -- **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)) — `validateIndex` dynamically imports and reuses `buildRouters` to recompute every digest without writing anything, backing six `RuleId`s: `config.index_file_ext`, `config.index_file_path`, `config.index_file_entity` (frontmatter/config shape), and `index.stale`, `index.orphaned`, `index.unreadable_target` (router drift, read off `STORED_DIGEST_RE` against the current `` attribute). +- **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)) — `validateIndex` dynamically imports and reuses `buildRouters` to recompute every digest without writing anything, backing three `RuleId`s: `index.unreadable_target`, `index.stale`, and `index.orphaned` (router drift, read off `STORED_DIGEST_RE` against the current `` attribute). `config.index_file_ext`, `config.index_file_path`, and `config.index_file_entity` belong to the parser, not to this domain: [`src/model/parse.ts`](../../src/model/parse.ts) emits all three during `parseModels`, unconditionally, with no dependency on `buildRouters` or a router digest. - **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)) — `indexCmd` (`ignatius index [path] [--model] [--agents]`) parses the model, runs `buildRouters` + `writeRouters`, and, under `--agents`, calls `resolveHarness` and `writeGuidance`. `validateCmd` gains an `--index` flag that calls `validateIndex` so a plain `ignatius validate` never pays a full-tree hash pass. -- **parser** ([`src/model/parse.ts`](../../src/model/parse.ts)) / **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)) — `build.ts`, `agents.ts`, and `detect.ts` import `Model`, `ModelNode`, `ModelEdge`, `HarnessMode`, `FlowDiagram`, `FlowModel`, and `FlowStoreRef` as read-only inputs; router code never mutates a parsed model. The reserved `index_file` basename skip that keeps a written router from being re-parsed as an entity or flow definition lives in those two domains, not in [`src/router/`](../../src/router). +- **parser** ([`src/model/parse.ts`](../../src/model/parse.ts)) / **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)) — `build.ts`, `agents.ts`, and `detect.ts` import `Model`, `ModelNode`, `ModelEdge`, `HarnessMode`, `FlowDiagram`, `FlowModel`, and `FlowStoreRef` as read-only inputs; router code never mutates a parsed model. `FlowDiagram.description` is parsed there, from a flow folder's own `index.md` frontmatter, and `buildFlowFolder` is the only router code that reads it. The reserved `index_file` basename skip that keeps a written router from being re-parsed as an entity or flow definition lives in those two domains, not in [`src/router/`](../../src/router). - **flows** ([`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts)) — `deriveLevels` wraps the parser's flat leaf diagrams in a context (Level 0) diagram and an L1 overview diagram before router ever sees them; `flowModel.diagrams[0]` is that context wrapper and `subDfds[0]` is the L1 wrapper, which is why `buildRouters` starts its walk one level past both. See [`docs/wiki/flows.md`](flows.md). -- **docs** — [`docs/design/model-index-routing.md`](../design/model-index-routing.md) (approach and rationale) and [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) (the SC1–SC14 contract, checkpoints CP1–CP7) are the design/spec pair for this domain. +- **docs** — [`docs/design/model-index-routing.md`](../design/model-index-routing.md) (approach and rationale) and [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) (the SC1–SC14 contract, checkpoints CP1–CP7) are the design/spec pair for this domain; SC7's last sentence covers the flow folder description fold. [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) and [`docs/design/large-model-nav.md`](../design/large-model-nav.md) are the design/spec pair that added `FlowDiagram.description` and the flow index/breadcrumb features consuming it; this domain's part of that work is `buildFlowFolder`'s description fold, checkpoint 1 in that spec. + diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 9c6f147..5710610 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -3,23 +3,22 @@ ## Tree ``` -├── .claude/ (3) +├── .claude/ (2) │ ├── rules/ (1) │ │ └── wiki/ (13) -│ │ ├── cli.md (2ed7f07, 16L, 653ch, 655B) -│ │ ├── docs.md (791dd87, 18L, 652ch, 656B) -│ │ ├── flow-view.md (06a2917, 25L, 1035ch, 1037B) -│ │ ├── flows.md (56dcd02, 29L, 1110ch, 1112B) -│ │ ├── frontend.md (c2a4f95, 26L, 1132ch, 1134B) +│ │ ├── cli.md (61979a6, 17L, 731ch, 733B) +│ │ ├── docs.md (12b4631, 18L, 652ch, 656B) +│ │ ├── flow-view.md (66fa416, 27L, 1363ch, 1365B) +│ │ ├── flows.md (87c71f4, 31L, 1241ch, 1243B) +│ │ ├── frontend.md (9f02e64, 28L, 1333ch, 1335B) │ │ ├── generators.md (c54e110, 15L, 576ch, 580B) │ │ ├── parser.md (cb759ae, 26L, 1044ch, 1050B) -│ │ ├── router.md (0fba0a3, 16L, 676ch, 678B) +│ │ ├── router.md (308c8a6, 18L, 819ch, 821B) │ │ ├── scripts.md (27ca70a, 14L, 533ch, 535B) │ │ ├── server.md (a99cf7f, 12L, 546ch, 548B) -│ │ ├── skill.md (6164cac, 23L, 807ch, 809B) -│ │ ├── theme.md (62ad5dd, 18L, 544ch, 546B) +│ │ ├── skill.md (471bfad, 23L, 890ch, 892B) +│ │ ├── theme.md (612e1c3, 18L, 601ch, 603B) │ │ └── validate.md (f5bffbd, 20L, 887ch, 889B) -│ ├── .gitignore (9ebf88f, 2L, 74ch, 74B) │ └── atomic.toml (da6b99f, 10L, 128ch, 128B) ├── .cursor/ (1) │ └── rules/ (1) @@ -31,16 +30,16 @@ ├── assets/ (1) │ └── noorm-logo.svg (8d46c28, 6L, 2529ch, 2529B) ├── docs/ (5) -│ ├── design/ (30) +│ ├── design/ (31) │ │ ├── app-tsx-decomposition.md (6620907, 142L, 9400ch, 9870B) │ │ ├── bidirectional-predicates.md (5e872cc, 67L, 3485ch, 3522B) │ │ ├── branding.md (6025a97, 160L, 7673ch, 7956B) │ │ ├── cli-and-outputs.md (4822c56, 135L, 5344ch, 5412B) -│ │ ├── dd-spotlight-grid.md (9435542, 60L, 7640ch, 7699B) +│ │ ├── dd-spotlight-grid.md (ee37195, 60L, 7716ch, 7775B) │ │ ├── dfd-edge-hover-data.md (a5e515f, 100L, 5565ch, 5603B) │ │ ├── dfd-nesting-depth.md (4f3261d, 75L, 3767ch, 3799B) │ │ ├── dfd-overhaul.md (74239cb, 93L, 8816ch, 8888B) -│ │ ├── dfd-store-clusters.md (09fa66f, 299L, 20540ch, 20583B) +│ │ ├── dfd-store-clusters.md (f24aea5, 308L, 21388ch, 21431B) │ │ ├── dict-navigation.md (0c0f0f7, 100L, 5253ch, 5278B) │ │ ├── example-instance-tables.md (2a61175, 135L, 9391ch, 9447B) │ │ ├── folder-model.md (0c51512, 104L, 5558ch, 5591B) @@ -51,6 +50,7 @@ │ │ ├── ignatius-project-config.md (854740b, 107L, 6806ch, 6838B) │ │ ├── key-inheritance-lineage.md (63dd0fb, 175L, 10759ch, 10911B) │ │ ├── keyboard-nav-shortcuts.md (37b922e, 110L, 5283ch, 5301B) +│ │ ├── large-model-nav.md (98924fe, 102L, 8343ch, 8367B) │ │ ├── markdown-driven-erd.md (2a28ec9, 333L, 12635ch, 12763B) │ │ ├── model-index-routing.md (5775685, 476L, 23244ch, 23505B) │ │ ├── noorm-flow-discovery.md (2062784, 179L, 14151ch, 14249B) @@ -64,10 +64,10 @@ │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) │ ├── guides/ (10) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (5ec0b65, 174L, 11245ch, 11293B) +│ │ ├── commands.md (eb36c04, 176L, 11690ch, 11740B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) -│ │ ├── flows.md (f782ee5, 280L, 18442ch, 18492B) -│ │ ├── folder-format.md (348cd3f, 275L, 12658ch, 12701B) +│ │ ├── flows.md (2780567, 309L, 21825ch, 21885B) +│ │ ├── folder-format.md (afdd7c2, 277L, 13132ch, 13175B) │ │ ├── getting-started.md (85d1b13, 93L, 3536ch, 3550B) │ │ ├── modeling-skill.md (1d1e795, 73L, 4187ch, 4203B) │ │ ├── predicates.md (502da1e, 83L, 3790ch, 3790B) @@ -76,40 +76,42 @@ │ ├── research/ (2) │ │ ├── dfd-layout-and-leveling.md (091455c, 129L, 14807ch, 14993B) │ │ └── ssadm-dfd-rules.md (7d83a02, 118L, 8356ch, 8460B) -│ ├── spec/ (36) +│ ├── spec/ (38) │ │ ├── app-tsx-decomposition.md (430d88c, 246L, 21478ch, 22248B) │ │ ├── bidirectional-predicates.md (e6355c0, 157L, 7664ch, 7712B) │ │ ├── branding.md (bd50adc, 102L, 8976ch, 9031B) │ │ ├── cli-and-outputs.md (2010e2f, 144L, 12667ch, 12748B) -│ │ ├── dd-spotlight-grid.md (a914108, 239L, 47419ch, 47725B) +│ │ ├── dd-spotlight-grid.md (3bcf374, 239L, 47813ch, 48119B) │ │ ├── derive-classification.md (e6b0392, 72L, 4334ch, 4373B) │ │ ├── dfd-edge-hover-data.md (07e995c, 83L, 10184ch, 10287B) │ │ ├── dfd-nesting-depth.md (a458218, 69L, 8063ch, 8118B) -│ │ ├── dfd-overhaul.md (7b63691, 155L, 34405ch, 34673B) +│ │ ├── dfd-overhaul.md (6c8a4cc, 163L, 35031ch, 35301B) │ │ ├── dfd-polish-round2.md (17f1b5a, 169L, 9418ch, 9488B) │ │ ├── dfd-polish-round3.md (a9bd5eb, 238L, 14549ch, 14692B) │ │ ├── dfd-polish-round4.md (b3be090, 159L, 8446ch, 8514B) -│ │ ├── dfd-store-clusters.md (19073d0, 391L, 46279ch, 46692B) +│ │ ├── dfd-store-clusters.md (7aa18bf, 411L, 48276ch, 48691B) │ │ ├── dict-navigation.md (07a7ce2, 90L, 6183ch, 6205B) │ │ ├── dict-polish.md (eef2bdc, 87L, 6588ch, 6618B) │ │ ├── example-instance-tables.md (951cc49, 170L, 19245ch, 19315B) │ │ ├── folder-model.md (70788dd, 117L, 11755ch, 11817B) -│ │ ├── graph-flow-search.md (1080c63, 199L, 18217ch, 18435B) +│ │ ├── graph-flow-search.md (a0c0d83, 207L, 18628ch, 18848B) │ │ ├── graph-position-persistence.md (ae7bdcd, 106L, 8067ch, 8126B) -│ │ ├── help-overlay.md (e4faa50, 64L, 4840ch, 4862B) +│ │ ├── help-overlay.md (a6d3d83, 70L, 5417ch, 5445B) │ │ ├── ignatius-modeling-skill.md (2969d84, 211L, 22984ch, 23115B) │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) -│ │ ├── keyboard-nav-shortcuts.md (0c00b77, 189L, 16705ch, 16864B) -│ │ ├── model-index-routing.md (aaa3982, 270L, 29790ch, 30042B) +│ │ ├── keyboard-nav-shortcuts.md (0f4c346, 196L, 17379ch, 17540B) +│ │ ├── large-model-nav.md (f5b308c, 217L, 14042ch, 14270B) +│ │ ├── model-index-routing.md (339970f, 276L, 30929ch, 31183B) │ │ ├── noorm-flow-discovery.md (62f4c01, 83L, 10572ch, 10684B) │ │ ├── noorm-modeling-skill.md (b2a16d4, 12L, 412ch, 418B) -│ │ ├── process-flows.md (e5e6472, 682L, 83302ch, 83868B) +│ │ ├── process-flows.md (3a3dc43, 690L, 83972ch, 84538B) │ │ ├── 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) │ │ ├── unified-app.md (02a3bda, 216L, 29279ch, 29469B) +│ │ ├── update-download-progress.md (1fcad6a, 80L, 6182ch, 6188B) │ │ ├── viewer-fab-ux.md (9a889a1, 101L, 8717ch, 8759B) │ │ ├── viewer-ux-polish.md (bfcb728, 180L, 28658ch, 28880B) │ │ └── wiki-entity-links.md (569742d, 79L, 4329ch, 4377B) @@ -262,13 +264,13 @@ │ │ │ │ ├── Create-Project.md (2dbe6db, 38L, 2130ch, 2132B) │ │ │ │ ├── Delete-Agent.md (ad780c5, 60L, 2677ch, 2681B) │ │ │ │ ├── Update-Project.md (07bb3a2, 34L, 1963ch, 1965B) -│ │ │ │ └── index.md (cad2a2c, 16L, 886ch, 889B) +│ │ │ │ └── index.md (d2ee2b4, 20L, 972ch, 975B) │ │ │ ├── artifact-management/ (5) │ │ │ │ ├── Attach-Artifact-To-Milestone.md (28265ae, 38L, 1575ch, 1577B) │ │ │ │ ├── Attach-Artifact-To-Task.md (7f28a21, 38L, 1646ch, 1650B) │ │ │ │ ├── Create-Artifact.md (bdb60d2, 32L, 1855ch, 1859B) │ │ │ │ ├── Set-Artifact-Relevance.md (6541db6, 58L, 3087ch, 3101B) -│ │ │ │ └── index.md (2c68954, 16L, 1002ch, 1005B) +│ │ │ │ └── index.md (74c8c56, 20L, 1145ch, 1148B) │ │ │ ├── memory-lifecycle/ (7) │ │ │ │ ├── Attach-Memory-to-Project.md (9d29568, 35L, 1594ch, 1600B) │ │ │ │ ├── Consolidate-Memory.md (76957ae, 74L, 3592ch, 3596B) @@ -276,27 +278,27 @@ │ │ │ │ ├── Filter-Memories-by-Tags.md (ce6e9ad, 46L, 2436ch, 2448B) │ │ │ │ ├── Relate-Memories.md (217d9b2, 41L, 2146ch, 2154B) │ │ │ │ ├── Set-Memory-Relevance.md (4cfef79, 58L, 3007ch, 3027B) -│ │ │ │ └── index.md (88fe7c9, 18L, 1279ch, 1282B) +│ │ │ │ └── index.md (77ef1ca, 22L, 1405ch, 1408B) │ │ │ ├── note-capture/ (5) │ │ │ │ ├── Create-Milestone-Note.md (04fbb9c, 38L, 1868ch, 1868B) │ │ │ │ ├── Create-Project-Note.md (c6110d3, 38L, 1867ch, 1873B) │ │ │ │ ├── Create-Task-Note.md (187310d, 38L, 1974ch, 1980B) │ │ │ │ ├── Set-Note-Relevance.md (1500a8e, 62L, 3303ch, 3313B) -│ │ │ │ └── index.md (8fabfe7, 16L, 903ch, 906B) +│ │ │ │ └── index.md (4ccce44, 20L, 1011ch, 1014B) │ │ │ ├── tag-administration/ (5) │ │ │ │ ├── Attach-Tag-to-Memory.md (675cf9b, 39L, 1505ch, 1509B) │ │ │ │ ├── Bulk-Attach-Tag-to-Memories.md (aeb2cf3, 40L, 1851ch, 1855B) │ │ │ │ ├── Create-Tag.md (9d1ae6a, 41L, 2016ch, 2024B) │ │ │ │ ├── Merge-Tag.md (1c89bca, 97L, 3807ch, 3817B) -│ │ │ │ └── index.md (3228931, 16L, 891ch, 894B) +│ │ │ │ └── index.md (bae626d, 20L, 1004ch, 1007B) │ │ │ ├── work-planning/ (6) │ │ │ │ ├── Add-Task-Dependency.md (6233827, 44L, 2526ch, 2534B) │ │ │ │ ├── Close-Milestone.md (ce766d2, 69L, 3573ch, 3591B) │ │ │ │ ├── Create-Milestone.md (dfe56e4, 34L, 2009ch, 2015B) │ │ │ │ ├── Create-Task.md (5005a31, 42L, 2365ch, 2371B) │ │ │ │ ├── Set-Task-Tracking.md (3c3104d, 54L, 2757ch, 2763B) -│ │ │ │ └── index.md (bd9d446, 17L, 1065ch, 1068B) -│ │ │ └── index.md (94b9514, 18L, 794ch, 796B) +│ │ │ │ └── index.md (9c817c5, 21L, 1187ch, 1190B) +│ │ │ └── index.md (522af00, 18L, 1354ch, 1356B) │ │ ├── groups/ (9) │ │ │ ├── artifact.md (f338781, 9L, 290ch, 290B) │ │ │ ├── audit.md (2163451, 9L, 396ch, 398B) @@ -313,7 +315,7 @@ │ │ ├── CLAUDE.md (645220e, 8L, 162ch, 162B) │ │ ├── SKILL.md (dd56c86, 15L, 417ch, 417B) │ │ ├── ignatius.yml (9b85962, 13L, 746ch, 752B) -│ │ └── index.md (045f97f, 13L, 1039ch, 1041B) +│ │ └── index.md (fd69074, 13L, 1039ch, 1041B) │ ├── orm-hybrid/ (7) │ │ ├── data/ (5) │ │ │ ├── catalog/ (3) @@ -429,16 +431,16 @@ │ └── ignatius-modeling/ (2) │ ├── references/ (10) │ │ ├── conventions.md (d42e9e0, 64L, 3000ch, 3024B) -│ │ ├── dfd-authoring.md (576ee85, 340L, 20270ch, 20424B) +│ │ ├── dfd-authoring.md (deb7fb3, 347L, 21209ch, 21363B) │ │ ├── discover-flow.md (87b6eb6, 193L, 11572ch, 11672B) │ │ ├── entity-flow.md (9c72852, 247L, 16390ch, 16546B) -│ │ ├── flow-templates.md (15fb9c4, 305L, 11084ch, 11114B) +│ │ ├── flow-templates.md (a55c8d3, 316L, 11389ch, 11419B) │ │ ├── interviewing.md (7e93bc1, 13L, 1853ch, 1859B) │ │ ├── model-flow.md (260b6af, 86L, 3787ch, 3826B) │ │ ├── reverse-engineering.md (f6c9e69, 129L, 8403ch, 8471B) │ │ ├── templates.md (329dfd3, 443L, 12207ch, 12273B) │ │ └── verification.md (e531840, 104L, 11562ch, 11624B) -│ └── SKILL.md (eef24dd, 50L, 6035ch, 6085B) +│ └── SKILL.md (a8df1c8, 50L, 6254ch, 6304B) ├── spec/ (1) │ └── spec.md (478f9ed, 464L, 20340ch, 20470B) [generated] ├── src/ (10) @@ -453,7 +455,7 @@ │ │ │ │ ├── ExamplesAccordion.tsx (1bd5324, 70L, 2219ch, 2220B) │ │ │ │ ├── FlowNodeGridCard.tsx (571a1a5, 209L, 6180ch, 6572B) │ │ │ │ ├── GridCard.tsx (084e391, 87L, 2667ch, 2675B) -│ │ │ │ └── SpotlightOverlay.tsx (174dc79, 1095L, 43529ch, 43620B) +│ │ │ │ └── SpotlightOverlay.tsx (3ef1248, 1187L, 46601ch, 46692B) │ │ │ ├── findings/ (1) │ │ │ │ └── FindingsPanel.tsx (a31dd6a, 93L, 3285ch, 3291B) │ │ │ ├── flow/ (1) @@ -463,11 +465,11 @@ │ │ │ │ ├── ExternalCard.tsx (796ad29, 26L, 834ch, 836B) │ │ │ │ ├── FlowDocModal.tsx (a3d4cee, 30L, 1057ch, 1061B) │ │ │ │ ├── FlowNodeModal.tsx (7ead0b4, 119L, 4732ch, 4740B) -│ │ │ │ ├── StackDialog.tsx (a24ada2, 216L, 7478ch, 7492B) +│ │ │ │ ├── StackDialog.tsx (0ebbaf4, 218L, 7710ch, 7724B) │ │ │ │ └── StoreCard.tsx (175cad6, 26L, 791ch, 793B) │ │ │ ├── process/ (6) │ │ │ │ ├── IoTable.tsx (603a537, 139L, 4970ch, 4980B) -│ │ │ │ ├── KindMarker.tsx (da6dd93, 31L, 925ch, 925B) +│ │ │ │ ├── KindMarker.tsx (f957984, 21L, 746ch, 746B) │ │ │ │ ├── ProcessCard.tsx (451bd9c, 86L, 3114ch, 3126B) │ │ │ │ ├── ProcessExamples.tsx (a0f2570, 64L, 2459ch, 2466B) │ │ │ │ ├── ProcessesSection.tsx (738ed4c, 43L, 1261ch, 1261B) @@ -475,7 +477,7 @@ │ │ │ └── ui/ (7) │ │ │ ├── ExampleCell.tsx (2422242, 26L, 885ch, 887B) │ │ │ ├── FabMenu.tsx (61407e9, 244L, 7778ch, 7794B) -│ │ │ ├── HelpModal.tsx (bef4770, 138L, 7131ch, 7191B) +│ │ │ ├── HelpModal.tsx (5c41743, 141L, 7533ch, 7597B) │ │ │ ├── JsonValue.tsx (4c89f39, 54L, 1945ch, 1951B) │ │ │ ├── Modal.tsx (c55b148, 73L, 2608ch, 2611B) │ │ │ ├── SearchBar.tsx (b187f11, 147L, 5239ch, 5253B) @@ -485,10 +487,10 @@ │ │ │ └── theme-css-vars.ts (61ca750, 114L, 6255ch, 6267B) │ │ ├── hooks/ (4) │ │ │ ├── useHashRoute.ts (267fba1, 140L, 6274ch, 6286B) -│ │ │ ├── useKeyboardShortcuts.ts (226e7b4, 101L, 4029ch, 4049B) +│ │ │ ├── useKeyboardShortcuts.ts (8d11972, 105L, 4213ch, 4235B) │ │ │ ├── useModelData.ts (fde38cb, 183L, 6730ch, 6862B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) -│ │ ├── logic/ (13) +│ │ ├── logic/ (14) │ │ │ ├── color.ts (8006242, 39L, 1796ch, 1796B) │ │ │ ├── doc-resolver.ts (075dcf2, 125L, 5950ch, 5970B) │ │ │ ├── finding-rows.ts (831abb1, 48L, 1695ch, 1695B) @@ -496,52 +498,56 @@ │ │ │ ├── flow-spotlight.ts (0725869, 136L, 5096ch, 5114B) │ │ │ ├── json-highlight.ts (df29eb8, 41L, 1679ch, 1683B) │ │ │ ├── json-value.ts (2076a19, 73L, 2566ch, 2570B) +│ │ │ ├── motion.ts (caa6fa4, 81L, 2822ch, 2826B) │ │ │ ├── relationship-key.ts (21bbb6e, 21L, 950ch, 956B) │ │ │ ├── search.ts (3c76b8b, 272L, 10130ch, 10138B) -│ │ │ ├── shortcuts.ts (b246af3, 159L, 6893ch, 6941B) +│ │ │ ├── shortcuts.ts (6c42a68, 162L, 7052ch, 7102B) │ │ │ ├── spotlight-inherited.ts (6acb553, 266L, 11818ch, 12228B) │ │ │ ├── spotlight-lines.ts (0138798, 100L, 3696ch, 3724B) │ │ │ └── spotlight.ts (d4aec20, 101L, 3534ch, 3540B) │ │ ├── views/ (3) │ │ │ ├── dict/ (1) -│ │ │ │ └── DictionaryView.tsx (b53bb21, 1452L, 63746ch, 64048B) +│ │ │ │ └── DictionaryView.tsx (db60923, 1457L, 64234ch, 64536B) │ │ │ ├── flow/ (2) -│ │ │ │ ├── FlowsView.tsx (8e83092, 989L, 46907ch, 47050B) -│ │ │ │ └── LegendModal.tsx (aff63c2, 204L, 11228ch, 11246B) +│ │ │ │ ├── FlowsView.tsx (4c2e3be, 963L, 45460ch, 45597B) +│ │ │ │ └── LegendModal.tsx (7894d56, 204L, 11306ch, 11324B) │ │ │ └── graph/ (7) -│ │ │ ├── GraphView.tsx (4c1c0eb, 1455L, 65715ch, 66478B) +│ │ │ ├── GraphView.tsx (1baea66, 1456L, 65993ch, 66772B) │ │ │ ├── layout-store.ts (8a12833, 108L, 3606ch, 3618B) │ │ │ ├── markers.ts (8097d7a, 202L, 7434ch, 7448B) │ │ │ ├── navigator.ts (72cc64f, 53L, 2711ch, 2729B) │ │ │ ├── organic-layout.ts (8512e49, 648L, 31865ch, 32203B) │ │ │ ├── styles.ts (ea7f166, 243L, 8584ch, 8620B) │ │ │ └── wrap-label.ts (ce0e43d, 42L, 1976ch, 1986B) -│ │ ├── App.tsx (864a844, 860L, 39522ch, 39695B) +│ │ ├── App.tsx (943db1a, 862L, 39653ch, 39826B) │ │ ├── globals.d.ts (7e966b0, 51L, 2854ch, 2860B) -│ │ ├── hash-router.ts (98ee6ff, 134L, 4486ch, 4500B) +│ │ ├── hash-router.ts (55e9af6, 136L, 4723ch, 4737B) │ │ ├── index.html (e560504, 14L, 396ch, 396B) │ │ ├── main.tsx (881fa67, 12L, 293ch, 293B) -│ │ └── styles.css (88b858c, 2959L, 69810ch, 70960B) +│ │ └── styles.css (89afed4, 3374L, 78364ch, 79506B) │ ├── cli/ (7) │ │ ├── cli.ts (d7d3e48, 435L, 16195ch, 19355B) │ │ ├── discover.ts (2a355d6, 176L, 5910ch, 5940B) │ │ ├── open-browser.ts (b9b6fa8, 33L, 1206ch, 1210B) │ │ ├── resolve-model.ts (0c23743, 70L, 2276ch, 2294B) │ │ ├── serve-port.ts (0d665d7, 94L, 3345ch, 3351B) -│ │ ├── update.ts (9e8f3fe, 221L, 7942ch, 8196B) +│ │ ├── update.ts (76b4443, 298L, 10708ch, 10964B) │ │ └── version.ts (8ca92d2, 6L, 312ch, 314B) -│ ├── flow-view/ (5) -│ │ ├── FlowChrome.tsx (c81ca71, 449L, 17265ch, 17698B) -│ │ ├── FlowDiagramSvg.tsx (7f12e28, 2213L, 95495ch, 97597B) -│ │ ├── elk-flow-layout.ts (68d267f, 367L, 15654ch, 16479B) -│ │ ├── flow-layout.ts (b38d8e4, 1761L, 76646ch, 77664B) +│ ├── flow-view/ (8) +│ │ ├── FlowChrome.tsx (a999d37, 431L, 16623ch, 17058B) +│ │ ├── FlowDiagramSvg.tsx (c48f7f2, 2234L, 94988ch, 97072B) +│ │ ├── FlowIndex.tsx (ad93d48, 120L, 5188ch, 5192B) +│ │ ├── LevelMenu.tsx (db3095c, 123L, 4786ch, 4797B) +│ │ ├── elk-flow-layout.ts (17fe2ce, 382L, 16716ch, 17539B) +│ │ ├── flow-layout.ts (bd166cc, 1800L, 77834ch, 78850B) +│ │ ├── flow-nav.ts (016aa1f, 185L, 6746ch, 6748B) │ │ └── zoom-scale.ts (5a575f2, 74L, 3068ch, 3092B) │ ├── flows/ (8) │ │ ├── flow-clusters.ts (4f03045, 130L, 5811ch, 5827B) -│ │ ├── flow-derive-levels.ts (44aabf8, 434L, 15982ch, 16052B) +│ │ ├── flow-derive-levels.ts (9353cc0, 435L, 16072ch, 16142B) │ │ ├── flow-fingerprint.ts (a013654, 90L, 3428ch, 3442B) │ │ ├── flow-markdown.ts (c2ac196, 39L, 1839ch, 1843B) -│ │ ├── flow-parse.ts (551c7bc, 849L, 33348ch, 33442B) +│ │ ├── flow-parse.ts (5ed5368, 889L, 35114ch, 35210B) │ │ ├── flow-usage-index.ts (0d79284, 244L, 7296ch, 7321B) │ │ ├── flow-validate.ts (885f95b, 853L, 33178ch, 33234B) │ │ └── titlelize.ts (c7f9798, 47L, 1661ch, 1685B) @@ -557,7 +563,7 @@ │ │ └── wikilink.ts (b6c297b, 98L, 3416ch, 3426B) │ ├── router/ (6) │ │ ├── agents.ts (3da0e86, 191L, 9053ch, 9061B) -│ │ ├── build.ts (a67fab6, 359L, 12982ch, 13011B) +│ │ ├── build.ts (813b48c, 365L, 13368ch, 13397B) │ │ ├── detect.ts (4c3bc20, 33L, 1132ch, 1134B) │ │ ├── fingerprint.ts (efec556, 33L, 1132ch, 1134B) │ │ ├── region.ts (d7894db, 134L, 4955ch, 4959B) @@ -566,14 +572,14 @@ │ │ └── server.ts (41860fc, 182L, 7293ch, 7303B) │ ├── theme/ (2) │ │ ├── branding-defaults.ts (9e1300e, 134L, 4495ch, 4497B) -│ │ └── theme-defaults.ts (fb1a850, 188L, 6659ch, 6813B) +│ │ └── theme-defaults.ts (195145b, 199L, 6934ch, 7088B) │ └── 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/ (5) -│ ├── checks/ (103) +│ ├── checks/ (108) │ │ ├── test-api-model.ts (7cb8b51, 97L, 3807ch, 3809B) │ │ ├── test-app-gen-zero-diagrams.ts (5bb5382, 131L, 4343ch, 4599B) │ │ ├── test-app-title.ts (53472fb, 144L, 5253ch, 5631B) @@ -601,7 +607,7 @@ │ │ ├── test-derive-classification.ts (3343b3f, 143L, 4434ch, 4768B) │ │ ├── test-description-field.ts (50b18bb, 156L, 5410ch, 5412B) │ │ ├── test-dfd-edge-hover.ts (f7a3267, 214L, 9005ch, 9011B) -│ │ ├── test-dfd-stack-dialogs.ts (3599658, 579L, 31331ch, 31373B) +│ │ ├── test-dfd-stack-dialogs.ts (815576a, 625L, 33561ch, 33603B) │ │ ├── test-dict-render-coverage.ts (11283b9, 252L, 11650ch, 12035B) │ │ ├── test-dict-route.ts (941d985, 70L, 1848ch, 1848B) │ │ ├── test-dict-search-branding-overlap.ts (431e394, 160L, 6881ch, 7031B) @@ -611,30 +617,33 @@ │ │ ├── test-entity-usage-index.ts (3ac8bb6, 190L, 7486ch, 7498B) │ │ ├── test-export-union-injection.ts (7827bfc, 200L, 9296ch, 9792B) │ │ ├── test-findings-panel.ts (73eedf7, 147L, 6944ch, 6950B) -│ │ ├── test-flow-chip-placement.ts (6c37aa9, 318L, 15232ch, 15258B) +│ │ ├── test-flow-chip-placement.ts (0ce7dae, 336L, 15965ch, 15991B) │ │ ├── test-flow-cli.ts (872d124, 272L, 12366ch, 12404B) │ │ ├── test-flow-clusters-parse.ts (099ff49, 293L, 14586ch, 14592B) │ │ ├── test-flow-clusters-validate.ts (7830e73, 172L, 8370ch, 8376B) +│ │ ├── test-flow-diagram-description.ts (02b5fab, 140L, 7731ch, 7733B) │ │ ├── test-flow-edge-labels.ts (492c72c, 264L, 12853ch, 12869B) │ │ ├── test-flow-endpoints.ts (8e80450, 316L, 13756ch, 13778B) │ │ ├── test-flow-fingerprint.ts (be5f9d0, 393L, 14910ch, 14982B) │ │ ├── test-flow-leveling.ts (8c38db9, 468L, 18593ch, 18651B) +│ │ ├── test-flow-nav.ts (d43d8f1, 122L, 8354ch, 8356B) │ │ ├── test-flow-search.ts (648cac6, 358L, 18845ch, 18887B) │ │ ├── test-flow-serve.ts (272afb2, 186L, 7205ch, 7707B) │ │ ├── test-flow-spotlight-connections.ts (9e58dcd, 349L, 16296ch, 16376B) -│ │ ├── test-flow-view-grouping.ts (b3b331c, 778L, 47199ch, 47261B) +│ │ ├── test-flow-view-grouping.ts (3f5809b, 857L, 50332ch, 50394B) │ │ ├── test-flow-wikilink.ts (699f762, 49L, 2157ch, 2165B) │ │ ├── test-folder-model.ts (a19559a, 237L, 8937ch, 8957B) │ │ ├── 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-inherited-edges.ts (b04e285, 277L, 11982ch, 12358B) │ │ ├── test-graph-search.ts (27925c7, 344L, 16756ch, 16808B) -│ │ ├── test-hash-router.ts (611b00b, 255L, 8745ch, 8827B) +│ │ ├── test-hash-router.ts (c10d4f9, 262L, 9135ch, 9217B) │ │ ├── test-help-overlay.ts (8d52edc, 140L, 5469ch, 5697B) │ │ ├── test-index-config.ts (49bfc7b, 201L, 8715ch, 8721B) │ │ ├── test-inherited-edges-no-leak.ts (0c8a942, 120L, 5250ch, 5348B) │ │ ├── test-json-value.ts (f48fbe3, 126L, 4802ch, 4830B) │ │ ├── test-keyboard-shortcuts.ts (28cb588, 297L, 12141ch, 12172B) +│ │ ├── test-large-model-nav.ts (9fc6570, 251L, 14042ch, 14532B) │ │ ├── test-layout-fingerprint.ts (92d8eea, 255L, 8386ch, 8464B) │ │ ├── test-layout-key-injection.ts (1ff0306, 104L, 4296ch, 4300B) │ │ ├── test-layout-store.ts (9c4a003, 157L, 6200ch, 6236B) @@ -642,6 +651,7 @@ │ │ ├── test-modal-history.ts (3b62815, 187L, 7092ch, 7173B) │ │ ├── test-mode-flag.ts (52f3812, 136L, 5187ch, 5195B) │ │ ├── test-model-index.ts (f6e304f, 409L, 17447ch, 17469B) +│ │ ├── test-motion.ts (df6288a, 104L, 4417ch, 4421B) │ │ ├── test-navigator-teardown.ts (a3776c7, 76L, 3299ch, 3323B) │ │ ├── test-open-browser.ts (e9f001b, 25L, 1122ch, 1126B) │ │ ├── test-parse-examples.ts (fb9b559, 121L, 3875ch, 3891B) @@ -656,7 +666,7 @@ │ │ ├── test-router-region.ts (7ebc6f5, 689L, 21800ch, 21872B) │ │ ├── test-semantic-colors.ts (60b1f25, 71L, 2206ch, 2206B) │ │ ├── test-serve-port.ts (6e3e5fb, 52L, 1928ch, 2192B) -│ │ ├── test-shortcuts.ts (1edcc27, 506L, 23281ch, 23527B) +│ │ ├── test-shortcuts.ts (694816c, 523L, 24372ch, 24634B) │ │ ├── test-skill-no-repo-paths.ts (c7f12c3, 68L, 2714ch, 2734B) │ │ ├── test-spotlight-connections.ts (0806e1c, 282L, 10879ch, 10931B) │ │ ├── test-spotlight-inherited.ts (8f7b2c4, 388L, 18139ch, 18383B) @@ -666,6 +676,7 @@ │ │ ├── test-theme-parse.ts (f273d8f, 58L, 2435ch, 2435B) │ │ ├── test-titlelize.ts (f6d0ce4, 83L, 4692ch, 4742B) │ │ ├── test-update-helpers.ts (12f44c6, 91L, 3768ch, 4380B) +│ │ ├── test-update-progress.ts (4edda93, 71L, 2684ch, 3154B) │ │ ├── test-validate-body-links.ts (2a8ed16, 78L, 2958ch, 2968B) │ │ ├── test-validate-entity.ts (6988d0e, 238L, 10322ch, 10358B) │ │ ├── test-validate-examples.ts (d90bba4, 301L, 12219ch, 12276B) @@ -768,7 +779,7 @@ │ ├── notes/ (2) │ │ ├── another-idea.md (1e542c5, 384L, 16674ch, 17352B) │ │ └── branding-screenshots.md (027cad8, 33L, 2524ch, 2537B) -│ ├── visual/ (66) +│ ├── visual/ (67) │ │ ├── screenshot-branding.ts (702ea86, 65L, 2336ch, 2338B) │ │ ├── screenshot-chrome-unification.ts (a7d7479, 326L, 13382ch, 14842B) │ │ ├── screenshot-cp4-live-dict.ts (f0f85bc, 34L, 1043ch, 1043B) @@ -777,11 +788,12 @@ │ │ ├── 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-hover-fade.ts (1dd60a0, 106L, 3767ch, 3771B) │ │ ├── screenshot-json-values.ts (fbd5b21, 123L, 5534ch, 5546B) -│ │ ├── screenshot-lineage-highlight.ts (4b8cd1e, 104L, 3840ch, 3860B) +│ │ ├── screenshot-large-model-nav.ts (8c58980, 90L, 3592ch, 3596B) +│ │ ├── screenshot-lineage-highlight.ts (0751e41, 113L, 4290ch, 4310B) │ │ ├── screenshot-position-persist.ts (d0580d9, 288L, 12113ch, 12280B) -│ │ ├── screenshot-predicate-hover.ts (b45d185, 138L, 5952ch, 5964B) +│ │ ├── screenshot-predicate-hover.ts (c464838, 146L, 6415ch, 6427B) │ │ ├── screenshot-store-clusters.ts (fd1a147, 224L, 10339ch, 10353B) │ │ ├── screenshot-themes.ts (14d36c6, 40L, 1335ch, 1335B) │ │ ├── screenshot-toggle.ts (e406fba, 26L, 800ch, 800B) @@ -796,7 +808,7 @@ │ │ ├── test-cp12-dd-body-links.ts (f888ba8, 284L, 12366ch, 12760B) │ │ ├── test-cp13-external-store-parity.ts (54a18da, 354L, 13857ch, 15237B) │ │ ├── test-cp14-no-text-select.ts (1b8112b, 231L, 8287ch, 9163B) -│ │ ├── test-cp15-kind-colors.ts (e77daad, 186L, 7199ch, 7781B) +│ │ ├── test-cp15-kind-colors.ts (1805b38, 191L, 7622ch, 8200B) │ │ ├── test-cp16-process-examples.ts (b10f982, 233L, 8946ch, 9990B) │ │ ├── test-cp18-navigator-crash.ts (0b24138, 262L, 10185ch, 11375B) │ │ ├── test-cp19-minimap-parity.ts (794798e, 415L, 16763ch, 19253B) @@ -823,7 +835,7 @@ │ │ ├── test-cp8a-flow-redirect.ts (cc77166, 190L, 7762ch, 8072B) │ │ ├── test-cp9-dd-search-highlight.ts (8498b08, 232L, 9605ch, 10825B) │ │ ├── test-cp9a-modal-primitive.ts (9301dbd, 270L, 11018ch, 11400B) -│ │ ├── test-dd-spotlight-grid.ts (cce6095, 5090L, 245025ch, 255932B) +│ │ ├── test-dd-spotlight-grid.ts (07b197c, 5145L, 247541ch, 258444B) │ │ ├── test-dd-sticky-search.ts (edf16e7, 157L, 6282ch, 6564B) │ │ ├── test-deep-nesting.ts (8b1d8f9, 142L, 5017ch, 5749B) │ │ ├── test-dfd-edge-hover.ts (d60cb93, 168L, 6163ch, 6577B) @@ -831,7 +843,7 @@ │ │ ├── test-export-offline.ts (9d1630a, 308L, 14287ch, 15241B) │ │ ├── test-flow-live-reload.ts (b173873, 202L, 7775ch, 8463B) │ │ ├── test-flow-search.ts (4e8e5b5, 98L, 3961ch, 3979B) -│ │ ├── test-graph-inherited-lines.ts (320bd0d, 330L, 14352ch, 14548B) +│ │ ├── test-graph-inherited-lines.ts (2829243, 316L, 13776ch, 13972B) │ │ ├── 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) @@ -856,10 +868,9 @@ │ ├── samples/ (1) │ │ └── sample_model.yaml (72d3fa6, 532L, 17934ch, 17962B) │ └── App.tsx (8ff50d4, 78L, 2470ch, 2474B) -├── .dirty (e3b0c44, 0L, 0ch, 0B) ├── .gitignore (6d3972b, 53L, 809ch, 811B) ├── .signalsignore (50ea1be, 28L, 1036ch, 1050B) -├── CHANGELOG.md (391d9b2, 283L, 20812ch, 20818B) +├── CHANGELOG.md (d299576, 312L, 22075ch, 22081B) ├── CLAUDE.md (2b92f41, 51L, 2352ch, 2364B) ├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) @@ -869,21 +880,21 @@ ├── bun.lock (c190846, 202L, 18164ch, 18164B) ├── bunfig.toml (3ab75b0, 2L, 35ch, 35B) ├── install.sh (64a6757, 116L, 3578ch, 3594B) -├── package.json (7ed6c16, 52L, 1931ch, 1933B) +├── package.json (aabfdd9, 52L, 1931ch, 1933B) ├── release-please-config.json (9f191fe, 15L, 416ch, 416B) -├── release-please-manifest.json (8e08753, 3L, 20ch, 20B) +├── release-please-manifest.json (8fb3c0b, 3L, 20ch, 20B) └── tsconfig.json (1d9427f, 37L, 870ch, 870B) ``` ## Manifests -- package.json: name=ignatius, version=0.18.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] +- package.json: name=ignatius, version=0.20.2, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] ## Languages -- TypeScript: 69146 LOC (70%), 300 files (40%) -- Markdown: 24478 LOC (24%), 414 files (55%) -- CSS: 3146 LOC (3%), 2 files (0%) +- TypeScript: 70979 LOC (70%), 310 files (41%) +- Markdown: 25062 LOC (24%), 417 files (55%) +- CSS: 3561 LOC (3%), 2 files (0%) - YAML: 1348 LOC (1%), 16 files (2%) - Shell: 116 LOC (0%), 1 file (0%) - JSON: 107 LOC (0%), 4 files (0%) diff --git a/docs/wiki/skill.md b/docs/wiki/skill.md index 55d8a68..6f8ab67 100644 --- a/docs/wiki/skill.md +++ b/docs/wiki/skill.md @@ -34,7 +34,7 @@ flowchart LR - `flow` — author a DFD for a user who already knows their processes (`references/dfd-authoring.md`, steps F0-F9, with lettered sub-steps F4a and F6a). - `discover` — a five-gate Socratic interview that generates both entities and flows (`references/discover-flow.md`); it routes to `references/reverse-engineering.md` (phases R0-R4) when a live database, codebase, schema, or API exists to read instead of a user description. -Ten core rules in `SKILL.md` apply across all four modes: derive-never-ask (classification and per-edge `identifying` come from key shape, never a question), convention-is-derived (an entity's PK shape *is* its key-inherited-vs-orm-oriented style), adapt-to-the-user's-conventions (never invent naming), existence-rules-survive-the-key-style (a mandatory parent is asserted by key placement or by a documented `nullable: false` rule), subtype-independence (Subtype classification comes from cluster membership, never a direct ask), predicates-carry-business-meaning (push past "has many" to a domain verb), examples-always (every entity gets 2-3 `examples:` rows, every process gets `in`/`out` examples, generated by the skill and never skipped), `description:`-always (every entity, group, process, external, and store gets a one-line `description:` frontmatter field, generated by the skill, never skipped, and never named after the model's reserved `index_file` basename), capture-the-business-story (rules, constraints, and lifecycle go in the body with their source), and labels-and-clusters-always (every flow entry carries a prose `label:`, and stores a process treats as one thing are declared in `clusters/.md` and referenced with one `cluster:` entry; `dfd-authoring.md` Steps F4a and F5). +Ten core rules in `SKILL.md` apply across all four modes: derive-never-ask (classification and per-edge `identifying` come from key shape, never a question), convention-is-derived (an entity's PK shape *is* its key-inherited-vs-orm-oriented style), adapt-to-the-user's-conventions (never invent naming), existence-rules-survive-the-key-style (a mandatory parent is asserted by key placement or by a documented `nullable: false` rule), subtype-independence (Subtype classification comes from cluster membership, never a direct ask), predicates-carry-business-meaning (push past "has many" to a domain verb), examples-always (every entity gets 2-3 `examples:` rows, every process gets `in`/`out` examples, generated by the skill and never skipped), `description:`-always (every entity, group, process, external, and store gets a one-line `description:` frontmatter field, plus every top-level flow folder's `flows//index.md`, generated by the skill, never skipped, and never named after the model's reserved `index_file` basename), capture-the-business-story (rules, constraints, and lifecycle go in the body with their source), and labels-and-clusters-always (every flow entry carries a prose `label:`, and stores a process treats as one thing are declared in `clusters/.md` and referenced with one `cluster:` entry; `dfd-authoring.md` Steps F4a and F5). `references/interviewing.md` sets the conversational discipline every mode follows: one question at a time, explain the why, write files as answers land rather than only proposing YAML, infer from existing files before asking, and reflect on validator findings rather than blindly regenerating. @@ -83,8 +83,8 @@ A second, separate gate covers router staleness: `ignatius validate --index /index.md` description), sub-DFD decomposition (F8) | +| [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) | Flow folder `index.md`, process, external, non-`db` store, and cluster file templates and worked examples | | [`skills/ignatius-modeling/references/discover-flow.md`](../../skills/ignatius-modeling/references/discover-flow.md) | `discover` mode: the five gates, verbs-first shape, banned-term list | | [`skills/ignatius-modeling/references/reverse-engineering.md`](../../skills/ignatius-modeling/references/reverse-engineering.md) | Extracting entities + flows from a live system, phases R0-R4 | | [`skills/ignatius-modeling/references/conventions.md`](../../skills/ignatius-modeling/references/conventions.md) | Reserved `index_file` basename, column types, classification/cardinality derivation tables | diff --git a/docs/wiki/theme.md b/docs/wiki/theme.md index f1cb481..5ca5951 100644 --- a/docs/wiki/theme.md +++ b/docs/wiki/theme.md @@ -1,49 +1,124 @@ --- type: Domain description: Default theme palettes, DFD flow-kind colors, and branding config with their deep-merge functions +tags: [theme, frontend, branding] --- # theme ## What it does -Defines the built-in color/spacing defaults and branding (logo/title/copyright) for the app, and the merge functions that layer a project's `ignatius.yml` `theme:`/`branding:` overrides on top of those defaults. Consumed by [`src/model/parse.ts`](../../src/model/parse.ts), which is the only caller of `mergeTheme`/`mergeBranding`, and by frontend/flow-view rendering code that reads the resulting `ThemeConfig`/`Branding` values. - -## CLI code - -- [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) — exports `defaultTheme: ThemeConfig`, `mergeTheme()`, `semanticColors`, `defaultFlowKinds`, `FLOW_KIND_KEYS`, `resolveFlowKindPalette()`, and the `ThemeConfig`/`ThemePalette`/`ThemeSpacing`/`ThemeMode`/`FlowKindEntry`/`FlowKindKey` types. - - `ThemePalette` (per mode `dark`/`light`): `background`, `surface`, `border`, `text`, `textMuted`, `edgeIdentifying`, `edgeReferential`, `pastelMix` (number). - - `ThemeSpacing`: `nodeSep`, `markerOffset`, `markerScale: [number, number]`. - - `ThemeConfig`: `{ dark: ThemePalette; light: ThemePalette; spacing: ThemeSpacing; flowKinds?: Partial; light: Partial }>>> }`. - - `defaultTheme` supplies concrete dark/light palettes (e.g. dark `background: '#0e1116'`, light `background: '#ffffff'`) and `spacing: { nodeSep: 60, markerOffset: 10, markerScale: [0.5, 2.5] }`. - - `semanticColors` maps entity classification names (`independent`, `dependent`, `classifier`, `subtype`, `associative`, plus a `link` color) to `{ bg, fg }` pairs, one full set per `ThemeMode` (`'dark' | 'light'`). Not part of `ThemeConfig` — it is a fixed export, not user-overridable. - - `FLOW_KIND_KEYS = ['db', 'cache', 'queue', 'file', 'doc', 'manual', 'other', 'external']` and `FlowKindKey` is its element union. - - `FlowKindEntry = { bg, fg, border }` — one color triple per DFD store/external kind. - - `defaultFlowKinds: Record>` holds dark + light entries for all 8 kinds. Per the inline comments, `db` and `external` intentionally keep their pre-existing colors (`db` dark: `bg: '#3d2e00', fg: '#f2d49b', border: '#d29922'`; `external` dark: `bg: '#1a3a1a', fg: '#b7f0c4', border: '#3fb950'`); `cache`/`queue`/`file`/`doc`/`manual`/`other` are new distinct, mode-appropriate colors (amber/violet/lime/sky/rose/slate respectively). - - `mergeTheme(partial)` shallow-merges `partial.dark`/`partial.light`/`partial.spacing` over `defaultTheme`'s corresponding keys (object-spread, one level deep — not a deep per-field default fallback beyond that), and passes `partial.flowKinds` through as-is (no default-merge at this level) when present, omitting the `flowKinds` key entirely from the result when absent. - - `resolveFlowKindPalette(mode, flowKinds?)` is the actual deep merge for flow-kind colors: for each key in `FLOW_KIND_KEYS`, if `flowKinds[key][mode]` exists, it spreads that partial `FlowKindEntry` over `defaultFlowKinds[mode][key]`, so a user can override just `bg` without losing `fg`/`border`. Returns `defaultFlowKinds[mode]` unchanged when no `flowKinds` argument is given. -- [`src/theme/branding-defaults.ts`](../../src/theme/branding-defaults.ts) — exports `Branding`, `LogoPair`, `CopyrightConfig` types, `defaultBranding`, and `mergeBranding()`. - - `LogoPair = { dark: string; light: string }`, `CopyrightConfig = { holder: string; year: number }`, `Branding = { logo: LogoPair; title: string; subtitle: string; copyright: CopyrightConfig; poweredBy: boolean }`. - - At module load, `noormLogoPath` is imported from [`assets/noorm-logo.svg`](../../assets/noorm-logo.svg) via `with { type: 'file' }` (a Bun file import, embedded into the compiled binary at `$bunfs/`), read with `Bun.file(...).arrayBuffer()`, and base64-encoded into `NOORM_DEFAULT_LOGO`, a `data:image/svg+xml;base64,...` URI used for both `logo.dark` and `logo.light` in `defaultBranding`. - - `defaultBranding`: `title: 'Noorm Ignatius'`, `subtitle: 'Visualize your data model'`, `poweredBy: true`, and a `copyright` getter returning `{ holder: 'Noorm Ignatius', year: new Date().getFullYear() }` (computed per access, not frozen at module load). - - `mergeBranding(userInput)` accepts `RawBrandingInput` (`logo` as either a bare string or `{ dark?, light? }`, plus `title`, `subtitle`, `copyright`, `poweredBy`, all optional). It throws `Error` if `title` or `subtitle` exceeds 50 characters. `normalizeLogo()` fills a missing `dark`/`light` from the other side, or falls back to `NOORM_DEFAULT_LOGO` if both are absent; an explicit `null` in the object form also falls through to the embedded default (documented in a `WHY` comment). `copyright.holder`/`copyright.year` and `poweredBy` each fall back independently to `defaultBranding`'s values when not supplied. - -## Docs - -- [`docs/design/branding.md`](../design/branding.md) — design doc for the branding system. -- [`docs/spec/branding.md`](../spec/branding.md) — implementation spec for the branding system. -- [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) — user-facing guide: theme/branding config lives in optional `theme:`/`branding:` blocks in `ignatius.yml`; built-in defaults apply when absent; all subcommands read the same config so the interactive view, data dictionary, and static graph render consistently. +Owns the built-in color/spacing defaults and branding (logo/title/copyright) for the app, plus the merge functions that layer a project's `ignatius.yml` `theme:`/`branding:` overrides on top of those defaults. Without it, every render path (Graph, Dictionary, Flows, static export) would need its own fallback logic for colors and logos instead of reading one resolved `ThemeConfig`/`Branding` object. Consumed by [`src/model/parse.ts`](../../src/model/parse.ts), the only caller of `mergeTheme`/`mergeBranding`, and by frontend/flow-view rendering code that reads the resulting values. -## Coupling +## How it works -- [`src/model/parse.ts`](../../src/model/parse.ts) is the sole caller of `mergeTheme()` and `mergeBranding()`: `parseModels()` reads `ignatius.yml`, and when it has a `theme:` or `branding:` block, passes it through the respective merge function; otherwise it uses `defaultTheme`/`defaultBranding` directly. `parse.ts` also re-exports the `ThemeConfig` and `Branding` types, so most consumers import these types from `../model/parse` rather than directly from [`src/theme/`](../../src/theme). -- `resolveFlowKindPalette()` is imported directly from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) (not re-exported through `parse.ts`) by [`src/app/App.tsx`](../../src/app/App.tsx), [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx), [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx), and [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) to render DFD flow-kind swatches/legends. `defaultFlowKinds` and `FLOW_KIND_KEYS` are referenced only inside [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) itself and in test files, not by any of those consumers. -- Frontend consumers reading `ThemeConfig`/`ThemePalette`/`semanticColors` values include [`src/app/App.tsx`](../../src/app/App.tsx), [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts), [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), [`src/app/views/graph/markers.ts`](../../src/app/views/graph/markers.ts), [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts), and [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) — changing a `ThemePalette`/`ThemeSpacing` field name or shape forces updates across these. [`src/app/components/entity/FlowNodeGridCard.tsx`](../../src/app/components/entity/FlowNodeGridCard.tsx), [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx), and [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) instead import only the `FlowKindKey`/`FlowKindEntry` types from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) — changing a `FlowKindEntry` field name or shape forces updates there too. [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) is a mixed case: it imports `resolveFlowKindPalette` as a value (see above) alongside the `FlowKindKey`/`FlowKindEntry` types in the same statement, so it is affected by changes to either. -- Conversely, adding a new theme-configurable value (e.g. a new palette field or a new `FlowKindKey`) requires updating [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)'s defaults and merge logic, [`src/model/parse.ts`](../../src/model/parse.ts)'s YAML parsing, and the [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) guide to keep them in sync. -- `branding-defaults.ts` imports [`assets/noorm-logo.svg`](../../assets/noorm-logo.svg) directly (a file-import, not routed through generators), coupling it to Bun's `with { type: 'file' }` embedding behavior relied on by both the dev server and the compiled binary. +`mergeTheme()` and `resolveFlowKindPalette()` do different jobs at different times: the former runs once, at parse time, over the whole `ThemeConfig`; the latter runs per render, per mode, only over flow-kind colors. + +```mermaid +flowchart TD + Yaml["ignatius.yml theme:"] --> MergeTheme["mergeTheme(partial)"] + MergeTheme --> PerPalette["dark / light / spacing: {...defaultTheme[x], ...partial[x]}"] + MergeTheme --> RawFlowKinds["flowKinds: partial.flowKinds as-is (unresolved)"] + RawFlowKinds --> Resolve["resolveFlowKindPalette(mode, flowKinds)"] + Defaults["defaultFlowKinds[mode]"] --> Resolve + Resolve --> PerKind{"override[key][mode] present?"} + PerKind -->|yes| Spread["{...defaults[key], ...modeOverride}"] + PerKind -->|no| Keep["defaults[key] unchanged"] +``` + +`dark`, `light`, and `spacing` each get the identical one-level object-spread shown above; only which default object and which partial object get spread differs. `mergeTheme` never touches `flowKinds` beyond a shallow copy, so `ThemeConfig.flowKinds` after a merge is the raw override set, not a resolved palette. Every consumer that needs actual flow-kind colors calls `resolveFlowKindPalette(mode, theme.flowKinds)` itself rather than reading `theme.flowKinds` directly. + +### Logo resolution has two independent fallback points + +`branding-defaults.ts` resolves a logo value twice: once when merging user config (`normalizeLogo`, sync, string-or-object shape), and again when embedding for export (`inlineLogo`, async, reads bytes off disk or passes a URI through). + +#### Merge time: string shorthand and per-side fallback + +`normalizeLogo` short-circuits on a bare string before any object-shape fallback logic runs. + +```mermaid +flowchart TD + Input["branding.logo (string | {dark?, light?} | undefined)"] --> IsUndef{"logo === undefined?"} + IsUndef -->|yes| DefaultPair["defaultBranding.logo (NOORM_DEFAULT_LOGO for both)"] + IsUndef -->|no| IsString{"typeof input === 'string'?"} + IsString -->|yes| StringPair["LogoPair {dark: input, light: input}"] + IsString -->|no| BothSides{"dark and light both given?"} + BothSides -->|no, one side missing or null| Fallback["fill missing side from the other, or NOORM_DEFAULT_LOGO if both absent"] + BothSides -->|yes| Pair["LogoPair {dark, light}"] + Fallback --> Pair +``` + +`normalizeLogo` runs inside `mergeBranding`, synchronously, from a raw YAML value. + +#### Export time: reading bytes or passing a URI through + +`inlineLogo` runs once per side, after merge, to turn each resolved value into something an `` can render standalone. + +```mermaid +flowchart TD + Value["LogoPair.dark / LogoPair.light"] --> Scheme{"starts with data:/http(s)://?"} + Scheme -->|yes| PassThrough["pass through unchanged"] + Scheme -->|no| ExtCheck{"extension in LOGO_MIME_BY_EXT?"} + ExtCheck -->|no| Embedded["NOORM_DEFAULT_LOGO"] + ExtCheck -->|yes| Exists{"file exists at resolved path?"} + Exists -->|no| Embedded + Exists -->|yes| Encode["base64-encode into data: URI"] +``` + +`inlineLogo` runs inside `inlineBrandingLogos`, asynchronously, against the resolved model root. A broken image from an unreadable local file is never possible in the served/exported output: every branch that cannot resolve a real file lands on `NOORM_DEFAULT_LOGO`. The pass-through branch is the exception: an `http(s)://` override keeps that live URL in the exported HTML instead of embedding it (see Constraints). -## Conventions worth knowing +### Flow-kind colors, per mode -- The default noorm logo is embedded as a base64 `data:` URI at module load time rather than referenced by path or fetched at runtime, so generated output (including the compiled binary) never needs a network or filesystem request for the default logo. [`test/checks/test-branding-zero-network.ts`](../../test/checks/test-branding-zero-network.ts) verifies this end-to-end (via Playwright, blocking all non-`file://`/`data:` requests) for both the dev `export` path and the compiled binary's `export` output. -- `mergeTheme()` and `resolveFlowKindPalette()` use different merge strategies: `mergeTheme()` does a shallow one-level object-spread per palette (`dark`, `light`, `spacing`), while `resolveFlowKindPalette()` does a per-kind, per-mode `FlowKindEntry`-level merge so a single overridden field (e.g. just `bg`) doesn't drop the rest of that kind's colors. -- `semanticColors` (entity classification colors) is a fixed, non-overridable export — unlike `ThemeConfig` and `flowKinds`, there is no merge function or user-facing config key for it. +Each of the 8 `FlowKindKey` values gets one `FlowKindEntry` (`bg`, `fg`, `border`) per mode. `db` and `external` keep their pre-existing colors; the other six are new, mode-appropriate hues assigned when `defaultFlowKinds` grew from 2 kinds to 8. + +| Kind | Dark bg / fg / border | Light bg / fg / border | Store cap symbol | +|---|---|---|---| +| `db` | `#3d2e00` / `#f2d49b` / `#d29922` | `#fef9c3` / `#713f12` / `#ca8a04` | `D` | +| `cache` | `#451a03` / `#fcd34d` / `#d97706` | `#fef3c7` / `#92400e` / `#d97706` | `C` | +| `queue` | `#2e1065` / `#c4b5fd` / `#7c3aed` | `#ede9fe` / `#4c1d95` / `#7c3aed` | `Q` | +| `file` | `#1a2e05` / `#bef264` / `#65a30d` | `#f7fee7` / `#365314` / `#65a30d` | `F` | +| `doc` | `#082f49` / `#7dd3fc` / `#0284c7` | `#e0f2fe` / `#0c4a6e` / `#0284c7` | `Do` | +| `manual` | `#4c0519` / `#fda4af` / `#e11d48` | `#fff1f2` / `#881337` / `#e11d48` | `M` | +| `other` | `#1e293b` / `#94a3b8` / `#475569` | `#f1f5f9` / `#334155` / `#64748b` | `O` | +| `external` | `#1a3a1a` / `#b7f0c4` / `#3fb950` | `#dcfce7` / `#14532d` / `#16a34a` | (none, not a store) | + +`FLOW_STORE_KIND_SYMBOLS` covers every key except `external` (it is not a store kind) and supplies the compact cap letter (`D`, `C`, `Q`, `F`, `Do`, `M`, `O`) that `storeCapLabel()` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) appends a per-stack store number to (e.g. `D1`, `C2`), and that `KindMarker.tsx`/`LegendModal.tsx` render directly as the store's marker glyph. + +## Where it lives + +| Path | What | +|---|---| +| [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) | `ThemeConfig`/`ThemePalette`/`ThemeSpacing`/`ThemeMode`/`FlowKindEntry`/`FlowKindKey` types, `defaultTheme`, `mergeTheme()`, `semanticColors`, `FLOW_KIND_KEYS`, `FLOW_STORE_KIND_SYMBOLS`, `defaultFlowKinds`, `resolveFlowKindPalette()` | +| [`src/theme/branding-defaults.ts`](../../src/theme/branding-defaults.ts) | `Branding`/`LogoPair`/`CopyrightConfig` types, `defaultBranding`, `mergeBranding()`, `inlineBrandingLogos()` | +| [`assets/noorm-logo.svg`](../../assets/noorm-logo.svg) | Embedded default logo, imported via Bun's `with { type: 'file' }` | +| [`docs/design/branding.md`](../design/branding.md) | Design doc for the branding system | +| [`docs/spec/branding.md`](../spec/branding.md) | Implementation spec for the branding system | +| [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) | User-facing guide: `theme:`/`branding:` blocks in `ignatius.yml`, defaults when absent | + +`ThemePalette` (per mode) holds `background`, `surface`, `border`, `text`, `textMuted`, `edgeIdentifying`, `edgeReferential`, `pastelMix` (number). `ThemeSpacing` holds `nodeSep`, `markerOffset`, `markerScale: [number, number]`. `defaultTheme` supplies concrete dark/light values (dark `background: '#0e1116'`, light `background: '#ffffff'`) and `spacing: { nodeSep: 60, markerOffset: 10, markerScale: [0.5, 2.5] }`. + +`semanticColors` maps entity classification names (`independent`, `dependent`, `classifier`, `subtype`, `associative`, plus a `link` color) to `{ bg, fg }` pairs, one full set per `ThemeMode`. It is a fixed export, not part of `ThemeConfig`, and has no merge function or user-facing config key. + +`Branding = { logo: LogoPair; title: string; subtitle: string; copyright: CopyrightConfig; poweredBy: boolean }`. `defaultBranding.copyright` is a getter that returns `{ holder: 'Noorm Ignatius', year: new Date().getFullYear() }`, computed per access rather than frozen at module load. `defaultBranding.logo.dark`/`.light` are both `NOORM_DEFAULT_LOGO`, a `data:image/svg+xml;base64,...` URI built at module load from [`assets/noorm-logo.svg`](../../assets/noorm-logo.svg). + +## Constraints + +| Constraint | Detail | +|---|---| +| `mergeTheme` merge depth | Shallow, one level of object-spread per palette (`dark`, `light`, `spacing`). A partial `dark: { background }` override keeps the rest of `defaultTheme.dark` intact, but a nested field inside a non-palette object would not merge this way. | +| `flowKinds` bypasses `mergeTheme` | `mergeTheme` passes `partial.flowKinds` through unresolved (a shallow copy, or omitted entirely when absent). Reading `ThemeConfig.flowKinds` directly gets the override set, not usable colors; always go through `resolveFlowKindPalette(mode, flowKinds)`. | +| `resolveFlowKindPalette` merge depth | Per-kind, per-mode, at the `FlowKindEntry` level: `{...defaults[key], ...modeOverride}`. A caller does not need to supply the full `{bg, fg, border}` triple: a partial `{ bg }` override keeps the default `fg`/`border` for that kind. | +| `title`/`subtitle` length | `mergeBranding` throws `Error` if either exceeds 50 characters. | +| Explicit `null` in object-form logo | Falls through to `NOORM_DEFAULT_LOGO`, by design (documented in a `WHY` comment in `normalizeLogo`). | +| Only the default logo is zero-network | The default logo is base64-embedded at module load, never fetched or referenced by path, so generated output (including the compiled binary) needs no network/filesystem request for it. [`test/checks/test-branding-zero-network.ts`](../../test/checks/test-branding-zero-network.ts) verifies this default-logo path (parses a model with no `_branding.yaml` override) via Playwright (blocking all non-`file://`/`data:` requests) for both the dev `export` path and the compiled binary's `export` output. An `http(s)://` logo override is not covered by that test: `inlineLogo` passes it through unchanged, so the exported output keeps a live network reference. | +| `FLOW_STORE_KIND_SYMBOLS` excludes `external` | Its type is `Record, string>`; indexing it with `'external'` is a type error, not a runtime gap. | + +## Coupling + +- [`src/model/parse.ts`](../../src/model/parse.ts) is the sole caller of `mergeTheme()` and `mergeBranding()`: `parseModels()` reads `ignatius.yml` and passes its `theme:`/`branding:` block through the respective merge function when present, otherwise uses `defaultTheme`/`defaultBranding` directly. `parse.ts` also re-exports the `ThemeConfig` and `Branding` types, but most `ThemeConfig` consumers import it directly from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) instead: [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), [`src/app/views/graph/markers.ts`](../../src/app/views/graph/markers.ts), [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts), and [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) all do, and only [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) goes through `../model/parse`. `Branding` has no consumer outside test files to characterize either way. +- `resolveFlowKindPalette()` is imported directly from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) (not re-exported through `parse.ts`) by [`src/app/App.tsx`](../../src/app/App.tsx), [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx), [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx), and [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) to render DFD flow-kind swatches and legends. +- `FLOW_STORE_KIND_SYMBOLS` is imported by [`src/app/components/process/KindMarker.tsx`](../../src/app/components/process/KindMarker.tsx) (renders the store kind glyph on a flow endpoint), [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) (renders the symbol next to each kind's legend label), and [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (its `storeCapLabel()` appends a per-stack store number, e.g. `D1`, `C2`). `defaultFlowKinds` and `FLOW_KIND_KEYS` are referenced only inside [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) itself and in test files, not by any of those consumers. +- Frontend consumers reading `ThemeConfig`/`ThemePalette`/`semanticColors` values include [`src/app/App.tsx`](../../src/app/App.tsx), [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts), [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), [`src/app/views/graph/markers.ts`](../../src/app/views/graph/markers.ts), [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts), and [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx). Changing a `ThemePalette`/`ThemeSpacing` field name or shape forces updates across these. +- [`src/app/components/entity/FlowNodeGridCard.tsx`](../../src/app/components/entity/FlowNodeGridCard.tsx) and [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) import only the `FlowKindKey`/`FlowKindEntry` types from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts). [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) and [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) are mixed value+type cases: `DictionaryView.tsx` imports `resolveFlowKindPalette` as a value alongside the `FlowKindKey`/`FlowKindEntry` types in the same statement, and `flow-layout.ts` imports the value `FLOW_STORE_KIND_SYMBOLS` alongside the `FlowKindKey` type (it never imports `FlowKindEntry`). +- Adding a new theme-configurable value (a new palette field or a new `FlowKindKey`) requires updating [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)'s defaults and merge logic, [`src/model/parse.ts`](../../src/model/parse.ts)'s YAML parsing, and [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) to keep them in sync. +- `branding-defaults.ts` imports [`assets/noorm-logo.svg`](../../assets/noorm-logo.svg) directly (a file-import, not routed through generators), coupling it to Bun's `with { type: 'file' }` embedding behavior relied on by both the dev server and the compiled binary. diff --git a/models/llm-memory-db-mssql/flows/agent-project-setup/index.md b/models/llm-memory-db-mssql/flows/agent-project-setup/index.md index 82814a9..86e3994 100644 --- a/models/llm-memory-db-mssql/flows/agent-project-setup/index.md +++ b/models/llm-memory-db-mssql/flows/agent-project-setup/index.md @@ -1,3 +1,7 @@ +--- +description: Registering and retiring agents, and the projects they work in. +--- + ↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) diff --git a/models/llm-memory-db-mssql/flows/artifact-management/index.md b/models/llm-memory-db-mssql/flows/artifact-management/index.md index 514c38c..e56dac6 100644 --- a/models/llm-memory-db-mssql/flows/artifact-management/index.md +++ b/models/llm-memory-db-mssql/flows/artifact-management/index.md @@ -1,3 +1,7 @@ +--- +description: Registering the files an agent produces, linking them to milestones and tasks, and moving them through relevance states. +--- + ↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) diff --git a/models/llm-memory-db-mssql/flows/index.md b/models/llm-memory-db-mssql/flows/index.md index 1225854..d7a211a 100644 --- a/models/llm-memory-db-mssql/flows/index.md +++ b/models/llm-memory-db-mssql/flows/index.md @@ -4,15 +4,15 @@ - + | Name | Kind | Description | Go | |---|---|---|---| -| agent-project-setup | folder | | [agent-project-setup](agent-project-setup/index.md) | -| artifact-management | folder | | [artifact-management](artifact-management/index.md) | -| memory-lifecycle | folder | | [memory-lifecycle](memory-lifecycle/index.md) | -| note-capture | folder | | [note-capture](note-capture/index.md) | -| tag-administration | folder | | [tag-administration](tag-administration/index.md) | -| work-planning | folder | | [work-planning](work-planning/index.md) | +| agent-project-setup | folder | Registering and retiring agents, and the projects they work in. | [agent-project-setup](agent-project-setup/index.md) | +| artifact-management | folder | Registering the files an agent produces, linking them to milestones and tasks, and moving them through relevance states. | [artifact-management](artifact-management/index.md) | +| memory-lifecycle | folder | Creating, relating, scoping, consolidating, and retiring long-term memories, and recalling them by tag. | [memory-lifecycle](memory-lifecycle/index.md) | +| note-capture | folder | Recording free-form notes against projects, milestones, and tasks, and retiring them. | [note-capture](note-capture/index.md) | +| tag-administration | folder | Minting tags, attaching them to memories one at a time or in bulk, and merging duplicates. | [tag-administration](tag-administration/index.md) | +| work-planning | folder | Planning milestones and tasks, wiring task dependencies, tracking progress, and closing milestones. | [work-planning](work-planning/index.md) | diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md index 4138aac..6dc28f0 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md @@ -1,3 +1,7 @@ +--- +description: Creating, relating, scoping, consolidating, and retiring long-term memories, and recalling them by tag. +--- + ↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) diff --git a/models/llm-memory-db-mssql/flows/note-capture/index.md b/models/llm-memory-db-mssql/flows/note-capture/index.md index 12d83d8..57e6ea8 100644 --- a/models/llm-memory-db-mssql/flows/note-capture/index.md +++ b/models/llm-memory-db-mssql/flows/note-capture/index.md @@ -1,3 +1,7 @@ +--- +description: Recording free-form notes against projects, milestones, and tasks, and retiring them. +--- + ↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) diff --git a/models/llm-memory-db-mssql/flows/tag-administration/index.md b/models/llm-memory-db-mssql/flows/tag-administration/index.md index 4b3d134..2abc4c2 100644 --- a/models/llm-memory-db-mssql/flows/tag-administration/index.md +++ b/models/llm-memory-db-mssql/flows/tag-administration/index.md @@ -1,10 +1,14 @@ +--- +description: Minting tags, attaching them to memories one at a time or in bulk, and merging duplicates. +--- + ↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) - + | Name | Kind | Description | Go | |---|---|---|---| diff --git a/models/llm-memory-db-mssql/flows/work-planning/index.md b/models/llm-memory-db-mssql/flows/work-planning/index.md index 67a9658..cd96c4b 100644 --- a/models/llm-memory-db-mssql/flows/work-planning/index.md +++ b/models/llm-memory-db-mssql/flows/work-planning/index.md @@ -1,3 +1,7 @@ +--- +description: Planning milestones and tasks, wiring task dependencies, tracking progress, and closing milestones. +--- + ↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) diff --git a/models/llm-memory-db-mssql/index.md b/models/llm-memory-db-mssql/index.md index fb45243..50ed76e 100644 --- a/models/llm-memory-db-mssql/index.md +++ b/models/llm-memory-db-mssql/index.md @@ -1,4 +1,4 @@ - + Reverse-engineered IDEF1X model of the llm-memory-db-mssql schema — an agent long-term memory store. Agents record memories, notes, milestones, tasks, and artifacts within projects; everything is taggable and every relevance/tracking change is journaled as an immutable state transition. diff --git a/skills/ignatius-modeling/SKILL.md b/skills/ignatius-modeling/SKILL.md index 7d3d1ff..1f0c0ef 100644 --- a/skills/ignatius-modeling/SKILL.md +++ b/skills/ignatius-modeling/SKILL.md @@ -30,7 +30,7 @@ After writing any file, always run the verification loop in `references/verifica - Subtype clusters are an independent authoring choice; classification as Subtype is derived from the cluster declaration, never asked. - Predicates carry business meaning, not cardinality: push for the domain verb a stakeholder would say ("makes payments using", "settles") over "has many" / "belongs to". The crow's-foot already shows cardinality; the predicate makes the line read as a true sentence. - Examples always, in every mode: every entity carries 2–3 `examples:` rows and every process carries in/out `examples:` — never skipped, never offered as optional. Generate them yourself from the business context (realistic domain values, not `foo`/`1`/`test`), show them, and let the user adjust. Concrete instances expose wrong rules that pass every structural check; a model without examples is unverified. -- `description:` always, in every mode: every entity, group, process, external, and store carries a one-line `description:` in frontmatter — never skipped, never offered as optional. It is not documentation; it is the payload of the generated router tables, the line a reader uses to decide whether to open the file. Generate it yourself from real business context, show it, and let the user adjust. Never name a file `index.md` (or whatever `index_file` resolves to in `ignatius.yml`) for an entity — that name is reserved for the generated router. +- `description:` always, in every mode: every entity, group, process, external, and store carries a one-line `description:` in frontmatter, and so does every top-level flow folder, in the frontmatter of `flows//index.md` — never skipped, never offered as optional. It is not documentation; it is the payload of the generated router tables and of the app's flow index and breadcrumb menus, the line a reader uses to decide whether to open the file or the diagram. Generate it yourself from real business context, show it, and let the user adjust. Never name a file `index.md` (or whatever `index_file` resolves to in `ignatius.yml`) for an entity — that name is reserved for the generated router, whose frontmatter is the one place a flow folder describes itself. - Capture the business story, not just the schema: business rules, constraints, lifecycle, and the *why* behind structural complexity go in the body with their source and justification. Treat an offhand "billing won't allow payments under $5" as a documentable rule, not chatter. - Labels and clusters always, in every mode that writes flows: every `inputs:`/`outputs:` entry carries a short prose `label:` beside its `data:` (the diagram shows the label; the column list stays the validated contract), and two or more stores a process reads or writes as one thing are declared once in `clusters/.md` and referenced with one `cluster:` entry. This is what keeps a diagram readable at the size real models reach; neither is optional or an afterthought. Steps F4a and F5 in `references/dfd-authoring.md`. diff --git a/skills/ignatius-modeling/references/dfd-authoring.md b/skills/ignatius-modeling/references/dfd-authoring.md index dac3f2c..2cbf48a 100644 --- a/skills/ignatius-modeling/references/dfd-authoring.md +++ b/skills/ignatius-modeling/references/dfd-authoring.md @@ -263,6 +263,8 @@ per member that carries rows. See the worked example in `references/flow-templat Always run this step — do not skip it. Every process, external, and store carries a one-line `description:` frontmatter field: the router table's payload, not documentation. Generate it yourself for each node from what the flow has established so far (the process's verb phrase, the external's role, the store's purpose), show it, and let the user adjust. One sentence; the full story is Step F7's body. +The flow itself gets one too. Write it as `description:` frontmatter at the top of `flows//index.md`: what the flow covers, end to end, in one sentence. The viewer shows it in the flow index and in the breadcrumb menu that switches between flows. If the file already exists it is the generated router: add the frontmatter above its `` regions and leave the regions alone (`ignatius index` rewrites only those). If it does not exist, create it with only the frontmatter. A sub-DFD folder needs no index file; its process's `description:` describes it. See the flow index template in `references/flow-templates.md`. + ### Step F7 — Bodies: the business context (per node) Every node carries a markdown body. This is where the *why* lives — the reason the thing exists. diff --git a/skills/ignatius-modeling/references/flow-templates.md b/skills/ignatius-modeling/references/flow-templates.md index 5b81568..f890fa9 100644 --- a/skills/ignatius-modeling/references/flow-templates.md +++ b/skills/ignatius-modeling/references/flow-templates.md @@ -1,6 +1,6 @@ ## Flow reference templates -Templates for the three DFD node files: process, external entity, and non-`db` data store. +Templates for the flow folder index and the three DFD node files: process, external entity, and non-`db` data store. Frontmatter keys and endpoint tokens match the shipped flow format. Endpoint tokens used in `inputs:`/`outputs:`/`examples:`: @@ -18,6 +18,17 @@ Endpoint tokens used in `inputs:`/`outputs:`/`examples:`: Every entry carries a `label:`, the prose name the diagram shows (Step F5). The `data:` stays the validated contract behind it. +### Flow folder `index.md` template + +`flows//index.md` describes the whole flow (Step F6a). Only the frontmatter is authored; +everything else in the file belongs to `ignatius index`. + +```markdown +--- +description: Order entry through invoicing and cash collection. +--- +``` + ### Process `.md` template ```markdown diff --git a/src/app/App.tsx b/src/app/App.tsx index 13f3a51..d9bb05c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -36,6 +36,7 @@ import { useModelData } from './hooks/useModelData'; import { useHashRoute } from './hooks/useHashRoute'; import { useThemeMode } from './hooks/useThemeMode'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import { scrollBehaviorWithin } from './logic/motion'; export function App() { const graphRef = useRef(null); @@ -239,7 +240,7 @@ export function App() { // before we query the element (keep-mounted dict uses display:none visibility). requestAnimationFrame(() => { const el = document.getElementById(`process-${processId}`); - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); }); }, [view]); @@ -351,6 +352,7 @@ export function App() { onView: setView, onToggleLayout: handleToggleLayoutMode, onToggleLens: () => dictViewRef.current?.toggleLens(), + onFlowIndex: () => flowsViewRef.current?.toggleIndex(), onZoomIn: handleKeyboardZoomIn, onZoomOut: handleKeyboardZoomOut, onZoomReset: handleKeyboardZoomReset, diff --git a/src/app/components/entity/SpotlightOverlay.tsx b/src/app/components/entity/SpotlightOverlay.tsx index dc4be85..44fe542 100644 --- a/src/app/components/entity/SpotlightOverlay.tsx +++ b/src/app/components/entity/SpotlightOverlay.tsx @@ -3,6 +3,7 @@ import type { SpotlightConnection } from '../../logic/spotlight'; import type { FlowSpotlightConnection } from '../../logic/flow-spotlight'; import { INHERITED_IDENTITY, type InheritedConnection } from '../../logic/spotlight-inherited'; import { separateSpotlightLines, type LineDirection } from '../../logic/spotlight-lines'; +import { scrollBehaviorWithin } from '../../logic/motion'; /** * Map an inherited connection's bundle direction to the per-line direction set @@ -536,7 +537,7 @@ export function SpotlightOverlay({ const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' - : 'smooth'; + : scrollBehaviorWithin(targetCard); targetCard.scrollIntoView({ behavior: scrollBehavior, block: 'center' }); function waitForScrollToSettle(now: number) { diff --git a/src/app/components/ui/HelpModal.tsx b/src/app/components/ui/HelpModal.tsx index 548e0d5..2283873 100644 --- a/src/app/components/ui/HelpModal.tsx +++ b/src/app/components/ui/HelpModal.tsx @@ -65,7 +65,9 @@ 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: 'Levels & drill-down', desc: 'Numbered processes decompose — click one to drill into its sub-diagram; breadcrumbs walk back up, and the house button returns to the overview of every flow.' }, + { term: 'Flow index', desc: 'Click Process Flows (or press I) for the whole process hierarchy; hover a row to read its description, click it to open that diagram.' }, + { term: 'Switch levels', desc: 'A ▾ on a breadcrumb lists the other diagrams at that level, with descriptions — pick one to jump sideways.' }, { 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 — flip Include descriptions to also match markdown text; results list by diagram, click one to navigate there. Non-matches dim in the diagram.' }, ]; @@ -74,6 +76,7 @@ function shortcutRows(view: ViewName): Row[] { const rows: Row[] = [{ term: 'G · D · F', desc: 'Switch between Graph, Dictionary, and Flows.' }]; if (view === 'graph') rows.push({ term: 'L', desc: 'Toggle the graph layout (Organic / Hierarchical).' }); if (view === 'dict') rows.push({ term: 'B', desc: 'Toggle the dictionary lens (Read / Browse).' }); + if (view === 'flow') rows.push({ term: 'I', desc: 'Open or close the flow index.' }); if (view === 'graph' || view === 'dict') { rows.push({ term: 'Shift + hover', desc: 'Reveal key-inheritance lineage.' }); } diff --git a/src/app/hash-router.ts b/src/app/hash-router.ts index f3639ed..f281ec7 100644 --- a/src/app/hash-router.ts +++ b/src/app/hash-router.ts @@ -1,5 +1,5 @@ // Hash-router: pure parse + serialize for URL hash state. -// Format: #view=&entity=&zoom=&pan=,&dfd=&flowview=&collapse= +// Format: #view=&entity=&zoom=&pan=,&dfd=&flowview=&collapse= // All params are optional. Unknown/malformed values are silently dropped. export type ViewName = 'graph' | 'dict' | 'flow'; @@ -28,7 +28,7 @@ export interface HashState { entity?: string; zoom?: number; pan?: { x: number; y: number }; - /** Active flow diagram id — only meaningful when view === 'flow'. */ + /** Active flow diagram reference (a bare id, or an id path such as `invoicing/Submit-PCI`) — only meaningful when view === 'flow'. */ dfd?: string; /** Per-process vs. connected rendering — global setting, deep-linkable per docs/spec/dfd-store-clusters.md. */ flowview?: FlowViewMode; @@ -119,7 +119,9 @@ export function serializeHash(state: HashState): string { } if (state.dfd !== undefined) { - parts.push(`dfd=${encodeURIComponent(state.dfd)}`); + // A sub-DFD reference is an id path (`invoicing/Submit-PCI`); '/' is legal + // in a fragment, so it stays readable instead of becoming %2F. + parts.push(`dfd=${encodeURIComponent(state.dfd).replaceAll('%2F', '/')}`); } if (state.flowview !== undefined) { diff --git a/src/app/hooks/useKeyboardShortcuts.ts b/src/app/hooks/useKeyboardShortcuts.ts index 1f969f7..00524a6 100644 --- a/src/app/hooks/useKeyboardShortcuts.ts +++ b/src/app/hooks/useKeyboardShortcuts.ts @@ -7,6 +7,8 @@ interface KeyboardShortcutsConfig { onView: (v: ViewName) => void; onToggleLayout: () => void; onToggleLens: () => void; + /** `i` — open or close the flow index (flow view only). */ + onFlowIndex: () => void; /** Cmd/Ctrl + =/+ — zoom the active canvas in (no-op on dict). */ onZoomIn: () => void; /** Cmd/Ctrl + -/_ — zoom the active canvas out (no-op on dict). */ @@ -25,7 +27,7 @@ interface KeyboardShortcutsConfig { /** * useKeyboardShortcuts — registers exactly ONE global keydown listener for the - * unified SPA keyboard shortcuts (g/d/f/l/b). + * unified SPA keyboard shortcuts (g/d/f/l/b/i). * * Stale-closure hazard: the listener is registered once on mount. To avoid * capturing a stale `view`/callbacks reference, we keep the latest config in @@ -39,6 +41,7 @@ export function useKeyboardShortcuts({ onView, onToggleLayout, onToggleLens, + onFlowIndex, onZoomIn, onZoomOut, onZoomReset, @@ -48,8 +51,8 @@ export function useKeyboardShortcuts({ }: KeyboardShortcutsConfig): void { // Latest config ref — updated synchronously on every render so the stable // listener closure never reads stale values. - const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }); - configRef.current = { view, onView, onToggleLayout, onToggleLens, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }; + const configRef = useRef({ view, onView, onToggleLayout, onToggleLens, onFlowIndex, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }); + configRef.current = { view, onView, onToggleLayout, onToggleLens, onFlowIndex, onZoomIn, onZoomOut, onZoomReset, onHelp, onSearch, onPan }; useEffect(() => { function handleKeyDown(e: KeyboardEvent): void { @@ -82,6 +85,7 @@ export function useKeyboardShortcuts({ case 'view': cfg.onView(action.view); break; case 'toggleLayout': cfg.onToggleLayout(); break; case 'toggleLens': cfg.onToggleLens(); break; + case 'flowIndex': cfg.onFlowIndex(); break; case 'zoomIn': cfg.onZoomIn(); break; case 'zoomOut': cfg.onZoomOut(); break; case 'zoomReset': cfg.onZoomReset(); break; diff --git a/src/app/logic/motion.ts b/src/app/logic/motion.ts new file mode 100644 index 0000000..ac8a8cf --- /dev/null +++ b/src/app/logic/motion.ts @@ -0,0 +1,81 @@ +/** + * motion.ts — hover timing and the animation cutoff shared by the Graph, + * Dictionary, and Flows views (docs/spec/large-model-nav.md). + * + * A hover focus fades every element outside the hovered one's connections, + * which on a large model means restyling hundreds of elements. Applying it on + * pointer contact made a pointer crossing the canvas repaint the whole view + * once per element it passed over. + */ + +/** How long the pointer must rest on one target before its hover focus applies. */ +export const HOVER_INTENT_MS = 300; + +/** + * Rendered-element count (nodes + edges, or cards) above which a view drops + * its transitions and smooth scrolls and jumps straight to the end state. + */ +export const ANIMATION_ELEMENT_LIMIT = 150; + +export function animationsAllowed(renderedElementCount: number): boolean { + return renderedElementCount <= ANIMATION_ELEMENT_LIMIT; +} + +/** + * A view over the limit marks its root `data-motion="off"`; scrolling to an + * element inside it jumps instead of animating. Callers outside the view (the + * shell scrolling to a process) get the same answer without recounting. + */ +export function scrollBehaviorWithin(el: Element): ScrollBehavior { + return el.closest('[data-motion="off"]') ? 'auto' : 'smooth'; +} + +export interface HoverIntent { + /** Report the target now under the pointer (`null` = none). */ + set(target: string | null): void; + /** Apply a target immediately, dropping any pending one. */ + applyNow(target: string | null): void; + /** Drop any pending target without applying it. */ + cancel(): void; + /** The target most recently applied. */ + applied(): string | null; +} + +/** + * Every change of hover target, including leaving to empty space, applies only + * after the pointer has stayed on that target for `delayMs`. Moving A → B + * keeps A's focus until B settles, so the view switches in one step instead of + * clearing and re-fading. Reporting the same target again (pointer moves + * inside one element) never restarts the wait. + */ +export function createHoverIntent( + apply: (target: string | null) => void, + delayMs: number = HOVER_INTENT_MS, +): HoverIntent { + let applied: string | null = null; + let pending: string | null = null; + let timer: ReturnType | null = null; + + function cancel(): void { + if (timer !== null) clearTimeout(timer); + timer = null; + pending = null; + } + + function applyNow(target: string | null): void { + cancel(); + if (target === applied) return; + applied = target; + apply(target); + } + + function set(target: string | null): void { + if (timer !== null && target === pending) return; + cancel(); + if (target === applied) return; + pending = target; + timer = setTimeout(() => applyNow(target), delayMs); + } + + return { set, applyNow, cancel, applied: () => applied }; +} diff --git a/src/app/logic/shortcuts.ts b/src/app/logic/shortcuts.ts index 9ef3f68..7398c07 100644 --- a/src/app/logic/shortcuts.ts +++ b/src/app/logic/shortcuts.ts @@ -10,6 +10,7 @@ * f → view flow (any view) * l → toggleLayout (view==='graph' only; null otherwise) * b → toggleLens (view==='dict' only; null otherwise) + * i → flowIndex (view==='flow' only; null otherwise) * / → search (any view; ordinary bare key — unlike '?' it needs no * Shift, so it resolves in the normal switch below. * Cmd/Ctrl+k is a second, always-on route to the same @@ -67,6 +68,7 @@ export type ShortcutAction = | { type: 'view'; view: ViewName } | { type: 'toggleLayout' } | { type: 'toggleLens' } + | { type: 'flowIndex' } | { type: 'zoomIn' } | { type: 'zoomOut' } | { type: 'zoomReset' } @@ -153,6 +155,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 'i': return view === 'flow' ? { type: 'flowIndex' } : null; case '/': return { type: 'search' }; default: return null; } diff --git a/src/app/styles.css b/src/app/styles.css index 314be94..1173489 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -676,18 +676,12 @@ code { Flow surface — minimap wrapper (CP19) Visual parity with the DG .minimap: same corner/offset, border, radius, background, opacity+hover, size, and z-index. - Position (left) is driven inline from FlowChrome so the wrapper steps right - when the DFD-nav breadcrumb card is visible — that is the ONE intentional - structural divergence from the DG minimap (which has no nav card). All other - chrome is shared with .minimap. ========================================================================= */ .flow-minimap-wrapper { position: absolute; bottom: 16px; - /* left is set inline per showNav state (see FlowChrome.tsx): - - nav hidden → 16px (matches DG .minimap left:16px) - - nav visible → 228px (clears the breadcrumb nav card — intentional divergence) */ + left: 16px; z-index: 50; background: var(--color-surface); border: 1px solid var(--color-border); @@ -713,6 +707,448 @@ code { } } +/* ========================================================================= + Flow breadcrumbs, level menus, and the flow index (docs/spec/large-model-nav.md) + The root chip opens the index; a chip's ▾ opens the diagrams at its level. + Stacking: the crumb row (46) sits above the flow search bar (45) so a level + menu drops over it; the index (56) covers the minimap (50) and zoom control + (55) and stays under the FAB (60). + ========================================================================= */ + +.flow-crumbs { + position: absolute; + top: 18px; + left: 240px; + display: flex; + align-items: center; + gap: 8px; + z-index: 46; +} + +.flow-crumbs__step { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.flow-crumbs__sep { + color: var(--color-text-muted, #8b949e); + font-size: 13px; +} + +.flow-crumb { + position: relative; + display: inline-flex; + align-items: stretch; + background: var(--color-surface, #161b22); + border: 1px solid var(--color-border, #30363d); + border-radius: 8px; + font-size: 13px; + color: var(--color-text-muted, #8b949e); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + font-family: inherit; + white-space: nowrap; +} + +.flow-crumb--current { + border-color: var(--color-link, #58a6ff); + color: var(--color-link, #58a6ff); +} + +.flow-crumb--index { + align-items: center; + gap: 7px; + padding: 7px 12px; + color: var(--color-text, #e6edf3); + cursor: pointer; +} + +.flow-crumb--index:hover, +.flow-crumb--open { + border-color: var(--color-link, #58a6ff); +} + +.flow-crumb--home { + align-items: center; + padding: 7px 10px; + cursor: pointer; +} + +.flow-crumb--home:hover { + border-color: var(--color-link, #58a6ff); + color: var(--color-text, #e6edf3); +} + +.flow-crumb--home.flow-crumb--current { + cursor: default; +} + +.flow-crumb--home svg { + display: block; +} + +.flow-crumb__icon { + color: var(--color-text-muted, #8b949e); +} + +.flow-crumb__label { + padding: 7px 12px; + background: none; + border: none; + color: inherit; + font: inherit; +} + +button.flow-crumb__label { + cursor: pointer; +} + +button.flow-crumb__label:hover { + color: var(--color-text, #e6edf3); +} + +.flow-crumb__menu { + padding: 0 9px; + background: none; + border: none; + border-left: 1px solid var(--color-border, #30363d); + color: inherit; + font: inherit; + font-size: 11px; + cursor: pointer; + border-radius: 0 8px 8px 0; +} + +.flow-crumb__menu:hover, +.flow-crumb__menu[aria-expanded="true"] { + background: var(--color-surface-alt, #1c2128); + color: var(--color-text, #e6edf3); +} + +.flow-crumbs__back { + background: none; + border: 1px solid var(--color-border, #30363d); + border-radius: 6px; + color: var(--color-text-muted, #8b949e); + cursor: pointer; + font-size: 12px; + padding: 4px 8px; + font-family: inherit; +} + +.flow-level-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + width: 360px; + max-height: min(480px, 70vh); + display: flex; + flex-direction: column; + background: var(--color-surface, #161b22); + border: 1px solid var(--color-border, #30363d); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + color: var(--color-text, #e6edf3); + white-space: normal; + z-index: 40; + overflow: hidden; +} + +.flow-level-menu__head { + padding: 8px 12px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted, #8b949e); + border-bottom: 1px solid var(--color-border, #30363d); +} + +.flow-level-menu__filter { + margin: 8px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--color-border, #30363d); + background: var(--color-background, #0e1116); + color: var(--color-text, #e6edf3); + font: inherit; + font-size: 12px; +} + +.flow-level-menu__list { + list-style: none; + overflow-y: auto; + padding: 4px 0; +} + +.flow-level-menu__item { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 2px 6px; + width: 100%; + padding: 7px 12px; + background: none; + border: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.flow-level-menu__item--active { + background: var(--color-surface-alt, #1c2128); +} + +.flow-level-menu__item--current { + background: color-mix(in srgb, var(--color-link, #58a6ff) 12%, transparent); +} + +.flow-level-menu__num { + font-family: ui-monospace, SFMono-Regular, monospace; + font-variant-ligatures: none; + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted, #8b949e); + padding-top: 1px; +} + +.flow-level-menu__label { + font-size: 12.5px; + font-weight: 600; +} + +.flow-level-menu__item--current .flow-level-menu__label { + color: var(--color-link, #58a6ff); +} + +.flow-level-menu__count { + font-size: 10.5px; + color: var(--color-text-muted, #8b949e); + white-space: nowrap; +} + +.flow-level-menu__desc { + grid-column: 2 / 4; + font-size: 11.5px; + line-height: 1.35; + color: var(--color-text-muted, #8b949e); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.flow-level-menu__empty { + padding: 10px 12px; + font-size: 12px; + color: var(--color-text-muted, #8b949e); +} + +.flow-index { + position: absolute; + top: 64px; + left: 16px; + bottom: 16px; + width: min(980px, calc(100% - 32px)); + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + background: var(--color-surface, #161b22); + border: 1px solid var(--color-border, #30363d); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + color: var(--color-text, #e6edf3); + z-index: 56; + overflow: hidden; +} + +.flow-index__tree { + display: flex; + flex-direction: column; + min-height: 0; +} + +.flow-index__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--color-border, #30363d); +} + +.flow-index__title { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted, #8b949e); +} + +.flow-index__close { + background: none; + border: none; + color: var(--color-text-muted, #8b949e); + font-size: 14px; + cursor: pointer; +} + +.flow-index__list { + list-style: none; + overflow: auto; + padding: 12px 14px 16px; +} + +.flow-index__children { + list-style: none; + margin-left: 18px; +} + +/* Elbow connectors: each child hangs off its parent's left rail. */ +.flow-index__children > .flow-index__item { + position: relative; + padding-left: 20px; +} + +.flow-index__children > .flow-index__item::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 14px; + height: 15px; + border-left: 1px solid var(--color-border, #30363d); + border-bottom: 1px solid var(--color-border, #30363d); +} + +.flow-index__children > .flow-index__item:not(:last-child)::after { + content: ''; + position: absolute; + left: 0; + top: 15px; + bottom: 0; + border-left: 1px solid var(--color-border, #30363d); +} + +.flow-index__item { + padding: 3px 0; +} + +.flow-index__pill { + display: inline-flex; + align-items: stretch; + max-width: 100%; + border: 1px solid var(--color-border, #30363d); + border-radius: 999px; + background: var(--color-background, #0e1116); + color: var(--color-text, #e6edf3); + font: inherit; + font-size: 12px; + cursor: pointer; + overflow: hidden; + text-align: left; +} + +.flow-index__pill:hover, +.flow-index__pill:focus-visible { + border-color: var(--color-link, #58a6ff); + outline: none; +} + +.flow-index__num { + min-width: 46px; + padding: 4px 8px; + border-right: 1px solid var(--color-border, #30363d); + font-family: ui-monospace, SFMono-Regular, monospace; + font-variant-ligatures: none; + font-size: 11px; + text-align: center; + color: var(--color-text-muted, #8b949e); +} + +.flow-index__label { + min-width: 0; + padding: 4px 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.flow-index__pill--leaf .flow-index__label { + color: var(--color-text-muted, #8b949e); +} + +.flow-index__pill--ancestor { + border-color: color-mix(in srgb, var(--color-link, #58a6ff) 45%, var(--color-border, #30363d)); +} + +.flow-index__pill--current { + border-color: var(--color-link, #58a6ff); + background: color-mix(in srgb, var(--color-link, #58a6ff) 14%, var(--color-background, #0e1116)); +} + +.flow-index__pill--current .flow-index__label { + color: var(--color-link, #58a6ff); + font-weight: 600; +} + +.flow-index__pane { + border-left: 1px solid var(--color-border, #30363d); + padding: 16px; + overflow: auto; +} + +.flow-index__pane-title { + font-size: 14px; + margin-bottom: 8px; +} + +.flow-index__pane-desc { + font-size: 13px; + line-height: 1.5; + color: var(--color-text-secondary, var(--color-text, #e6edf3)); +} + +.flow-index__pane-desc--empty { + color: var(--color-text-muted, #8b949e); + font-style: italic; +} + +.flow-index__pane-hint { + margin-top: 12px; + font-size: 12px; + color: var(--color-text-muted, #8b949e); +} + +/* A phone cannot fit the branding and the crumbs on one row: the crumbs wrap + onto their own rows below the branding, and the index and level menus open + under them at full width (--flow-search-bar-top is the crumbs' bottom edge). */ +@media (max-width: 768px) { + .flow-crumbs { + top: 64px; + left: 16px; + right: 16px; + flex-wrap: wrap; + row-gap: 8px; + } + + .flow-index { + top: var(--flow-search-bar-top, 120px); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + } + + .flow-index__pane { + border-left: none; + border-top: 1px solid var(--color-border, #30363d); + max-height: 30vh; + } + + .flow-level-menu { + position: fixed; + top: var(--flow-search-bar-top, 120px); + left: 16px; + right: 16px; + width: auto; + } +} + /* ========================================================================= Zoom control (CP22) — shared, view-agnostic; Graph view only for now (CP23 will wire it to the Flows view too). @@ -2622,6 +3058,13 @@ a.dict-link-missing:hover { transition: opacity 0.18s; } +/* Above the animation limit every card fades in the same frame, so the fade + itself is the cost; jump to the end state (motion.ts ANIMATION_ELEMENT_LIMIT). */ +[data-motion="off"] .dict-grid-card--dim, +[data-motion="off"] .dict-grid-card--spotlit { + transition: none; +} + /* ── Spotlight leader-line overlay (CP4) ─────────────────────────────────── */ /* * The SVG overlay is position:fixed, spans the full viewport, and sits above the diff --git a/src/app/views/dict/DictionaryView.tsx b/src/app/views/dict/DictionaryView.tsx index d048bbb..34eb3f2 100644 --- a/src/app/views/dict/DictionaryView.tsx +++ b/src/app/views/dict/DictionaryView.tsx @@ -17,6 +17,7 @@ import { resolveBodyClick, upgradeMissingLinksInContainer } from '../../dom/body import { buildSpotlightConnections } from '../../logic/spotlight'; import { buildFlowSpotlightConnections } from '../../logic/flow-spotlight'; import { buildInheritedConnections } from '../../logic/spotlight-inherited'; +import { animationsAllowed, createHoverIntent, scrollBehaviorWithin } from '../../logic/motion'; import { buildFlowDocResolver } from '../../logic/doc-resolver'; import type { FlowDocResult } from '../../logic/doc-resolver'; import { SpotlightOverlay } from '../../components/entity/SpotlightOverlay'; @@ -120,7 +121,7 @@ const DictionaryView = forwardRef( try { localStorage.setItem(LENS_STORAGE_KEY, next); } catch {} - setHoverId(null); + cardHover.applyNow(null); setPinnedId(null); setLabelHoverCardId(null); setFocusId(null); @@ -252,21 +253,21 @@ const DictionaryView = forwardRef( const spotlitIdsRef = useRef>(new Set()); const activeIdRef = useRef(null); + // A card's hover applies once the pointer settles on it (motion.ts), so + // sweeping across the grid never re-spotlights it card by card. + const cardHover = useMemo(() => createHoverIntent(id => { + // Only a lit card other than the active one reveals its label. + const active = activeIdRef.current; + const revealsLabel = id !== null && active !== null && id !== active && spotlitIdsRef.current.has(id); + setHoverId(id); + setLabelHoverCardId(revealsLabel ? id : null); + }), []); + useEffect(() => () => cardHover.cancel(), [cardHover]); + // Card interaction callbacks — stable via useCallback so GridCard doesn't rerender // on every spotlight state change (cards without dim/spotlit class stay static). - const handleCardMouseEnter = useCallback((id: string) => { - setHoverId(id); - // CP14: If a spotlight is active and this card is a connected (lit) card - // (but not the active card itself), reveal its label by tracking it as labelHoverCardId. - const active = activeIdRef.current; - if (active !== null && id !== active && spotlitIdsRef.current.has(id)) { - setLabelHoverCardId(id); - } - }, []); - const handleCardMouseLeave = useCallback((_id: string) => { - setHoverId(null); - setLabelHoverCardId(null); - }, []); + const handleCardMouseEnter = useCallback((id: string) => cardHover.set(id), [cardHover]); + const handleCardMouseLeave = useCallback((_id: string) => cardHover.set(null), [cardHover]); const handleCardClick = useCallback((id: string) => { setPinnedId(prev => { const next = prev === id ? null : id; @@ -430,7 +431,7 @@ const DictionaryView = forwardRef( // Scroll-to-anchor navigation (anchor links within the dict panel). function scrollToEntity(entityId: string) { const el = document.getElementById(`entity-${entityId}`); - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); } // Generalized scroll: resolves the correct DD section anchor regardless of @@ -442,7 +443,7 @@ const DictionaryView = forwardRef( for (const prefix of prefixes) { const el = document.getElementById(`${prefix}-${id}`); if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); return; } } @@ -450,7 +451,7 @@ const DictionaryView = forwardRef( function scrollToMissing(id: string) { const el = document.getElementById(`missing-${id}`); - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); } // Delegates to the shared module-level resolveBodyClick with the local scrollToSection. @@ -815,7 +816,7 @@ const DictionaryView = forwardRef( function scrollToProcess(processId: string) { const el = document.getElementById(`process-${processId}`); - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); } // Build side-nav subtypeIds per-group for indent styling. @@ -948,7 +949,11 @@ const DictionaryView = forwardRef( {/* Main dict content */} -
+
{/* Reader legend — read lens only; not meaningful in browse/grid view */} diff --git a/src/app/views/flow/FlowsView.tsx b/src/app/views/flow/FlowsView.tsx index 304c837..cd978ed 100644 --- a/src/app/views/flow/FlowsView.tsx +++ b/src/app/views/flow/FlowsView.tsx @@ -22,6 +22,7 @@ import { computeElkLayout } from '../../../flow-view/elk-flow-layout'; import { screenScaleToPercent, percentToScreenScale } from '../../../flow-view/zoom-scale'; import type { ElkPositionMap } from '../../../flow-view/FlowDiagramSvg'; import { layoutKeyForView } from '../../../flow-view/flow-layout'; +import { defaultDiagramPath, diagramRef, findDiagramByRef, resolveDiagramPath } from '../../../flow-view/flow-nav'; import type { BuildFlowDataOpts, StackNodeData } from '../../../flow-view/flow-layout'; import { parseHash, serializeHash } from '../../hash-router'; import type { HashState, FlowViewMode, FlowCollapseLevel } from '../../hash-router'; @@ -46,7 +47,7 @@ export interface FlowChromeCallbacks { /** Called on every breadcrumb change (DFD select, drill-down, drill-up). */ onStackChange: (stack: BreadcrumbEntry[]) => void; /** Called once on init and on each SSE re-render with the full diagram list. */ - onDiagramsChange: (all: FlowDiagram[], activeId: string) => void; + onDiagramsChange: (all: FlowDiagram[]) => void; /** * Called by the core once after init, providing the imperative drill handlers. * The FlowsView component stores these in refs so FlowChrome callbacks can invoke @@ -55,6 +56,7 @@ export interface FlowChromeCallbacks { onRegisterHandlers: ( drillUp: (idx: number) => void, selectDiagram: (id: string) => void, + selectPath: (ids: string[]) => void, ) => void; /** Called on pan/zoom/drag to update the minimap in FlowChrome. */ onViewChange?: (data: MinimapData) => void; @@ -77,11 +79,11 @@ export interface FlowChromeCallbacks { 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). + * `ref` is the rendered diagram's path reference (flow-nav `diagramRef`). * Used by FlowsView to write the `dfd` URL hash param so the active DFD is * deep-linkable at every level. */ - onActiveDiagramChange?: (id: string) => void; + onActiveDiagramChange?: (ref: string) => void; /** * Called whenever the SVG scale changes (wheel, zoom control, reset) or the * container resizes. `scale` is the inner- factor (1 = fit); `fitScale` is @@ -237,8 +239,8 @@ export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, themeMode, all // Core flow graph setup. Extracted so both static and live modes can call it. // allDiagrams is passed in rather than read from globals so the live path can // pass fresh data on each SSE-triggered re-render. -// startDiagramId: which top-level DFD to render first (null → first in array). -// onDiagramChange: called whenever the selected top-level DFD changes. +// startDiagramId: `dfd=` reference of the diagram to render first (null → first in array). +// onDiagramChange: called with the selected diagram's path reference. // chromeCallbacks: optional — when provided, the FlowChrome React component drives // the breadcrumb/selector UI; when absent (e.g. static mode without React chrome), // the function falls back to no-ops. @@ -329,11 +331,8 @@ export function initFlowGraphCore( container.style.position = 'relative'; } - // Track the active selector id for chrome callback - let activeSelectorId: string = startDiagramId ?? allDiagrams[0]!.id; - function pushChromeState() { - chromeCallbacks?.onStackChange(stack.map(s => ({ label: s.label }))); + chromeCallbacks?.onStackChange(stack.map(s => ({ label: s.label, diagramId: s.diagram.id }))); } // --- SVG React root --- @@ -555,7 +554,9 @@ export function initFlowGraphCore( }); // Notify the app that the active diagram changed so it can update the URL hash. - chromeCallbacks?.onActiveDiagramChange?.(diagram.id); + // The test hook carries the bare id; the hash carries the path reference. + window.__IGNATIUS_ACTIVE_FLOW_DFD__ = diagram.id; + chromeCallbacks?.onActiveDiagramChange?.(diagramRef(stack.map(s => s.diagram))); pushChromeState(); } @@ -570,89 +571,54 @@ export function initFlowGraphCore( // onActiveDiagramChange fired inside renderDiagram above. } - // Recursively search allDiagrams (and their sub-DFD trees) for a diagram with the - // given id, returning the path of ancestor diagrams from the top-level root down - // to (and including) the found diagram. Returns null if not found. - function findDiagramPath(id: string): FlowDiagram[] | null { - function search(diagrams: FlowDiagram[], path: FlowDiagram[]): FlowDiagram[] | null { - for (const d of diagrams) { - if (d.id === id) return [...path, d]; - const found = search(d.subDfds, [...path, d]); - if (found) return found; - } - return null; - } - return search(allDiagrams, []); + // Each step of a root-to-diagram path becomes a crumb: the root by its + // title, a sub-DFD by its owning process's dotted number and label. + function stackFor(path: FlowDiagram[]): Array<{ diagram: FlowDiagram; label: string }> { + return path.map((d, i) => { + const parent = path[i - 1]; + const proc = parent?.processes.find(p => p.id === d.id); + return { diagram: d, label: proc ? `${proc.dottedNumber} ${proc.label}` : d.title }; + }); + } + + // selectDiagramById: resolves a `dfd=` reference (a bare id or an id path, + // see findDiagramByRef), rebuilds the breadcrumb stack, then renders. Used + // by popstate/back-nav and by flow search. + function selectDiagramById(ref: string) { + const path = findDiagramByRef(allDiagrams, ref); + if (path) showPath(path); } - // selectDiagramById: resolves the diagram by id whether top-level OR sub-DFD. - // Rebuilds the breadcrumb stack to reflect the full ancestor path, then renders. - // Used by both the popstate/back-nav path AND the FlowChrome DFD selector. - function selectDiagramById(id: string) { - const path = findDiagramPath(id); - if (!path || path.length === 0) return; + // The flow index and breadcrumb level menus know the exact path to a + // diagram; walking it avoids a first-match lookup, which lands on the wrong + // sub-DFD when two flows share a process file name. + function selectDiagramPath(ids: string[]) { + const path = resolveDiagramPath(allDiagrams, ids); + if (path) showPath(path); + } + + function showPath(path: FlowDiagram[]) { const target = path.at(-1); if (!target) return; - // Rebuild stack: each step in the path becomes a breadcrumb entry. - const newStack = path.map((d, i) => { - if (i === 0) return { diagram: d, label: d.title }; - // For sub-DFD entries, find the process label in the parent diagram. - const parent = path[i - 1]; - if (!parent) return { diagram: d, label: d.title }; - const proc = parent.processes.find(p => p.id === d.id); - const label = proc ? `${proc.dottedNumber} ${proc.label}` : d.title; - return { diagram: d, label }; - }); - stack.splice(0, stack.length, ...newStack); - // Update the top-level active selector (nav-card highlight) to the root - // of this path, but track the LEAF via onDiagramChange — matching the - // initial-construction branch below, which seeds activeFlowDiagramIdRef - // with the leaf so a renderer rebuild (SSE refetch, or a flowview=/ - // collapse= toggle) resumes at the exact drilled diagram, not just its - // top-level ancestor. - const rootId = path[0]?.id ?? target.id; - activeSelectorId = rootId; - onDiagramChange?.(target.id); - chromeCallbacks?.onDiagramsChange(allDiagrams, rootId); + stack.splice(0, stack.length, ...stackFor(path)); + // Track the path reference so a renderer rebuild (SSE refetch, or a + // flowview=/collapse= toggle) resumes at this exact diagram. + onDiagramChange?.(diagramRef(path)); pushChromeState(); void renderDiagram(target); } - // Seed the stack with the starting DFD (preserving selection across SSE re-renders). - // Use findDiagramPath so a deep-linked sub-DFD id resolves correctly and the - // full ancestor breadcrumb chain is established from the first render. - const startPath = startDiagramId !== null ? findDiagramPath(startDiagramId) : null; - if (startPath && startPath.length > 0) { - // Build the initial stack for every ancestor step in the path. - const newStack = startPath.map((d, i) => { - if (i === 0) return { diagram: d, label: d.title }; - const parent = startPath[i - 1]; - if (!parent) return { diagram: d, label: d.title }; - const proc = parent.processes.find(p => p.id === d.id); - const label = proc ? `${proc.dottedNumber} ${proc.label}` : d.title; - return { diagram: d, label }; - }); - stack.push(...newStack); - // activeSelectorId should reflect the root of this path. - // startPath.length > 0 is guaranteed by the enclosing guard, so both - // index accesses are safe; extract locals so TypeScript can narrow them. - const pathRoot = startPath[0]; - const startDiagram = startPath[startPath.length - 1]; - if (!pathRoot || !startDiagram) return () => {}; // unreachable — length > 0 - activeSelectorId = pathRoot.id; - onDiagramChange?.(startDiagram.id); - chromeCallbacks?.onDiagramsChange(allDiagrams, activeSelectorId); - chromeCallbacks?.onRegisterHandlers(drillUp, selectDiagramById); - void renderDiagram(startDiagram); - } else { - // startDiagramId was null, unknown, or stale — fall back to first top-level diagram. - const startDiagram = allDiagrams[0]!; - stack.push({ diagram: startDiagram, label: startDiagram.title }); - onDiagramChange?.(startDiagram.id); - chromeCallbacks?.onDiagramsChange(allDiagrams, activeSelectorId); - chromeCallbacks?.onRegisterHandlers(drillUp, selectDiagramById); - void renderDiagram(startDiagram); - } + // Seed the stack with the starting DFD (preserving selection across SSE + // re-renders); a deep-linked sub-DFD reference establishes its full ancestor + // breadcrumb chain from the first render. No reference, or an unknown or + // stale one, opens the default diagram. + const startPath = (startDiagramId !== null ? findDiagramByRef(allDiagrams, startDiagramId) : null) + ?? defaultDiagramPath(allDiagrams); + stack.push(...stackFor(startPath)); + onDiagramChange?.(diagramRef(startPath)); + chromeCallbacks?.onDiagramsChange(allDiagrams); + chromeCallbacks?.onRegisterHandlers(drillUp, selectDiagramById, selectDiagramPath); + void renderDiagram(startPath.at(-1)!); return () => { // Set before anything else: any renderDiagram call still awaiting ELK at @@ -685,6 +651,8 @@ export function initFlowGraphCore( */ export interface FlowsViewHandle { selectDiagramById(id: string): void; + /** Open or close the flow index (keyboard `i`). */ + toggleIndex(): void; resetLayout(): void; zoomIn(): void; zoomOut(): void; @@ -762,6 +730,7 @@ export const FlowsView = forwardRef( // Drill handlers registered by initFlowGraphCore via chromeCallbacks.onRegisterHandlers. const flowDrillUpRef = useRef<((idx: number) => void) | null>(null); const flowSelectDiagramRef = useRef<((id: string) => void) | null>(null); + const flowSelectPathRef = useRef<((ids: 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. @@ -796,6 +765,9 @@ export const FlowsView = forwardRef( selectDiagramById(id: string) { flowSelectDiagramRef.current?.(id); }, + toggleIndex() { + flowChromeRef.current?.toggleIndex(); + }, resetLayout() { flowResetLayoutRef.current?.(); }, @@ -846,12 +818,13 @@ export const FlowsView = forwardRef( onStackChange: (stack) => { flowChromeRef.current?.setStack(stack); }, - onDiagramsChange: (all, activeId) => { - flowChromeRef.current?.setDiagrams(all, activeId); + onDiagramsChange: (all) => { + flowChromeRef.current?.setDiagrams(all); }, - onRegisterHandlers: (drillUp, selectDiagram) => { + onRegisterHandlers: (drillUp, selectDiagram, selectPath) => { flowDrillUpRef.current = drillUp; flowSelectDiagramRef.current = selectDiagram; + flowSelectPathRef.current = selectPath; }, onViewChange: (data) => { flowChromeRef.current?.setMinimap(data); @@ -870,14 +843,13 @@ export const FlowsView = forwardRef( }, onActiveDiagramChange: (id) => { activeFlowDiagramIdRef.current = id; - window.__IGNATIUS_ACTIVE_FLOW_DFD__ = id; - // Write the active DFD id into the URL hash so the view is deep-linkable. + // Write the active DFD reference into the URL hash so the view is deep-linkable. const current = parseHash(location.hash); const next: HashState = { ...current, view: 'flow', dfd: id }; const serialized = serializeHash(next); const newHash = serialized ? '#' + serialized : location.pathname; // Use replaceState on the very first activation in this effect run — the - // initial auto-select of diagrams[0] or the preserved prevId. This avoids + // initial auto-select of the default diagram or the preserved one. This avoids // polluting history: Back after switching to the flow view should return to // the pre-flow state, not loop through #view=flow (no dfd). const isInitial = !initialActivationDone || location.hash === newHash; @@ -918,9 +890,9 @@ export const FlowsView = forwardRef( return; } - // Preserve the user's selected DFD across SSE re-renders. - const prevId = activeFlowDiagramIdRef.current; - const startId = prevId ?? diagrams[0]!.id; + // Preserve the user's selected DFD across SSE re-renders; null opens the + // default diagram (flow-nav defaultDiagramPath). + const startId = activeFlowDiagramIdRef.current; // Pass a getter (not a snapshot) for the entity model so the resolver always // reads the LIVE entity-id set even when model changes via SSE without @@ -936,7 +908,6 @@ export const FlowsView = forwardRef( startId, (id) => { activeFlowDiagramIdRef.current = id; - window.__IGNATIUS_ACTIVE_FLOW_DFD__ = id; }, chromeCallbacks, themeMode, @@ -977,12 +948,15 @@ export const FlowsView = forwardRef( // FlowChrome is gated on isActive to match the original `isFlowSurface &&` guard. if (!isActive) return null; + const modelMeta = getEntityModel()?._meta; return ( flowSelectDiagramRef.current?.(id)} + onSelectPath={(ids) => flowSelectPathRef.current?.(ids)} onDrillUp={(idx) => flowDrillUpRef.current?.(idx)} themeMode={themeMode} + modelName={modelMeta?.name} + modelDescription={modelMeta?.desc} /> ); }, diff --git a/src/app/views/graph/GraphView.tsx b/src/app/views/graph/GraphView.tsx index 8f99db3..f008822 100644 --- a/src/app/views/graph/GraphView.tsx +++ b/src/app/views/graph/GraphView.tsx @@ -35,6 +35,7 @@ import type { PositionMap } from './layout-store'; import { parseHash, serializeHash } from '../../hash-router'; import type { HashState, ViewName } from '../../hash-router'; import { buildInheritedConnections } from '../../logic/spotlight-inherited'; +import { createHoverIntent } from '../../logic/motion'; import type { Model, ModelNode, ModelEdge, SubtypeCluster } from '../../../model/parse'; import type { EntityError, GlobalError } from '../../../model/validate'; import type { ModelIndex } from '../../../model/model-index'; @@ -1044,9 +1045,9 @@ export const GraphView = forwardRef( clearInheritedEdges(); clearFocusTiers(); lineageActiveRef.current = false; - // Restore the plain-hover fade if still hovering a node (no shift), else - // fall back to the selected node's tiers, else clear to normal. - const hoveredId = hoveredNodeIdRef.current; + // Restore the plain-hover fade for the node whose hover is showing (no + // shift), else fall back to the selected node's tiers, else clear. + const hoveredId = graphHover.applied(); if (hoveredId !== null) { const hovered = cy.$(`#${CSS.escape(hoveredId)}`); if (hovered.nonempty()) { @@ -1065,6 +1066,53 @@ export const GraphView = forwardRef( enterLineageHoverRef.current = enterLineageHover; exitLineageHoverRef.current = exitLineageHover; + // ── Hover focus, applied once the pointer settles (motion.ts) ───────── + // hoveredNodeIdRef tracks the node under the pointer right now (Shift + // reads it); showHover renders the settled target: the node's reverse + // predicates plus its lineage (Shift held) or direct-neighbour fade, or, + // for null, the selected node's tiers or no fade at all. + let hoverShownId: string | null = null; + // Read when a hover settles, after the pointer event that started it is gone. + let shiftHeld = false; + function showHover(nodeId: string | null): void { + if (hoverShownId !== null) showPredicates(hoverShownId, 'fwd'); + hoverShownId = nodeId; + if (lineageActiveRef.current) { + clearInheritedEdges(); + clearFocusTiers(); + lineageActiveRef.current = false; + } + const node = nodeId === null ? cy.collection() : cy.$(`#${CSS.escape(nodeId)}`); + if (nodeId !== null && node.nonempty()) { + showPredicates(nodeId, 'rev'); + if (shiftHeld) enterLineageHover(nodeId); + else applyFocusTiers(node[0]); + return; + } + const selected = cy.nodes(':selected'); + if (selected.nonempty()) { + applyFocusTiers(selected[0]); + } else { + clearFocusTiers(); + redrawMarkers(); + } + } + const graphHover = createHoverIntent(showHover); + + // A hovered node's incoming edges read their reverse predicate ('rev'); + // leaving restores every connected edge to the forward one ('fwd'). + function showPredicates(nodeId: string, dir: 'fwd' | 'rev'): void { + const n = cy.$(`#${CSS.escape(nodeId)}`); + if (n.empty()) return; + n.connectedEdges().forEach((edge) => { + const verb = edge.data(dir === 'rev' ? 'predicateRev' : 'predicateFwd'); + if (verb === undefined) return; + if (dir === 'rev' && edge.target().id() !== nodeId) return; + edge.data('predicateMode', dir); + edge.data('edgeLabel', applyArrow(edge, verb, dir)); + }); + } + // Cytoscape applies tap-selection AFTER emitting 'tap', so unselecting // inside the tap handler is silently overridden on real pointer // gestures (synthetic emits don't run the gesture pipeline, which is @@ -1100,8 +1148,9 @@ export const GraphView = forwardRef( // Shell's onSelectEntity is the single writer of entity= (pushes one // history entry). GraphView does not write entity= on tap. Lineage // (dotted inherited rays + 3-tier focus opacity) is a shift+hover - // affordance (see the mouseover handler below). + // affordance (see showHover). hoveredNodeIdRef.current = null; + graphHover.applyNow(null); clearInheritedEdges(); clearFocusTiers(); lineageActiveRef.current = false; @@ -1212,15 +1261,20 @@ export const GraphView = forwardRef( // for the Shift key globally: pressing Shift over a hovered node enters // lineage mode; releasing Shift exits it. Both read live state from refs to // avoid stale closures. Listeners are removed in the cy-init cleanup. + // Pressing Shift is a deliberate request, so it skips the hover wait: + // a node still inside its settle window shows its lineage at once. function onShiftKeyDown(ev: KeyboardEvent) { if (ev.key !== 'Shift') return; + shiftHeld = true; if (lineageActiveRef.current) return; // already showing lineage const hoveredId = hoveredNodeIdRef.current; if (hoveredId === null) return; // not over a node - enterLineageHoverRef.current?.(hoveredId); + if (graphHover.applied() === hoveredId) enterLineageHoverRef.current?.(hoveredId); + else graphHover.applyNow(hoveredId); } function onShiftKeyUp(ev: KeyboardEvent) { if (ev.key !== 'Shift') return; + shiftHeld = false; if (!lineageActiveRef.current) return; exitLineageHoverRef.current?.(); } @@ -1317,59 +1371,15 @@ export const GraphView = forwardRef( } cy.on('mouseover', 'node', (evt) => { - const n = evt.target; - n.connectedEdges().forEach((edge) => { - const rev = edge.data('predicateRev'); - if (rev === undefined) return; - if (edge.target().id() === n.id()) { - edge.data('predicateMode', 'rev'); - edge.data('edgeLabel', applyArrow(edge, rev, 'rev')); - } - }); - // Track the hovered node so the document-level Shift keydown/keyup - // listeners know which node to reveal lineage for. - hoveredNodeIdRef.current = n.id(); - - // Shift held → LINEAGE mode: draw the dotted inherited rays + apply the - // 3-tier focus opacity. No shift → plain direct-neighbour fade only (no - // rays are drawn, so applyFocusTiers degrades to the direct/unrelated - // two-tier fade — the pre-existing hover behaviour). - const shiftHeld = evt.originalEvent?.shiftKey === true; - if (shiftHeld) { - enterLineageHover(n.id()); - } else { - applyFocusTiers(n); - } + const id = evt.target.id(); + hoveredNodeIdRef.current = id; + shiftHeld = evt.originalEvent?.shiftKey === true; + graphHover.set(id); }); cy.on('mouseout', 'node', (evt) => { - const n = evt.target; - n.connectedEdges().forEach((edge) => { - const fwd = edge.data('predicateFwd'); - if (fwd === undefined) return; - edge.data('predicateMode', 'fwd'); - edge.data('edgeLabel', applyArrow(edge, fwd, 'fwd')); - }); - // Leaving the node clears the hovered id first so exitLineageHover does - // not try to re-apply a fade for a node we're no longer over. - if (hoveredNodeIdRef.current === n.id()) hoveredNodeIdRef.current = null; - - // In lineage mode (shift+hover), exit: strip the rays + focus tiers. - if (lineageActiveRef.current) { - clearInheritedEdges(); - clearFocusTiers(); - lineageActiveRef.current = false; - } - // Fall back to the SELECTED node's tiers if one is selected (so leaving a - // hovered node doesn't kill the select-state hierarchy); otherwise clear - // back to normal (all full opacity). - const selected = cy.nodes(':selected'); - if (selected.nonempty()) { - applyFocusTiers(selected[0]); - } else { - clearFocusTiers(); - redrawMarkers(); - } + if (hoveredNodeIdRef.current === evt.target.id()) hoveredNodeIdRef.current = null; + graphHover.set(null); }); // Cytoscape only synthesises a node `mouseout` from mousemoves on its own @@ -1380,17 +1390,7 @@ export const GraphView = forwardRef( // selection re-applies its tiers instead (the pin must survive). const onGraphMouseLeave = () => { hoveredNodeIdRef.current = null; - if (lineageActiveRef.current) { - clearInheritedEdges(); - lineageActiveRef.current = false; - } - const selected = cy.nodes(':selected'); - if (selected.nonempty()) { - applyFocusTiers(selected[0]); - } else { - clearFocusTiers(); - redrawMarkers(); - } + graphHover.set(null); }; containerRef.current.addEventListener('mouseleave', onGraphMouseLeave); @@ -1411,6 +1411,7 @@ export const GraphView = forwardRef( } if (writeTimer !== null) clearTimeout(writeTimer); if (saveTimer !== null) clearTimeout(saveTimer); + graphHover.cancel(); window.removeEventListener('hashchange', onHashChange); wheelContainer?.removeEventListener('wheel', blockPageZoom); document.removeEventListener('keydown', onShiftKeyDown); diff --git a/src/flow-view/FlowChrome.tsx b/src/flow-view/FlowChrome.tsx index 2e8c81f..9ec2cdf 100644 --- a/src/flow-view/FlowChrome.tsx +++ b/src/flow-view/FlowChrome.tsx @@ -2,19 +2,21 @@ * FlowChrome.tsx — floating UI shell for the flow-viewer surface. * * Renders all chrome AROUND the SVG diagram: - * - breadcrumb chips top-left, offset to clear the fixed .branding-block rendered by App.tsx - * - DFD nav card (floating left panel, shown when >1 top-level DFD) - * - findings aside top-right (green check when 0 findings) + * - breadcrumb chips top-left, offset to clear the fixed .branding-block rendered by App.tsx; + * the root chip opens the flow index, and a ▾ on each chip opens a menu of the + * diagrams at that chip's level (docs/spec/large-model-nav.md) + * - the flow index (FlowIndex) over the canvas * - minimap bottom-left: live SVG overview of the current diagram + viewport rect * * Theme toggle and FAB are shared app-level chrome (App.tsx) — not rendered here. * * Driven by the imperative core (initFlowGraphCore) via a forwarded ref exposing: * handle.setStack(stack) — called on every breadcrumb change - * handle.setDiagrams(all, activeId) — called on initial mount + SSE re-renders + * handle.setDiagrams(all) — called on initial mount + SSE re-renders * handle.setMinimap(data) — called on pan/zoom/drag to update minimap + * handle.toggleIndex() — keyboard `i` * - * The imperative core's onDrillUp and onSelectDiagram callbacks are provided + * The imperative core's onDrillUp and onSelectPath 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 @@ -22,32 +24,43 @@ * any drill depth (graph-flow-search CP5, SC12) — see the effect below. */ -import { useState, useImperativeHandle, forwardRef, useRef, useLayoutEffect } from 'react'; +import { useState, useImperativeHandle, forwardRef, useRef, useLayoutEffect, useCallback } from 'react'; import type { FlowDiagram } from '../flows/flow-parse'; import type { MinimapData } from './FlowDiagramSvg'; import { DARK_PALETTE, LIGHT_PALETTE } from './FlowDiagramSvg'; +import { defaultDiagramPath, levelEntries, resolveDiagramPath, type FlowLevelEntry } from './flow-nav'; +import { SYNTHETIC_DIAGRAM_IDS } from '../flows/flow-derive-levels'; +import { FlowIndex } from './FlowIndex'; +import { LevelMenu } from './LevelMenu'; // ── Types ────────────────────────────────────────────────────────────────── export interface BreadcrumbEntry { label: string; + diagramId: string; } export interface FlowChromeHandle { setStack: (stack: BreadcrumbEntry[]) => void; - setDiagrams: (all: FlowDiagram[], activeId: string) => void; + setDiagrams: (all: FlowDiagram[]) => void; setMinimap: (data: MinimapData) => void; /** Register a function that pans the main SVG to a world coordinate. */ setMinimapPanTo: (fn: ((worldX: number, worldY: number) => void) | null) => void; + /** Open the flow index, or close it when open. */ + toggleIndex: () => void; } export interface FlowChromeProps { - /** Called when the user picks a different top-level DFD from the nav card */ - onSelectDiagram: (id: string) => void; + /** Called with a diagram's id path (root first) picked from the index or a level menu */ + onSelectPath: (ids: string[]) => void; /** Called when the user clicks an ancestor crumb (index in stack) or the back button */ onDrillUp: (idx: number) => void; /** Current theme mode — drives minimap palette + chrome color vars */ themeMode: 'dark' | 'light'; + /** Model `name:` — titles the flow index. */ + modelName?: string; + /** Model `description:` — describes the root and whole-system entries. */ + modelDescription?: string; } // ── Minimap component ───────────────────────────────────────────────────── @@ -192,13 +205,15 @@ function FlowMinimap({ export const FlowChrome = forwardRef( function FlowChrome( - { onSelectDiagram, onDrillUp, themeMode }, + { onSelectPath, onDrillUp, themeMode, modelName, modelDescription }, ref, ) { const [stack, setStack] = useState([]); const [allDiagrams, setAllDiagrams] = useState([]); - const [activeDiagramId, setActiveDiagramId] = useState(''); const [minimapData, setMinimapData] = useState(null); + const [indexOpen, setIndexOpen] = useState(false); + // Index into `stack` of the crumb whose level menu is open. + const [menuCrumb, setMenuCrumb] = useState(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 @@ -235,17 +250,23 @@ export const FlowChrome = forwardRef( }; }, []); + const toggleIndex = useCallback(() => { + setMenuCrumb(null); + setIndexOpen(open => !open); + }, []); + useImperativeHandle(ref, () => ({ - setStack(s: BreadcrumbEntry[]) { setStack(s); }, - setDiagrams(all: FlowDiagram[], activeId: string) { - setAllDiagrams(all); - setActiveDiagramId(activeId); + setStack(s: BreadcrumbEntry[]) { + setStack(s); + setMenuCrumb(null); }, + setDiagrams(all: FlowDiagram[]) { setAllDiagrams(all); }, setMinimap(data: MinimapData) { setMinimapData(data); }, setMinimapPanTo(fn: ((worldX: number, worldY: number) => void) | null) { minimapPanRef.current = fn; }, - }), []); + toggleIndex, + }), [toggleIndex]); // Register a pan handler from the SVG component via the core callback. // The core passes this into the minimap; we store it on a ref so clicking @@ -254,11 +275,79 @@ export const FlowChrome = forwardRef( minimapPanRef.current?.(worldX, worldY); } - const hasDrillDepth = stack.length > 1; - const showNav = allDiagrams.length > 1; - const topName = stack[0]?.label - ?? allDiagrams.find(d => d.id === activeDiagramId)?.title - ?? activeDiagramId; + const closeIndex = useCallback(() => setIndexOpen(false), []); + const closeMenu = useCallback(() => setMenuCrumb(null), []); + + const stackIds = stack.map(s => s.diagramId); + const pathDiagrams = resolveDiagramPath(allDiagrams, stackIds) ?? []; + + // The diagrams a crumb can switch between: its parent's sub-DFDs, or the + // roots for the first crumb. + function crumbLevel(i: number): FlowLevelEntry[] { + const parent = i === 0 ? null : pathDiagrams[i - 1]; + if (parent === undefined) return []; + return levelEntries(parent, allDiagrams, modelDescription); + } + + function pickSibling(i: number, entry: FlowLevelEntry) { + setMenuCrumb(null); + if (entry.diagramId === stackIds[i]) return; + onSelectPath([...stackIds.slice(0, i), entry.diagramId]); + } + + function selectFromIndex(ids: string[]) { + setIndexOpen(false); + onSelectPath(ids); + } + + // Context and the System overview are levels leveling derives, not diagrams + // anyone authored: they get no crumb. The house button stands for the + // overview the Flows view opens on and reaches it from any depth. + const isDerived = (crumb: BreadcrumbEntry) => SYNTHETIC_DIAGRAM_IDS.has(crumb.diagramId); + const current = stack.at(-1); + const onDerived = current !== undefined && isDerived(current); + const hasDrillDepth = stack.length > 1 && !onDerived; + const homeIds = defaultDiagramPath(allDiagrams).map(d => d.id); + const atHome = homeIds.length > 0 && homeIds.join('/') === stackIds.join('/'); + + function renderCrumb(crumb: BreadcrumbEntry, i: number) { + const isCurrent = i === stack.length - 1; + const siblings = crumbLevel(i); + const hasMenu = siblings.length > 1; + const menuOpen = menuCrumb === i; + return ( +
+ {isCurrent + ? {crumb.label} + : } + {hasMenu && ( + + )} + {menuOpen && ( + pickSibling(i, entry)} + onClose={closeMenu} + /> + )} +
+ ); + } return ( <> @@ -266,159 +355,73 @@ export const FlowChrome = forwardRef(
- / -
+ className="flow-crumbs" + > + / +
- - {(topName || stack.length > 0) && ( - <> - / - {stack.slice(0, -1).map((crumb, i) => ( -
- - / -
- ))} - {stack.length > 0 && ( -
- {stack[stack.length - 1]!.label} -
- )} - + + + {homeIds.length > 0 && ( +
+ / + +
)} + {stack.map((crumb, i) => isDerived(crumb) ? null : ( +
+ / + {renderCrumb(crumb, i)} +
+ ))} + {hasDrillDepth && ( )}
- {/* ── DFD nav card — floating top-left below branding ── */} - {showNav && ( -
-

- Process Flows -

- {allDiagrams.map(d => { - const isActive = d.id === activeDiagramId; - return ( - - ); - })} -
+ {indexOpen && allDiagrams.length > 0 && ( + )} {/* ── Minimap — bottom-left ── */} -
+
{minimapData ? ( diff --git a/src/flow-view/FlowDiagramSvg.tsx b/src/flow-view/FlowDiagramSvg.tsx index 51a1afa..8da8ea3 100644 --- a/src/flow-view/FlowDiagramSvg.tsx +++ b/src/flow-view/FlowDiagramSvg.tsx @@ -37,6 +37,7 @@ import { computeFitScale } from './zoom-scale'; import type { FlowDiagram, FlowEdge as FlowEdgeModel } from '../flows/flow-parse'; import type { NodePos, FlowElementData, StackNodeData, StackMember, StackRow, BuildFlowDataOpts, FlowStoreKind } from './flow-layout'; import type { PositionMap } from '../app/views/graph/layout-store'; +import { animationsAllowed, createHoverIntent } from '../app/logic/motion'; import type { FlowKindEntry, FlowKindKey } from '../theme/theme-defaults'; // ── Visual palettes — theme-aware ───────────────────────────────────────────── @@ -1008,7 +1009,7 @@ function StackNode({ // above everything) so a line never draws over another edge's label. function EdgePath({ - d, label, hasHiddenLabel, opacity, highlighted, c, onHoverChange, + d, label, hasHiddenLabel, opacity, highlighted, animate, c, onHoverChange, }: { d: string; label: string; @@ -1016,13 +1017,14 @@ function EdgePath({ hasHiddenLabel: boolean; opacity: number; highlighted: boolean; + animate: boolean; c: FlowPalette; onHoverChange: (entering: boolean, clientX?: number, clientY?: number) => void; }) { return ( onHoverChange(true, e.clientX, e.clientY)} onPointerLeave={() => onHoverChange(false)} onPointerMove={e => onHoverChange(true, e.clientX, e.clientY)} @@ -1063,11 +1065,12 @@ export function chipDims(lines: string[]): { w: number; h: number } { /** A data-flow label, one column per line, centred on `pos`. Draggable: it * slides along its edge path via `onPointerDown`. Hovering it focuses its edge. */ function EdgeChip({ - pos, lines, opacity, c, onPointerDown, onHoverChange, + pos, lines, opacity, animate, c, onPointerDown, onHoverChange, }: { pos: NodePos; lines: string[]; opacity: number; + animate: boolean; c: FlowPalette; onPointerDown: (e: React.PointerEvent) => void; onHoverChange: (entering: boolean, clientX?: number, clientY?: number) => void; @@ -1078,7 +1081,7 @@ function EdgeChip({ onHoverChange(true, e.clientX, e.clientY)} onPointerLeave={() => onHoverChange(false)} @@ -1102,6 +1105,20 @@ function EdgeChip({ ); } +const TOOLTIP_OFFSET_X = 16; +const TOOLTIP_OFFSET_Y = 12; +const TOOLTIP_W_ESTIMATE = 220; + +function tooltipPlacement(pointerX: number, pointerY: number): { left: number; top: number } { + const vw = typeof window !== 'undefined' ? window.innerWidth : 1440; + const vh = typeof window !== 'undefined' ? window.innerHeight : 900; + let left = pointerX + TOOLTIP_OFFSET_X; + let top = pointerY + TOOLTIP_OFFSET_Y; + if (left + TOOLTIP_W_ESTIMATE > vw) left = pointerX - TOOLTIP_W_ESTIMATE - TOOLTIP_OFFSET_X; + if (top + 40 > vh) top = pointerY - 40 - TOOLTIP_OFFSET_Y; + return { left, top }; +} + // ── Main component ──────────────────────────────────────────────────────────── export type ElkPositionMap = Record; @@ -1222,7 +1239,13 @@ export function FlowDiagramSvg({ // Select palette based on current theme. const c = themeMode === 'light' ? LIGHT_PALETTE : DARK_PALETTE; - const { nodes, edges, positions: bandedPositions } = buildFlowData(diagram, flowDataOpts); + // Memoized: every hover and drag re-renders this component, and rebuilding + // the node/edge model each time is what made hovering a large DFD stall. + const { nodes, edges, positions: bandedPositions } = useMemo( + () => buildFlowData(diagram, flowDataOpts), + [diagram, flowDataOpts], + ); + const animate = animationsAllowed(nodes.length + edges.length); // In the per-process view, a shared store repeats in every process's own // stack by design — nearly every row would carry the duplicate marker, so @@ -1294,12 +1317,41 @@ export function FlowDiagramSvg({ const [draggingEdge, setDraggingEdge] = useState(null); // HTML tooltip for edge hover: shows full dataLines content at pointer coords. - // Separate from `hover` so the tooltip can carry pointer screen coords without - // triggering the SVG dim/highlight mechanism on every mousemove. + // Appears when an edge hover settles; after that, pointer moves reposition the + // element directly (tooltipRef) so following the pointer never re-renders the SVG. const [edgeTooltip, setEdgeTooltip] = useState<{ edgeId: string; x: number; y: number } | null>(null); - // Flicker guard: delay clearing the tooltip so that crossing from the edge path - // layer to the chip layer (two separate elements) does not flash it off. - const tooltipClearTimer = useRef | null>(null); + const tooltipRef = useRef(null); + const pointerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + // Hover targets are `node:` / `edge:` keys. Crossing from an edge's + // path layer to its chip layer (two separate elements) reports the same + // key, so the settled hover and its tooltip hold across the gap. + const hoverIntent = useMemo(() => createHoverIntent(key => { + if (key === null) { + setHover(null); + setEdgeTooltip(null); + return; + } + const kind = key.startsWith('node:') ? 'node' : 'edge'; + const id = key.slice(kind.length + 1); + setHover({ kind, id }); + setEdgeTooltip(kind === 'edge' ? { edgeId: id, ...pointerRef.current } : null); + }), []); + useEffect(() => () => hoverIntent.cancel(), [hoverIntent]); + + const onEdgeHoverChange = (edgeId: string) => (entering: boolean, clientX?: number, clientY?: number) => { + const key = `edge:${edgeId}`; + if (clientX !== undefined && clientY !== undefined) { + pointerRef.current = { x: clientX, y: clientY }; + const el = tooltipRef.current; + if (el && hoverIntent.applied() === key) { + const { left, top } = tooltipPlacement(clientX, clientY); + el.style.left = `${left}px`; + el.style.top = `${top}px`; + } + } + hoverIntent.set(entering ? key : null); + }; // Pan: world-space translate applied as CSS transform on the inner . const [tx, setTx] = useState(0); @@ -1634,11 +1686,7 @@ export function FlowDiagramSvg({ if (memberEdges.length > 0) { // The dialog replaces the tooltip — clear it so it doesn't stay // mounted (and stale) over the modal. - if (tooltipClearTimer.current !== null) { - clearTimeout(tooltipClearTimer.current); - tooltipClearTimer.current = null; - } - setEdgeTooltip(null); + hoverIntent.applyNow(null); onOpenContract(memberEdges, endpointLabel(flowEdge.source), endpointLabel(flowEdge.target)); } } @@ -1789,7 +1837,6 @@ export function FlowDiagramSvg({ useEffect(() => { return () => { if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); - if (tooltipClearTimer.current !== null) clearTimeout(tooltipClearTimer.current); }; }, []); @@ -1976,25 +2023,9 @@ export function FlowDiagramSvg({ ? edgeRenders.find(e => e.id === edgeTooltip.edgeId) : undefined; - // Clamp a fixed-position tooltip so it stays within the viewport. - // Offsets: 16px right + 12px below the pointer; flip left when near right edge. - const TOOLTIP_OFFSET_X = 16; - const TOOLTIP_OFFSET_Y = 12; - const TOOLTIP_W_ESTIMATE = 220; // max expected tooltip width for right-edge clamp - let tooltipLeft: number | undefined; - let tooltipTop: number | undefined; - if (edgeTooltip !== null && tooltipEdge !== undefined && tooltipEdge.dataLines.length > 0) { - const vw = typeof window !== 'undefined' ? window.innerWidth : 1440; - const vh = typeof window !== 'undefined' ? window.innerHeight : 900; - tooltipLeft = edgeTooltip.x + TOOLTIP_OFFSET_X; - tooltipTop = edgeTooltip.y + TOOLTIP_OFFSET_Y; - if (tooltipLeft + TOOLTIP_W_ESTIMATE > vw) { - tooltipLeft = edgeTooltip.x - TOOLTIP_W_ESTIMATE - TOOLTIP_OFFSET_X; - } - if (tooltipTop + 40 > vh) { - tooltipTop = edgeTooltip.y - 40 - TOOLTIP_OFFSET_Y; - } - } + const tooltipPos = edgeTooltip !== null && tooltipEdge !== undefined && tooltipEdge.dataLines.length > 0 + ? tooltipPlacement(edgeTooltip.x, edgeTooltip.y) + : null; return ( <> @@ -2037,22 +2068,9 @@ export function FlowDiagramSvg({ hasHiddenLabel={e.hasHiddenLabel} opacity={edgeOpacity(e.id)} highlighted={draggingEdge === e.id} + animate={animate} c={c} - onHoverChange={(entering, cx, cy) => { - setHover(entering ? { kind: 'edge', id: e.id } : null); - if (entering && e.dataLines.length > 0 && cx !== undefined && cy !== undefined) { - if (tooltipClearTimer.current !== null) { - clearTimeout(tooltipClearTimer.current); - tooltipClearTimer.current = null; - } - setEdgeTooltip({ edgeId: e.id, x: cx, y: cy }); - } else if (!entering) { - tooltipClearTimer.current = setTimeout(() => { - setEdgeTooltip(null); - tooltipClearTimer.current = null; - }, 80); - } - }} + onHoverChange={onEdgeHoverChange(e.id)} /> ))} @@ -2063,9 +2081,9 @@ export function FlowDiagramSvg({ const hoverProps = { opacity: nodeOpacity(node.id), - style: { transition: 'opacity 0.12s' }, - onPointerEnter: () => setHover({ kind: 'node' as const, id: node.id }), - onPointerLeave: () => setHover(null), + style: animate ? { transition: 'opacity 0.12s' } : undefined, + onPointerEnter: () => hoverIntent.set(`node:${node.id}`), + onPointerLeave: () => hoverIntent.set(null), }; if (node.nodeType === 'process') { @@ -2185,33 +2203,21 @@ export function FlowDiagramSvg({ pos={e.chip} lines={e.lines} opacity={edgeOpacity(e.id)} + animate={animate} c={c} onPointerDown={ev => onChipPointerDown(ev, e.id, e.points, e.chip)} - onHoverChange={(entering, cx, cy) => { - setHover(entering ? { kind: 'edge', id: e.id } : null); - if (entering && e.dataLines.length > 0 && cx !== undefined && cy !== undefined) { - if (tooltipClearTimer.current !== null) { - clearTimeout(tooltipClearTimer.current); - tooltipClearTimer.current = null; - } - setEdgeTooltip({ edgeId: e.id, x: cx, y: cy }); - } else if (!entering) { - tooltipClearTimer.current = setTimeout(() => { - setEdgeTooltip(null); - tooltipClearTimer.current = null; - }, 80); - } - }} + onHoverChange={onEdgeHoverChange(e.id)} /> : null ))} - {tooltipEdge !== undefined && tooltipLeft !== undefined && tooltipTop !== undefined && ( + {tooltipEdge !== undefined && tooltipPos !== null && (
{tooltipEdge.sourceLabel} → {tooltipEdge.targetLabel} diff --git a/src/flow-view/FlowIndex.tsx b/src/flow-view/FlowIndex.tsx new file mode 100644 index 0000000..7b8a259 --- /dev/null +++ b/src/flow-view/FlowIndex.tsx @@ -0,0 +1,120 @@ +/** + * FlowIndex.tsx — the flow index: every diagram and process as an SSADM + * process-hierarchy chart, with a side pane describing the row under the + * pointer, else the focused row, else the current diagram, else the model. + * Clicking a row opens its diagram. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { FlowDiagram } from '../flows/flow-parse'; +import { buildFlowIndex, type FlowIndexNode } from './flow-nav'; + +function collectNodes(nodes: FlowIndexNode[], into: Map): Map { + for (const node of nodes) { + into.set(node.key, node); + collectNodes(node.children, into); + } + return into; +} + +export function FlowIndex({ diagrams, activePath, modelName, modelDescription, onSelectPath, onClose }: { + diagrams: FlowDiagram[]; + /** Id path of the diagram on screen, root first. */ + activePath: string[]; + modelName?: string; + modelDescription?: string; + onSelectPath: (ids: string[]) => void; + onClose: () => void; +}) { + const tree = useMemo(() => buildFlowIndex(diagrams, modelDescription), [diagrams, modelDescription]); + const nodesByKey = useMemo(() => collectNodes(tree, new Map()), [tree]); + const activeKey = activePath.join('/'); + const currentNode = [...nodesByKey.values()].find(n => n.opensOwnDiagram && n.path.join('/') === activeKey); + const [hoverKey, setHoverKey] = useState(null); + const [focusKey, setFocusKey] = useState(null); + const preview = (hoverKey !== null ? nodesByKey.get(hoverKey) : undefined) + ?? (focusKey !== null ? nodesByKey.get(focusKey) : undefined) + ?? currentNode; + const currentRowRef = useRef(null); + + useEffect(() => { + currentRowRef.current?.scrollIntoView({ block: 'center' }); + currentRowRef.current?.focus({ preventScroll: true }); + }, []); + + useEffect(() => { + function onKeyDown(ev: KeyboardEvent) { + if (ev.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + function renderNode(node: FlowIndexNode) { + const pathKey = node.path.join('/'); + const isCurrent = node === currentNode; + const onActivePath = node.opensOwnDiagram && !isCurrent && activeKey.startsWith(`${pathKey}/`); + const classes = [ + 'flow-index__pill', + isCurrent ? 'flow-index__pill--current' : '', + onActivePath ? 'flow-index__pill--ancestor' : '', + node.opensOwnDiagram ? '' : 'flow-index__pill--leaf', + ].filter(Boolean).join(' '); + return ( +
  • + + {node.children.length > 0 && ( +
      {node.children.map(renderNode)}
    + )} +
  • + ); + } + + return ( +
    +
    setHoverKey(null)}> +
    + {modelName ? `${modelName} process hierarchy` : 'Process hierarchy'} + +
    +
      {tree.map(renderNode)}
    +
    + +
    + ); +} diff --git a/src/flow-view/LevelMenu.tsx b/src/flow-view/LevelMenu.tsx new file mode 100644 index 0000000..3260d93 --- /dev/null +++ b/src/flow-view/LevelMenu.tsx @@ -0,0 +1,123 @@ +/** + * LevelMenu.tsx — the dropdown a breadcrumb's ▾ opens: every diagram at that + * crumb's level, with its number, description, and process count, so a user + * can switch sideways without climbing back up. + */ + +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { FlowLevelEntry } from './flow-nav'; + +// Below this many entries every row fits without scrolling, so a filter box +// would only add a step. +const FILTER_MIN_ENTRIES = 8; + +export function LevelMenu({ heading, entries, currentId, onPick, onClose }: { + heading: string; + entries: FlowLevelEntry[]; + currentId: string; + onPick: (entry: FlowLevelEntry) => void; + onClose: () => void; +}) { + const [filter, setFilter] = useState(''); + const term = filter.trim().toLowerCase(); + const shown = term + ? entries.filter(e => `${e.number} ${e.label} ${e.description ?? ''}`.toLowerCase().includes(term)) + : entries; + const [activeIdx, setActiveIdx] = useState(() => Math.max(0, entries.findIndex(e => e.diagramId === currentId))); + const rootRef = useRef(null); + const listRef = useRef(null); + const filterRef = useRef(null); + const showFilter = entries.length > FILTER_MIN_ENTRIES; + + useEffect(() => { + if (showFilter) filterRef.current?.focus(); + else listRef.current?.querySelector('[data-active="true"]')?.focus(); + // Focus once on open. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useLayoutEffect(() => { + listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' }); + }, [activeIdx, term]); + + useEffect(() => { + // The menu renders inside its crumb, so its parent holds the ▾ that + // opened it; a press there is left to that button's own toggle. + function onPointerDown(ev: PointerEvent) { + if (ev.target instanceof Node && rootRef.current?.parentElement?.contains(ev.target)) return; + onClose(); + } + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); + }, [onClose]); + + function onKeyDown(ev: React.KeyboardEvent) { + if (ev.key === 'Escape') { + ev.preventDefault(); + ev.stopPropagation(); + onClose(); + } else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { + ev.preventDefault(); + ev.stopPropagation(); + if (shown.length === 0) return; + const step = ev.key === 'ArrowDown' ? 1 : -1; + setActiveIdx(i => (Math.min(i, shown.length - 1) + step + shown.length) % shown.length); + } else if (ev.key === 'Enter') { + const entry = shown[Math.min(activeIdx, shown.length - 1)]; + if (entry) { + ev.preventDefault(); + onPick(entry); + } + } + } + + return ( +
    +
    {heading} · {entries.length}
    + {showFilter && ( + { setFilter(e.target.value); setActiveIdx(0); }} + /> + )} +
      + {shown.map((entry, i) => { + const isCurrent = entry.diagramId === currentId; + const isActive = i === Math.min(activeIdx, shown.length - 1); + return ( +
    • + +
    • + ); + })} + {shown.length === 0 &&
    • No match.
    • } +
    +
    + ); +} diff --git a/src/flow-view/flow-nav.ts b/src/flow-view/flow-nav.ts new file mode 100644 index 0000000..4923602 --- /dev/null +++ b/src/flow-view/flow-nav.ts @@ -0,0 +1,185 @@ +/** + * flow-nav.ts — the leveled DFD tree as navigation data: the flow index + * (process hierarchy) and the breadcrumb level menus. + * + * Pure module, browser-safe. Diagrams are addressed by id path from a root, + * never by a bare id: sub-DFD ids are process file names, which two flows may + * share. + */ + +import type { FlowDiagram, FlowProcess } from '../flows/flow-parse'; +import { CONTEXT_DIAGRAM_ID, SYNTHETIC_DIAGRAM_IDS, SYSTEM_PROCESS_ID } from '../flows/flow-derive-levels'; +import { compareDottedProcesses } from '../app/logic/search'; + +// Processes arrive in file order; navigation reads in number order. +function inNumberOrder(processes: readonly FlowProcess[]): FlowProcess[] { + return [...processes].sort(compareDottedProcesses); +} + +/** A diagram reachable from a breadcrumb level menu. */ +export type FlowLevelEntry = { + diagramId: string; + /** Dotted number of the process the diagram decomposes; '' for a root. */ + number: string; + label: string; + description?: string; + processCount: number; +}; + +/** One row of the flow index: a root diagram or a process. */ +export type FlowIndexNode = { + /** Unique across the tree: the id path joined with '/'. */ + key: string; + number: string; + label: string; + description?: string; + /** Diagram id path (root first) that clicking the row opens: the process's + * own sub-DFD when it has one, else the diagram that contains it. */ + path: string[]; + /** True when `path` ends at this row's own diagram. */ + opensOwnDiagram: boolean; + children: FlowIndexNode[]; +}; + +/** Walk `ids` down from the roots; null when any step is missing. */ +export function resolveDiagramPath(roots: readonly FlowDiagram[], ids: readonly string[]): FlowDiagram[] | null { + const path: FlowDiagram[] = []; + let level: readonly FlowDiagram[] = roots; + for (const id of ids) { + const next = level.find(d => d.id === id); + if (!next) return null; + path.push(next); + level = next.subDfds; + } + return path.length > 0 ? path : null; +} + +/** + * Where the Flows view opens with no `dfd=`: the System overview, which shows + * every flow, rather than the one-box Context diagram above it. A tree that + * was not leveled opens on its first root. + */ +export function defaultDiagramPath(roots: readonly FlowDiagram[]): FlowDiagram[] { + const root = roots[0]; + if (!root) return []; + const system = root.id === CONTEXT_DIAGRAM_ID ? root.subDfds.find(d => d.id === SYSTEM_PROCESS_ID) : undefined; + return system ? [root, system] : [root]; +} + +/** + * A diagram's deep-link reference (`dfd=`): its id path below the derived + * Context and System levels, joined with '/' (`invoicing/Submit-PCI`). A + * derived diagram is referenced by its own id. + */ +export function diagramRef(path: readonly FlowDiagram[]): string { + const ids = path.map(d => d.id); + const authored = ids.filter(id => !SYNTHETIC_DIAGRAM_IDS.has(id)); + return (authored.length > 0 ? authored : ids.slice(-1)).join('/'); +} + +/** + * Resolve a deep-link reference to the first diagram path, in tree order, + * whose trailing ids match it. A bare id is a one-segment reference, so links + * written before references carried paths still resolve. + */ +export function findDiagramByRef(roots: readonly FlowDiagram[], ref: string): FlowDiagram[] | null { + const segments = ref.split('/'); + function search(level: readonly FlowDiagram[], path: FlowDiagram[]): FlowDiagram[] | null { + for (const d of level) { + const next = [...path, d]; + const tail = next.slice(-segments.length).map(x => x.id); + if (tail.length === segments.length && tail.every((id, i) => id === segments[i])) return next; + const found = search(d.subDfds, next); + if (found) return found; + } + return null; + } + return search(roots, []); +} + +/** + * Describe a process for navigation: its own `description:`, else its sub-DFD + * folder's, else (the synthetic whole-system process) the model's. + */ +function describeProcess(process: FlowProcess, sub: FlowDiagram | undefined, modelDescription: string | undefined): string | undefined { + return process.description + ?? sub?.description + ?? (process.id === SYSTEM_PROCESS_ID ? modelDescription : undefined); +} + +/** + * The diagrams one level below `parent` (its sub-DFDs, in number order), or + * the roots when `parent` is null. These are what a breadcrumb at that level + * can switch between. + */ +export function levelEntries( + parent: FlowDiagram | null, + roots: readonly FlowDiagram[], + modelDescription?: string, +): FlowLevelEntry[] { + if (parent === null) { + return roots.map(d => ({ + diagramId: d.id, + number: '', + label: d.title, + description: d.description ?? modelDescription, + processCount: d.processes.length, + })); + } + const entries: FlowLevelEntry[] = []; + for (const process of inNumberOrder(parent.processes)) { + const sub = parent.subDfds.find(d => d.id === process.id); + if (!sub) continue; + entries.push({ + diagramId: sub.id, + number: process.dottedNumber, + label: process.label, + description: describeProcess(process, sub, modelDescription), + processCount: sub.processes.length, + }); + } + return entries; +} + +function processNodes(diagram: FlowDiagram, path: string[], modelDescription: string | undefined): FlowIndexNode[] { + return inNumberOrder(diagram.processes).map(process => { + const sub = diagram.subDfds.find(d => d.id === process.id); + const ownPath = sub ? [...path, sub.id] : path; + return { + key: [...path, process.id].join('/'), + number: process.dottedNumber, + label: process.label, + description: describeProcess(process, sub, modelDescription), + path: ownPath, + opensOwnDiagram: sub !== undefined, + children: sub ? processNodes(sub, ownPath, modelDescription) : [], + }; + }); +} + +/** + * The whole process hierarchy, authored diagrams only: the derived Context and + * System levels are not rows, so the flows are the top level and their + * processes nest beneath. An unleveled tree lists one row per root. + */ +export function buildFlowIndex(roots: readonly FlowDiagram[], modelDescription?: string): FlowIndexNode[] { + const nodes = roots.map(root => ({ + key: root.id, + number: '', + label: root.title, + description: root.description ?? modelDescription, + path: [root.id], + opensOwnDiagram: true, + children: processNodes(root, [root.id], modelDescription), + })); + return withoutDerivedLevels(nodes); +} + +function withoutDerivedLevels(nodes: FlowIndexNode[]): FlowIndexNode[] { + return nodes.flatMap(node => { + const ownId = node.path.at(-1); + return node.opensOwnDiagram && ownId !== undefined && SYNTHETIC_DIAGRAM_IDS.has(ownId) + ? withoutDerivedLevels(node.children) + : [node]; + }); +} diff --git a/src/flows/flow-derive-levels.ts b/src/flows/flow-derive-levels.ts index 1940fbd..f2cbe69 100644 --- a/src/flows/flow-derive-levels.ts +++ b/src/flows/flow-derive-levels.ts @@ -262,6 +262,7 @@ function deriveL1( bodyHtml: '', hasSubDfd: true, flowId: systemId, + ...(leaf.description !== undefined ? { description: leaf.description } : {}), }; // Collect promoted-store edges for this leaf (activity ↔ promoted store) diff --git a/src/flows/flow-parse.ts b/src/flows/flow-parse.ts index 0806acc..2481319 100644 --- a/src/flows/flow-parse.ts +++ b/src/flows/flow-parse.ts @@ -115,6 +115,10 @@ export type FlowDiagram = { /** Human-readable display title. Derived from titlelize(id) at parse time. * Always use this for display; keep `id` for routing/lookup. */ title: string; + /** `description:` from the frontmatter of the diagram folder's index file + * (`flows//index.md`). Absent when the author wrote none; a sub-DFD's + * owning process `description:` is the display fallback, not copied here. */ + description?: string; processes: FlowProcess[]; externals: FlowExternal[]; storeRefs: FlowStoreRef[]; @@ -473,6 +477,39 @@ async function readExternalsDir( return map; } +// --------------------------------------------------------------------------- +// Diagram description (folder index file frontmatter) +// --------------------------------------------------------------------------- + +/** + * The index file doubles as the generated router (`ignatius index`), which + * owns only its `` regions — a hand-authored frontmatter block + * above them survives regeneration, so it is where a folder describes itself. + * A router-only file has no frontmatter and yields no description. + */ +async function readDiagramDescription( + folderPath: string, + indexFileName: string, + globalErrors: GlobalError[], +): Promise { + const indexPath = `${folderPath}/${indexFileName}`; + const file = Bun.file(indexPath); + if (!(await file.exists())) return undefined; + const content = await file.text(); + if (!content.startsWith('---\n')) return undefined; + try { + return normalizedLabel(parseFrontmatter(content).frontmatter['description']); + } catch (err) { + globalErrors.push({ + ruleId: 'parse.invalid_yaml', + severity: 'error', + omitted: { kind: 'file', id: indexPath }, + reason: `Cannot parse "${indexPath}": ${err instanceof Error ? err.message : String(err)}`, + }); + return undefined; + } +} + // --------------------------------------------------------------------------- // Parse a single DFD folder (recursive) // --------------------------------------------------------------------------- @@ -697,9 +734,12 @@ async function parseDiagramFolder( // Build deduplicated store refs from all collected edges using the shared root store registry const storeRefs = collectStoreRefsFromEdges(allEdges, flowId, rootStoreBodyByKindName); + const description = await readDiagramDescription(folderPath, indexFileName, globalErrors); + return { id: diagramId, title: titlelize(diagramId), + ...(description !== undefined ? { description } : {}), processes, externals, storeRefs, diff --git a/src/router/build.ts b/src/router/build.ts index 160f796..41a0acf 100644 --- a/src/router/build.ts +++ b/src/router/build.ts @@ -229,7 +229,13 @@ async function buildFlowFolder( const file = toRouterFile(relDir, 'flow-diagram', depth, ancestors, indexFile, rows); out.push(file); - return { name: diagram.id, kind: 'folder', description: '', link: `${diagram.id}/${indexFile}`, hash: file.digest }; + // The folder's own index file is not a hashed row, so an authored + // description would otherwise change the parent's table without touching + // any digest. Folding it in only when present leaves every digest of a + // model without folder descriptions unchanged. + const description = diagram.description ?? ''; + const hash = description ? folderDigest([file.digest, description]) : file.digest; + return { name: diagram.id, kind: 'folder', description, link: `${diagram.id}/${indexFile}`, hash }; } export async function buildRouters( diff --git a/test/checks/test-flow-diagram-description.ts b/test/checks/test-flow-diagram-description.ts new file mode 100644 index 0000000..0bca34b --- /dev/null +++ b/test/checks/test-flow-diagram-description.ts @@ -0,0 +1,140 @@ +/** + * test-flow-diagram-description.ts — a DFD folder describes itself through + * `description:` in its index file's frontmatter. + * + * Why it matters: the flow index and the breadcrumb level menus list every + * diagram with a description, and a top-level DFD folder had no file of its + * own to carry one. The index file is also the generated router, so the + * description must survive `ignatius index` regeneration, feed the parent + * router's folder row, and leave every existing digest alone when absent. + * + * Generates its fixture at runtime under tmp/. + */ + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { assert } from '../assert'; +import { parseModels } from '../../src/model/parse'; +import { parseFlows } from '../../src/flows/flow-parse'; +import type { FlowDiagram } from '../../src/flows/flow-parse'; +import { buildRouters } from '../../src/router/build'; +import { writeRouters } from '../../src/router/write'; +import { SYSTEM_PROCESS_ID } from '../../src/flows/flow-derive-levels'; + +const FIXTURE = 'tmp/flow-diagram-description-fixture'; + +function findDiagram(diagrams: FlowDiagram[], id: string): FlowDiagram | undefined { + for (const d of diagrams) { + if (d.id === id) return d; + const found = findDiagram(d.subDfds, id); + if (found) return found; + } + return undefined; +} + +const PROCESS = (name: string, extra = '') => `--- +process: ${name} +description: ${name} does its one job. +inputs: + - from: ext:Clerk + data: request +outputs: + - to: ext:Clerk + data: response +${extra}--- +`; + +rmSync(FIXTURE, { recursive: true, force: true }); +mkdirSync(`${FIXTURE}/flows/billing/Submit`, { recursive: true }); +mkdirSync(`${FIXTURE}/flows/ops`, { recursive: true }); +mkdirSync(`${FIXTURE}/externals`, { recursive: true }); +writeFileSync(`${FIXTURE}/ignatius.yml`, 'name: Fixture\n'); +writeFileSync(`${FIXTURE}/externals/Clerk.md`, '---\nexternal: Clerk\n---\n'); +writeFileSync(`${FIXTURE}/flows/billing/index.md`, `--- +description: Billing scopes and sending invoices. +--- +`); +writeFileSync(`${FIXTURE}/flows/billing/Submit.md`, PROCESS('Submit')); +writeFileSync(`${FIXTURE}/flows/billing/Submit/Check.md`, PROCESS('Check')); +writeFileSync(`${FIXTURE}/flows/ops/Run.md`, PROCESS('Run')); + +// Absent description is not an error; a described folder reaches the diagram. +{ + const { flowModel, globalErrors } = await parseFlows(FIXTURE); + assert(globalErrors.length === 0, `FAIL: expected no parse errors, got ${JSON.stringify(globalErrors)}`); + + const billing = findDiagram(flowModel.diagrams, 'billing'); + assert(billing?.description === 'Billing scopes and sending invoices.', `FAIL: billing.description = ${JSON.stringify(billing?.description)}`); + console.log('PASS: index.md description reaches FlowDiagram.description'); + + const ops = findDiagram(flowModel.diagrams, 'ops'); + assert(ops !== undefined && ops.description === undefined, `FAIL: ops.description should be absent, got ${JSON.stringify(ops?.description)}`); + console.log('PASS: a folder without an index file has no description and no error'); + + const submitSub = findDiagram(flowModel.diagrams, 'Submit'); + assert(submitSub !== undefined && submitSub.description === undefined, 'FAIL: a sub-DFD with no index file must not copy its process description into the diagram'); + console.log('PASS: sub-DFD description stays absent (process description is a display fallback only)'); + + const system = findDiagram(flowModel.diagrams, SYSTEM_PROCESS_ID); + const l1Billing = system?.processes.find(p => p.id === 'billing'); + assert(l1Billing?.description === 'Billing scopes and sending invoices.', `FAIL: L1 process for billing carries ${JSON.stringify(l1Billing?.description)}`); + const l1Ops = system?.processes.find(p => p.id === 'ops'); + assert(l1Ops !== undefined && l1Ops.description === undefined, 'FAIL: L1 process for ops should have no description'); + console.log('PASS: the L1 overview process of a described flow carries its description'); +} + +// Router: the folder row shows the description, regeneration keeps the +// frontmatter, and an undescribed folder's row hash is its plain digest. +{ + const { model } = await parseModels(FIXTURE); + const { flowModel } = await parseFlows(FIXTURE); + const routers = await buildRouters(FIXTURE, model, flowModel); + const flowsRouter = routers.find(r => r.relPath === 'flows/index.md'); + assert(flowsRouter !== undefined, 'FAIL: flows/index.md router not built'); + assert( + flowsRouter!.table.includes('| billing | folder | Billing scopes and sending invoices. |'), + `FAIL: flows router billing row lacks the description:\n${flowsRouter!.table}`, + ); + const opsRouter = routers.find(r => r.relPath === 'flows/ops/index.md'); + const billingRouter = routers.find(r => r.relPath === 'flows/billing/index.md'); + assert(opsRouter !== undefined && billingRouter !== undefined, 'FAIL: per-flow routers not built'); + console.log('PASS: parent router folder row renders the flow description'); + + await writeRouters(FIXTURE, routers); + const written = readFileSync(`${FIXTURE}/flows/billing/index.md`, 'utf8'); + assert(written.startsWith('---\ndescription: Billing scopes and sending invoices.\n---\n'), `FAIL: frontmatter did not survive router write:\n${written}`); + assert(written.includes(' r.relPath === 'flows/index.md'); + assert(rewordedFlows!.attrs.digest !== flowsRouter!.attrs.digest, 'FAIL: rewording a flow description left the flows/ digest unchanged'); + assert(rerouted.find(r => r.relPath === 'flows/billing/index.md')!.attrs.digest === billingDigest, 'FAIL: the described folder\'s own digest should not change'); + assert(rerouted.find(r => r.relPath === 'flows/ops/index.md')!.attrs.digest === opsDigest, 'FAIL: a sibling digest changed'); + console.log('PASS: a description edit dirties the parent digest only'); +} + +// Malformed frontmatter is reported, not swallowed. +{ + writeFileSync(`${FIXTURE}/flows/ops/index.md`, '---\ndescription: [unclosed\n---\n'); + const { globalErrors } = await parseFlows(FIXTURE); + assert( + globalErrors.some(e => e.ruleId === 'parse.invalid_yaml' && e.reason.includes('flows/ops/index.md')), + `FAIL: malformed index frontmatter should report parse.invalid_yaml, got ${JSON.stringify(globalErrors)}`, + ); + console.log('PASS: malformed index frontmatter reports parse.invalid_yaml'); +} + +rmSync(FIXTURE, { recursive: true, force: true }); +console.log('\nAll flow-diagram-description tests passed.'); diff --git a/test/checks/test-flow-nav.ts b/test/checks/test-flow-nav.ts new file mode 100644 index 0000000..b33e4cb --- /dev/null +++ b/test/checks/test-flow-nav.ts @@ -0,0 +1,122 @@ +/** + * test-flow-nav.ts — navigation data behind the flow index and the + * breadcrumb level menus (src/flow-view/flow-nav.ts). + * + * Why it matters: a large model has dozens of flows reachable only through + * the leveled tree. The index must list every diagram and process with a + * description, a level menu must offer exactly the diagrams beside the current + * one, and every row must land on the right diagram even when two flows share + * a process file name. + */ + +import { assert } from '../assert'; +import { deriveLevels, CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID } from '../../src/flows/flow-derive-levels'; +import type { FlowDiagram, FlowProcess } from '../../src/flows/flow-parse'; +import { buildFlowIndex, defaultDiagramPath, diagramRef, findDiagramByRef, levelEntries, resolveDiagramPath } from '../../src/flow-view/flow-nav'; +import type { FlowIndexNode } from '../../src/flow-view/flow-nav'; + +function proc(id: string, dottedNumber: string, extra: Partial = {}): FlowProcess { + return { id, label: id, dottedNumber, inputs: [], outputs: [], body: '', bodyHtml: '', hasSubDfd: false, flowId: '', ...extra }; +} + +function diagram(id: string, processes: FlowProcess[], subDfds: FlowDiagram[] = [], description?: string): FlowDiagram { + return { id, title: id, processes, externals: [], storeRefs: [], edges: [], subDfds, ...(description ? { description } : {}) }; +} + +// Two flows each decompose a process named "Submit": the ids collide below +// the flow level, which is exactly what a bare-id lookup gets wrong. +const billingSubmit = diagram('Submit', [proc('Check', '1.1')]); +const billing = diagram('billing', [ + proc('Submit', '1', { hasSubDfd: true, description: 'Submit an invoice.' }), + proc('Cancel', '2'), +], [billingSubmit], 'Billing scopes.'); +const ordersSubmit = diagram('Submit', [proc('Price', '1.1'), proc('Place', '1.2')]); +const orders = diagram('orders', [proc('Submit', '1', { hasSubDfd: true })], [ordersSubmit]); + +const leveled = deriveLevels({ diagrams: [billing, orders], modelDir: '', externals: [], clusters: [] }).diagrams; +const MODEL_DESC = 'The whole model.'; + +// Paths resolve exactly, including the colliding sub-DFD ids. +{ + const ordersPath = resolveDiagramPath(leveled, [CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID, 'orders', 'Submit']); + assert(ordersPath !== null && ordersPath.at(-1)!.processes.length === 2, 'FAIL: orders/Submit must resolve to the orders sub-DFD (2 processes), not billing\'s'); + assert(resolveDiagramPath(leveled, [CONTEXT_DIAGRAM_ID, 'nope']) === null, 'FAIL: a broken path must resolve to null'); + assert(resolveDiagramPath(leveled, []) === null, 'FAIL: an empty path must resolve to null'); + console.log('PASS: id paths resolve exactly, even when sub-DFD ids collide'); +} + +// A deep-link reference survives a reload: it names the exact sub-DFD, drops +// the derived levels, and an old bare-id link still resolves as before. +{ + const ordersPath = resolveDiagramPath(leveled, [CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID, 'orders', 'Submit'])!; + assert(diagramRef(ordersPath) === 'orders/Submit', `FAIL: sub-DFD ref is the authored path, got ${diagramRef(ordersPath)}`); + assert(diagramRef(ordersPath.slice(0, 2)) === SYSTEM_PROCESS_ID, 'FAIL: a derived diagram is referenced by its own id'); + assert(diagramRef(ordersPath.slice(0, 3)) === 'orders', 'FAIL: a top-level flow ref is its bare id, as links always were'); + + const byRef = findDiagramByRef(leveled, 'orders/Submit'); + assert(byRef !== null && byRef.at(-1)!.processes.some(p => p.id === 'Place'), 'FAIL: orders/Submit resolves to the orders sub-DFD'); + const byBareId = findDiagramByRef(leveled, 'Submit'); + assert(byBareId !== null && byBareId.at(-2)!.id === 'billing', 'FAIL: a bare id resolves to the first match in tree order, as before'); + assert(findDiagramByRef(leveled, SYSTEM_PROCESS_ID)?.length === 2, 'FAIL: a derived diagram ref resolves'); + assert(findDiagramByRef(leveled, 'nope/Submit') === null, 'FAIL: an unknown ref resolves to null'); + console.log('PASS: diagram refs round-trip exactly and bare-id links keep working'); +} + +// The Flows view opens on the overview of every flow, not the one-box Context. +{ + const landing = defaultDiagramPath(leveled).map(d => d.id); + assert(JSON.stringify(landing) === JSON.stringify([CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID]), `FAIL: default path should end at the System overview, got ${JSON.stringify(landing)}`); + assert(JSON.stringify(defaultDiagramPath([billing, orders]).map(d => d.id)) === '["billing"]', 'FAIL: an unleveled tree opens on its first root'); + assert(defaultDiagramPath([]).length === 0, 'FAIL: no diagrams, no default path'); + console.log('PASS: the default diagram is the System overview'); +} + +// Level menus list the diagrams beside the current one, with descriptions. +{ + const roots = levelEntries(null, leveled, MODEL_DESC); + assert(roots.length === 1 && roots[0]!.diagramId === CONTEXT_DIAGRAM_ID, 'FAIL: the root level is the Context diagram'); + assert(roots[0]!.description === MODEL_DESC, 'FAIL: the Context entry falls back to the model description'); + + const system = resolveDiagramPath(leveled, [CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID])!.at(-1)!; + const flows = levelEntries(system, leveled, MODEL_DESC); + assert(JSON.stringify(flows.map(f => f.diagramId)) === '["billing","orders"]', `FAIL: system level lists the flows in order, got ${JSON.stringify(flows.map(f => f.diagramId))}`); + assert(flows[0]!.description === 'Billing scopes.' && flows[0]!.number === '1', 'FAIL: a flow entry carries its index.md description and number'); + assert(flows[1]!.description === undefined, 'FAIL: an undescribed flow has no description (no invented text)'); + assert(flows[0]!.processCount === 2, 'FAIL: a flow entry counts its processes'); + + const billingLevel = levelEntries(resolveDiagramPath(leveled, [CONTEXT_DIAGRAM_ID, SYSTEM_PROCESS_ID, 'billing'])!.at(-1)!, leveled, MODEL_DESC); + assert(billingLevel.length === 1 && billingLevel[0]!.diagramId === 'Submit', 'FAIL: only processes with a sub-DFD appear in a level menu'); + assert(billingLevel[0]!.description === 'Submit an invoice.', 'FAIL: a sub-DFD entry is described by its process'); + + const contextLevel = levelEntries(resolveDiagramPath(leveled, [CONTEXT_DIAGRAM_ID])!.at(-1)!, leveled, MODEL_DESC); + assert(contextLevel.length === 1 && contextLevel[0]!.description === MODEL_DESC, 'FAIL: the whole-system process is described by the model description'); + console.log('PASS: level entries list same-level diagrams with number, description, and process count'); +} + +// The index covers every process; each row opens its own diagram or its container. +{ + const index = buildFlowIndex(leveled, MODEL_DESC); + const all: FlowIndexNode[] = []; + const walk = (nodes: FlowIndexNode[]) => { for (const n of nodes) { all.push(n); walk(n.children); } }; + walk(index); + + const keys = new Set(all.map(n => n.key)); + assert(keys.size === all.length, 'FAIL: index keys must be unique even when process ids repeat'); + + assert(JSON.stringify(index.map(n => n.label)) === '["billing","orders"]', `FAIL: the flows are the top level, with no Context or System rows above them, got ${JSON.stringify(index.map(n => n.label))}`); + assert(!all.some(n => n.path.at(-1) === CONTEXT_DIAGRAM_ID || (n.opensOwnDiagram && n.path.at(-1) === SYSTEM_PROCESS_ID)), 'FAIL: no row opens a derived level'); + // 2 flows + billing(Submit, Cancel) + Check + orders(Submit) + Price + Place + assert(all.length === 8, `FAIL: expected 8 rows, got ${all.length}`); + + const cancel = all.find(n => n.label === 'Cancel')!; + assert(!cancel.opensOwnDiagram && cancel.path.join('/') === `${CONTEXT_DIAGRAM_ID}/${SYSTEM_PROCESS_ID}/billing`, 'FAIL: a process without a sub-DFD opens the diagram that contains it'); + + const place = all.find(n => n.label === 'Place')!; + assert(place.path.join('/') === `${CONTEXT_DIAGRAM_ID}/${SYSTEM_PROCESS_ID}/orders/Submit`, 'FAIL: a nested leaf opens its exact containing sub-DFD'); + + const flowRow = all.find(n => n.label === 'billing' && n.number === '1')!; + assert(flowRow.opensOwnDiagram && flowRow.description === 'Billing scopes.', 'FAIL: a flow row opens the flow and shows its description'); + console.log('PASS: the index lists every process with a unique key and the exact diagram path it opens'); +} + +console.log('\nAll flow-nav tests passed.'); diff --git a/test/checks/test-graph-inherited-edges.ts b/test/checks/test-graph-inherited-edges.ts index 527f0d3..b922ace 100644 --- a/test/checks/test-graph-inherited-edges.ts +++ b/test/checks/test-graph-inherited-edges.ts @@ -25,7 +25,9 @@ * The shift state is injected synthetically: a cytoscape `mouseover` event is * emitted with `originalEvent: { shiftKey: true }`, which is exactly what the * GraphView handler reads (`evt.originalEvent?.shiftKey`). `mouseout` is emitted - * to leave the node. Reads element state straight off `window.__IGNATIUS_CY__`. + * to leave the node. Reads element state straight off `window.__IGNATIUS_CY__` + * once the hover has settled (HOVER_INTENT_MS) — a hover applies only after the + * pointer rests, so reading immediately would see the pre-hover graph. * * Skips gracefully (exit 0) when dist/static/index.js is absent — CI builds the * bundle before running checks. @@ -35,6 +37,9 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { existsSync } from 'fs'; import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; + +const settleHover = () => new Promise(r => setTimeout(r, HOVER_INTENT_MS + 100)); const ROOT = resolve(import.meta.dir, '../..'); const MODEL = join(ROOT, 'models/key-inherited'); @@ -67,13 +72,23 @@ type EdgeReport = { source: string; target: string; faded: boolean }; // Emit a synthetic 'mouseover' on a node with the given shift state and return // the resulting inherited-edge set. Mirrors GraphView's `evt.originalEvent?.shiftKey`. async function hoverAndReport(id: string, shiftKey: boolean): Promise<{ ok: boolean; edges: EdgeReport[] }> { - return await page.evaluate( + const emitted = await page.evaluate( ({ nodeId, shift }: { nodeId: string; shift: boolean }) => { const cy = window.__IGNATIUS_CY__; - if (!cy) return { ok: false, edges: [] as Array<{ source: string; target: string; faded: boolean }> }; + if (!cy) return false; const node = cy.$(`#${nodeId}`); - if (node.empty()) return { ok: false, edges: [] as Array<{ source: string; target: string; faded: boolean }> }; + if (node.empty()) return false; node.emit({ type: 'mouseover', target: node, originalEvent: { shiftKey: shift } }); + return true; + }, + { nodeId: id, shift: shiftKey }, + ); + if (!emitted) return { ok: false, edges: [] }; + await settleHover(); + return await page.evaluate( + () => { + const cy = window.__IGNATIUS_CY__; + if (!cy) return { ok: false, edges: [] as Array<{ source: string; target: string; faded: boolean }> }; const edges = cy.edges('.inherited').map((e: { source(): { id(): string }; target(): { id(): string } }) => ({ source: e.source().id(), target: e.target().id(), @@ -82,7 +97,6 @@ async function hoverAndReport(id: string, shiftKey: boolean): Promise<{ ok: bool })); return { ok: true, edges }; }, - { nodeId: id, shift: shiftKey }, ); } @@ -171,7 +185,7 @@ try { // ── 3. mouseout (still shift held) → lineage cleared ───────────────────── await leaveNode('Identity'); - await new Promise(r => setTimeout(r, 150)); + await settleHover(); assert((await inheritedCount()) === 0, 'mouseout removes ALL inherited edges (lineage cleared on leave)'); // ── 4. SHIFT+HOVER ITIN (transitive via ITIN → Identity → Party) ───────── @@ -203,7 +217,7 @@ try { ); await leaveNode('ITIN'); - await new Promise(r => setTimeout(r, 150)); + await settleHover(); assert((await inheritedCount()) === 0, 'mouseout after ITIN removes ALL inherited edges'); // ── 5. Plain (no-shift) HOVER → NO lineage ─────────────────────────────── @@ -214,7 +228,30 @@ try { `plain (no-shift) hover draws NO inherited edges (got ${plainHover.edges.length}); only the direct-neighbour fade applies`, ); await leaveNode('Identity'); - await new Promise(r => setTimeout(r, 150)); + await settleHover(); + + // ── 5b. Shift pressed inside the hover wait shows lineage at once ──────── + // Pressing Shift is deliberate, so it skips the wait a pointer hover gets. + await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__; + const node = cy?.$('#Identity'); + node?.emit({ type: 'mouseover', target: node, originalEvent: { shiftKey: false } }); + }); + await page.keyboard.down('Shift'); + const pendingShift = await inheritedCount(); + assert(pendingShift > 0, `Shift pressed while the hover is still waiting draws lineage at once (got ${pendingShift})`); + await page.keyboard.up('Shift'); + await leaveNode('Identity'); + await settleHover(); + + // ── 5c. A plain click clears a settled hover at once ───────────────────── + // The entity modal covers the canvas, so no mouseout will ever clear it. + await hoverAndReport('Identity', false); + const fadedCount = () => page.evaluate(() => window.__IGNATIUS_CY__?.elements('.faded').length ?? -1); + const fadedBeforeClick = await fadedCount(); + await tapNode('Identity'); + const fadedAfterClick = await fadedCount(); + assert(fadedBeforeClick > 0 && fadedAfterClick === 0, `a plain click clears the settled hover fade at once (before ${fadedBeforeClick}, after ${fadedAfterClick})`); // ── 6. Background-tap deselect → all inherited edges removed ────────────── await page.evaluate(() => { diff --git a/test/checks/test-hash-router.ts b/test/checks/test-hash-router.ts index 6608f6b..f43c258 100644 --- a/test/checks/test-hash-router.ts +++ b/test/checks/test-hash-router.ts @@ -195,6 +195,13 @@ function deepEqual(a: unknown, b: unknown): boolean { assert(parsed.dfd === 'order-to-cash', "dfd encode/decode: 'order-to-cash' round-trips exactly"); } +{ + // A sub-DFD reference is an id path; the slash stays readable in the URL. + const encoded = serializeHash({ view: 'flow', dfd: 'invoicing/Submit PCI' }); + assert(encoded.includes('dfd=invoicing/Submit%20PCI'), `dfd path keeps '/' readable (got ${encoded})`); + assert(parseHash('#' + encoded).dfd === 'invoicing/Submit PCI', "dfd path 'invoicing/Submit PCI' round-trips exactly"); +} + // --- flowview + collapse fields (docs/spec/dfd-store-clusters.md) --- { diff --git a/test/checks/test-large-model-nav.ts b/test/checks/test-large-model-nav.ts new file mode 100644 index 0000000..df89f17 --- /dev/null +++ b/test/checks/test-large-model-nav.ts @@ -0,0 +1,257 @@ +/** + * test-large-model-nav.ts — the flow index, breadcrumb level menus, hover + * delay, and animation cutoff in the running app (docs/spec/large-model-nav.md). + * + * Why it matters: on a model with dozens of flows the only way to a flow was + * drilling down, and every pointer pass over a diagram faded the whole view. + * This drives a served fixture through a browser and checks what a user sees: + * 1. a crumb gets a ▾ only when its level has another diagram, and the menu + * lists them with descriptions, filters them, and switches flows by + * pointer or keyboard; + * 2. the root chip and the `i` key open the index, whose rows open the exact + * diagram even when two flows decompose a same-named process, and the + * URL keeps that diagram across a reload; + * 3. a hover fades nothing until the pointer rests HOVER_INTENT_MS, and + * opening a dialog clears it at once; + * 4. a diagram or dictionary over ANIMATION_ELEMENT_LIMIT drops its fades. + * + * Generates its fixture under tmp/. Skips when dist/static is not built. The + * two same-named `Submit` sub-DFDs make the Dictionary log React duplicate-key + * warnings (its process rows key on bare ids); that is known and out of scope. + */ + +import { chromium } from 'playwright'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join, resolve } from 'path'; +import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; + +const ROOT = resolve(import.meta.dir, '../..'); +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); +} + +const FIXTURE = join(ROOT, 'tmp/large-model-nav-fixture'); +const WIDE_PROCESSES = 80; +const ENTITIES = 70; +// Enough one-process flows that the System level holds more than the level +// menu's filter threshold (8), so the filter and keyboard paths render. +const EXTRA_FLOWS = 7; +const FLOW_COUNT = 3 + EXTRA_FLOWS; + +function processFile(name: string, number: number, description?: string): string { + return [ + '---', + `process: ${name}`, + `number: ${number}`, + ...(description ? [`description: ${description}`] : []), + 'inputs:', + ' - from: ext:Clerk', + ' data: request', + 'outputs:', + ' - to: ext:Clerk', + ' data: response', + '---', + '', + ].join('\n'); +} + +rmSync(FIXTURE, { recursive: true, force: true }); +for (const dir of ['externals', 'groups', 'data', 'flows/alpha/Submit', 'flows/beta/Submit', 'flows/wide']) { + mkdirSync(join(FIXTURE, dir), { recursive: true }); +} +writeFileSync(join(FIXTURE, 'ignatius.yml'), 'name: NavFixture\ndescription: A fixture for flow navigation.\n'); +writeFileSync(join(FIXTURE, 'externals/Clerk.md'), '---\nexternal: Clerk\n---\n'); +writeFileSync(join(FIXTURE, 'groups/misc.md'), '---\nlabel: Misc\ncolor: "#888888"\n---\n'); +for (let i = 0; i < ENTITIES; i++) { + writeFileSync(join(FIXTURE, `data/Thing${i}.md`), `---\nentity: Thing${i}\ngroup: misc\npk: [id]\ncolumns:\n id: { type: integer }\n---\n`); +} +writeFileSync(join(FIXTURE, 'flows/alpha/index.md'), '---\ndescription: Alpha takes orders.\n---\n'); +writeFileSync(join(FIXTURE, 'flows/alpha/Submit.md'), processFile('Submit', 1, 'Alpha submits.')); +writeFileSync(join(FIXTURE, 'flows/alpha/Cancel.md'), processFile('Cancel', 2)); +writeFileSync(join(FIXTURE, 'flows/alpha/Submit/Check.md'), processFile('Check', 1)); +writeFileSync(join(FIXTURE, 'flows/alpha/Submit/Price.md'), processFile('Price', 2)); +writeFileSync(join(FIXTURE, 'flows/beta/Submit.md'), processFile('Submit', 1, 'Beta submits.')); +writeFileSync(join(FIXTURE, 'flows/beta/Hold.md'), processFile('Hold', 2)); +writeFileSync(join(FIXTURE, 'flows/beta/Submit/Place.md'), processFile('Place', 1)); +for (let i = 1; i <= EXTRA_FLOWS; i++) { + mkdirSync(join(FIXTURE, `flows/extra-${i}`), { recursive: true }); + writeFileSync(join(FIXTURE, `flows/extra-${i}/Step.md`), processFile('Step', 1)); +} +for (let i = 1; i <= WIDE_PROCESSES; i++) { + writeFileSync(join(FIXTURE, `flows/wide/Step${String(i).padStart(3, '0')}.md`), processFile(`Step ${i}`, i)); +} + +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 = 3318; +const handle = serveCommand(FIXTURE, { port: PORT }); +await new Promise(r => setTimeout(r, 400)); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const BASE = `http://localhost:${PORT}/`; + +async function waitForFlow(): Promise { + await page.waitForFunction(() => window.__IGNATIUS_FLOW_READY__ === true, null, { timeout: 30_000 }); + await page.waitForTimeout(150); +} +const crumbTexts = async () => (await page.locator('[data-ignatius="flow-crumb"]').allInnerTexts()).map(t => t.replace(/\s*▾\s*$/, '')); +const svgText = async () => (await page.locator('[data-ignatius="flow-svg"]').textContent()) ?? ''; +const dimmedCount = () => page.evaluate(() => + [...document.querySelectorAll('[data-ignatius="flow-svg"] g[data-token]')].filter(g => g.getAttribute('opacity') !== '1').length); +const transitionCount = () => page.evaluate(() => + [...document.querySelectorAll('[data-ignatius="flow-svg"] g[data-token]')].filter(g => g.style.transition !== '').length); + +try { + // ── 0. Landing: the System overview, no derived crumbs ────────────────── + await page.goto(`${BASE}#view=flow`); + await waitForFlow(); + const indexChip = page.locator('[data-ignatius="flow-index-button"]'); + assert((await svgText()).includes('Alpha') && (await svgText()).includes('Beta'), 'Flows opens on the overview that shows every flow'); + assert(JSON.stringify(await crumbTexts()) === '[]', 'the derived Context and System levels get no crumb', JSON.stringify(await crumbTexts())); + const homeButton = page.locator('[data-ignatius="flow-home-button"]'); + assert(await homeButton.getAttribute('aria-current') === 'page', 'the Home button is current on the overview'); + assert(await page.locator('.flow-crumbs__back').count() === 0, 'the overview has no Back button'); + await indexChip.click(); + const derivedRows = await page.locator('[data-ignatius="flow-index-row"][data-path$="__context__"], [data-ignatius="flow-index-row"][data-path$="__system__"]').count(); + assert(derivedRows === 0, 'the index has no Context or System rows; the flows are its top level', `derived rows=${derivedRows}`); + assert((await page.locator('[data-ignatius="flow-index-pane"]').innerText()).includes('A fixture for flow navigation.'), 'opened from the overview, the pane describes the model'); + await page.keyboard.press('Escape'); + + await page.goto(`${BASE}#view=flow&dfd=beta`); + await waitForFlow(); + + // ── 1. Level menus ────────────────────────────────────────────────────── + assert(JSON.stringify(await crumbTexts()) === '["2 Beta"]', 'a flow deep link shows only the authored crumb', JSON.stringify(await crumbTexts())); + assert(await homeButton.getAttribute('aria-current') === null, 'Home is not current inside a flow'); + const menuButtons = page.locator('[data-ignatius="flow-crumb-menu-button"]'); + assert(await menuButtons.count() === 1, 'the flow crumb has a ▾ listing its sibling flows', `count=${await menuButtons.count()}`); + + const menu = page.locator('[data-ignatius="flow-level-menu"]'); + const items = page.locator('[data-ignatius="flow-level-menu-item"]'); + await menuButtons.first().click(); + assert(await items.count() === FLOW_COUNT, `the level menu lists all ${FLOW_COUNT} flows`, `count=${await items.count()}`); + assert((await items.filter({ hasText: 'Alpha' }).innerText()).includes('Alpha takes orders.'), 'a menu entry shows its flow description from index.md'); + assert((await items.filter({ hasText: 'Beta' }).getAttribute('aria-selected')) === 'true', 'the current flow is marked in its menu'); + await page.keyboard.press('Escape'); + assert(await menu.count() === 0, 'Esc closes the menu'); + + await menuButtons.first().click(); + await page.locator('.flow-crumbs__sep').first().click(); + assert(await menu.count() === 0, 'a click outside closes the menu'); + + await menuButtons.first().click(); + assert(await page.locator('.flow-level-menu__filter').count() === 1, `a level with more than eight diagrams shows a filter`); + await page.keyboard.type('Extra'); + assert(await items.count() === EXTRA_FLOWS, 'typing filters the entries', `count=${await items.count()}`); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.type('alpha'); + await page.keyboard.press('Enter'); + await waitForFlow(); + assert((await crumbTexts()).at(-1) === '1 Alpha', 'Enter on the filtered entry switches to that flow', JSON.stringify(await crumbTexts())); + assert(await menu.count() === 0, 'the menu closes after a pick'); + + await menuButtons.first().click(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await waitForFlow(); + assert((await crumbTexts()).at(-1) === '2 Beta', '↓ then Enter picks the next flow', JSON.stringify(await crumbTexts())); + + // ── 2. Flow index ─────────────────────────────────────────────────────── + await page.locator('[data-ignatius="flow-index-button"]').click(); + assert(await page.locator('[data-ignatius="flow-index"]').count() === 1, 'the Process Flows chip opens the index'); + const wideRows = await page.locator('[data-ignatius="flow-index-row"][data-path$="/wide"] .flow-index__num').allInnerTexts(); + assert(wideRows.length === WIDE_PROCESSES + 1, 'the index lists the wide flow and each of its processes', `rows=${wideRows.length}`); + const wideSteps = wideRows.filter(n => n.includes('.')).map(n => Number(n.split('.')[1])); + assert(wideSteps.every((n, i) => n === i + 1), 'index rows run in number order', wideSteps.slice(0, 12).join(',')); + + const betaSubmit = page.locator('[data-ignatius="flow-index-row"][data-path$="/beta/Submit"]').first(); + await betaSubmit.hover(); + assert((await page.locator('[data-ignatius="flow-index-pane"]').innerText()).includes('Beta submits.'), 'hovering a row shows its description in the pane'); + await betaSubmit.click(); + await waitForFlow(); + assert(await page.locator('[data-ignatius="flow-index"]').count() === 0, 'choosing a row closes the index'); + const svgAfterPick = await svgText(); + assert(svgAfterPick.includes('Place') && !svgAfterPick.includes('Price'), 'the row opens beta/Submit, not the same-named alpha/Submit'); + const pickedHash = await page.evaluate(() => location.hash); + assert(pickedHash.includes('dfd=beta/Submit'), 'the URL carries the diagram path, not a bare id', pickedHash); + await page.reload(); + await waitForFlow(); + const svgAfterReload = await svgText(); + assert(svgAfterReload.includes('Place') && !svgAfterReload.includes('Price'), 'a reload lands on the same sub-DFD'); + await homeButton.click(); + await waitForFlow(); + assert(JSON.stringify(await crumbTexts()) === '[]' && await homeButton.getAttribute('aria-current') === 'page', 'Home returns from a sub-DFD to the overview in one click', JSON.stringify(await crumbTexts())); + await page.goBack(); + await waitForFlow(); + + await page.keyboard.press('i'); + assert(await page.locator('[data-ignatius="flow-index"]').count() === 1, '`i` opens the index'); + await page.keyboard.press('i'); + assert(await page.locator('[data-ignatius="flow-index"]').count() === 0, '`i` closes the index'); + await page.keyboard.press('i'); + await page.keyboard.press('Escape'); + assert(await page.locator('[data-ignatius="flow-index"]').count() === 0, 'Esc closes the index'); + + // ── 3. Hover delay ────────────────────────────────────────────────────── + await page.goto(`${BASE}#view=flow&dfd=alpha`); + await waitForFlow(); + assert(await transitionCount() > 0, 'a small diagram keeps its fade transitions'); + const node = page.locator('[data-token^="proc:"]').first(); + const box = await node.boundingBox(); + assert(box !== null, 'a process node is on screen'); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 2 }); + await page.waitForTimeout(HOVER_INTENT_MS / 3); + assert(await dimmedCount() === 0, `nothing fades before the pointer rests ${HOVER_INTENT_MS} ms`); + await page.waitForTimeout(HOVER_INTENT_MS); + assert(await dimmedCount() > 0, 'the hover focus applies once the pointer rests'); + await page.mouse.move(2, 450); + await page.waitForTimeout(HOVER_INTENT_MS / 3); + assert(await dimmedCount() > 0, 'leaving keeps the focus until the pointer rests elsewhere'); + await page.waitForTimeout(HOVER_INTENT_MS); + assert(await dimmedCount() === 0, 'resting on empty space clears the focus'); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 2 }); + await page.waitForTimeout(HOVER_INTENT_MS + 100); + assert(await dimmedCount() > 0, 'the focus is back before the dialog check'); + await page.locator('[data-ignatius="flow-chip"]').first().click(); + await page.waitForSelector('.modal', { timeout: 5000 }); + assert(await dimmedCount() === 0, 'opening an edge contract dialog clears the hover at once'); + await page.locator('.modal-close').first().click(); + } + + // ── 4. Animation cutoff ───────────────────────────────────────────────── + await page.goto(`${BASE}#view=flow&dfd=wide`); + await waitForFlow(); + assert(await transitionCount() === 0, `a diagram over the element limit renders without fade transitions`); + await page.goto(`${BASE}#view=dict`); + await page.waitForSelector('[data-ignatius="dict-view"]', { timeout: 20_000 }); + assert(await page.locator('[data-ignatius="dict-view"]').getAttribute('data-motion') === 'off', 'a dictionary over the element limit turns motion off'); +} finally { + await page.close(); + await browser.close(); + handle.stop(); + rmSync(FIXTURE, { recursive: true, force: true }); +} + +if (failures > 0) { + console.error(`\n${failures} failure(s).`); + process.exit(1); +} +console.log('\ntest-large-model-nav: all assertions passed.'); +process.exit(0); diff --git a/test/checks/test-motion.ts b/test/checks/test-motion.ts new file mode 100644 index 0000000..454ae5a --- /dev/null +++ b/test/checks/test-motion.ts @@ -0,0 +1,104 @@ +/** + * test-motion.ts — hover intent and the animation cutoff (src/app/logic/motion.ts). + * + * Why it matters: on a large model a hover focus restyles hundreds of + * elements. A pointer sweeping across the canvas must trigger none of that, + * a pointer that rests must trigger exactly one, and moving between two + * elements must switch focus in one step rather than clear and re-fade. + */ + +import { assert } from '../assert'; +import { ANIMATION_ELEMENT_LIMIT, HOVER_INTENT_MS, animationsAllowed, createHoverIntent } from '../../src/app/logic/motion'; + +const DELAY = 200; +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +function recorder() { + const applied: Array = []; + const intent = createHoverIntent(target => applied.push(target), DELAY); + return { applied, intent }; +} + +assert(HOVER_INTENT_MS === 300, `FAIL: hover intent delay is ${HOVER_INTENT_MS}ms, expected 300ms`); +assert(ANIMATION_ELEMENT_LIMIT === 150, `FAIL: animation limit is ${ANIMATION_ELEMENT_LIMIT}, expected 150`); +assert(animationsAllowed(150) && !animationsAllowed(151), 'FAIL: the limit is inclusive: 150 animates, 151 does not'); +console.log('PASS: 300 ms hover delay; animations stop above 150 rendered elements'); + +// A sweep across several elements applies nothing, then the resting one once. +{ + const { applied, intent } = recorder(); + for (const id of ['a', 'b', 'c', 'd']) { + intent.set(id); + await sleep(DELAY / 4); + } + assert(applied.length === 0, `FAIL: a sweep applied ${JSON.stringify(applied)} before settling`); + await sleep(DELAY * 2); + assert(JSON.stringify(applied) === '["d"]', `FAIL: expected only the resting target, got ${JSON.stringify(applied)}`); + console.log('PASS: sweeping applies nothing; resting applies the final target once'); +} + +// Pointer moves inside one element keep reporting it; the wait never restarts. +{ + const { applied, intent } = recorder(); + intent.set('edge'); + for (let i = 0; i < 6; i++) { + await sleep(DELAY / 4); + intent.set('edge'); + } + assert(JSON.stringify(applied) === '["edge"]', `FAIL: repeated reports of one target should settle once, got ${JSON.stringify(applied)}`); + console.log('PASS: repeated reports of the same target do not restart the wait'); +} + +// A → B switches in one step: no null in between, even across an empty gap. +{ + const { applied, intent } = recorder(); + intent.set('a'); + await sleep(DELAY * 2); + intent.set(null); // pointer crosses empty canvas + await sleep(DELAY / 4); + intent.set('b'); + await sleep(DELAY * 2); + assert(JSON.stringify(applied) === '["a","b"]', `FAIL: expected a then b with no clear, got ${JSON.stringify(applied)}`); + console.log('PASS: moving between elements switches focus without an intermediate clear'); +} + +// Leaving and coming back inside the wait keeps the focus and applies nothing. +{ + const { applied, intent } = recorder(); + intent.set('a'); + await sleep(DELAY * 2); + intent.set(null); + await sleep(DELAY / 4); + intent.set('a'); + await sleep(DELAY * 2); + assert(JSON.stringify(applied) === '["a"]', `FAIL: returning to the focused target should change nothing, got ${JSON.stringify(applied)}`); + console.log('PASS: returning to the focused element before the wait ends keeps its focus'); +} + +// Leaving to empty space clears only after resting there. +{ + const { applied, intent } = recorder(); + intent.set('a'); + await sleep(DELAY * 2); + intent.set(null); + await sleep(DELAY * 2); + assert(JSON.stringify(applied) === '["a",null]', `FAIL: expected a then clear, got ${JSON.stringify(applied)}`); + console.log('PASS: resting on empty space clears the focus'); +} + +// applyNow skips the wait (Shift, dialogs); cancel drops a pending target. +{ + const { applied, intent } = recorder(); + intent.set('a'); + intent.applyNow('b'); + assert(JSON.stringify(applied) === '["b"]' && intent.applied() === 'b', `FAIL: applyNow should apply at once, got ${JSON.stringify(applied)}`); + await sleep(DELAY * 2); + assert(applied.length === 1, `FAIL: applyNow must drop the pending target, got ${JSON.stringify(applied)}`); + intent.set('c'); + intent.cancel(); + await sleep(DELAY * 2); + assert(applied.length === 1 && intent.applied() === 'b', 'FAIL: cancel must drop the pending target and keep the applied one'); + console.log('PASS: applyNow applies immediately; cancel drops the pending target'); +} + +console.log('\nAll motion tests passed.'); diff --git a/test/checks/test-shortcuts.ts b/test/checks/test-shortcuts.ts index 7202594..0e85fe6 100644 --- a/test/checks/test-shortcuts.ts +++ b/test/checks/test-shortcuts.ts @@ -503,4 +503,21 @@ for (const key of ['g', 'd', 'f', 'l', 'b']) { console.log('PASS T29: ctrl/meta/alt arrow chords → null'); } +// --------------------------------------------------------------------------- +// T30: i → flowIndex only on the flow view. On graph/dict it stays a plain +// keystroke; typing or any modifier suppresses it like every bare key. +// --------------------------------------------------------------------------- +{ + const result = resolveShortcut(ev('i'), 'flow', false); + assert(result !== null && result.type === 'flowIndex', "T30: i in 'flow' → flowIndex"); + assert(resolveShortcut(ev('I'), 'flow', false)?.type === 'flowIndex', 'T30: capslock I in flow → flowIndex'); + assert(resolveShortcut(ev('i'), 'graph', false) === null, "T30: i in 'graph' → null"); + assert(resolveShortcut(ev('i'), 'dict', false) === null, "T30: i in 'dict' → null"); + assert(resolveShortcut(ev('i'), 'flow', true) === null, 'T30: i while editable → null'); + for (const mod of ['ctrlKey', 'metaKey', 'altKey', 'shiftKey'] as const) { + assert(resolveShortcut(ev('i', { [mod]: true }), 'flow', false) === null, `T30: ${mod}+i → null`); + } + console.log("PASS T30: i → flowIndex on the flow view only"); +} + console.log('\nAll tests passed.'); diff --git a/test/visual/screenshot-hover-fade.ts b/test/visual/screenshot-hover-fade.ts index 4632f45..8efbfbd 100644 --- a/test/visual/screenshot-hover-fade.ts +++ b/test/visual/screenshot-hover-fade.ts @@ -13,6 +13,7 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { mkdirSync } from 'fs'; import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; const ROOT = resolve(import.meta.dir, '../..'); const MODELS = join(ROOT, 'models', 'key-inherited'); @@ -58,17 +59,21 @@ try { await page.screenshot({ path: join(TMP, 'hover-fade-before.png') }); note('Saved tmp/hover-fade-before.png'); + await page.evaluate((id: string) => { + window.__IGNATIUS_CY__!.$id(id).emit('mouseover'); + }, target.id); + // Hover focus applies only once the pointer rests on the target (motion.ts). + await page.waitForTimeout(HOVER_INTENT_MS + 100); + const counts = await page.evaluate((id: string) => { const cy = window.__IGNATIUS_CY__!; const node = cy.$id(id); - node.emit('mouseover'); const all = cy.elements(); const faded = all.filter((e: CyEle) => e.hasClass('faded')); const keep = node.closedNeighborhood(); return { total: all.length, faded: faded.length, keep: keep.length }; }, target.id); - await page.waitForTimeout(300); await page.screenshot({ path: join(TMP, 'hover-fade-after.png') }); note(`Saved tmp/hover-fade-after.png (faded=${counts.faded}/${counts.total}, keep=${counts.keep})`); @@ -77,11 +82,15 @@ try { fail(`faded(${counts.faded}) + keep(${counts.keep}) != total(${counts.total})`); } - const restored = await page.evaluate((id: string) => { - const cy = window.__IGNATIUS_CY__!; - cy.$id(id).emit('mouseout'); - return cy.elements().filter((e: CyEle) => e.hasClass('faded')).length; + await page.evaluate((id: string) => { + window.__IGNATIUS_CY__!.$id(id).emit('mouseout'); }, target.id); + // Leaving clears the fade only after the same settle delay. + await page.waitForTimeout(HOVER_INTENT_MS + 100); + + const restored = await page.evaluate(() => { + return window.__IGNATIUS_CY__!.elements().filter((e: CyEle) => e.hasClass('faded')).length; + }); if (restored !== 0) fail(`${restored} elements still faded after mouseout`); else note('all elements restored on mouseout'); diff --git a/test/visual/screenshot-large-model-nav.ts b/test/visual/screenshot-large-model-nav.ts new file mode 100644 index 0000000..0ad5d82 --- /dev/null +++ b/test/visual/screenshot-large-model-nav.ts @@ -0,0 +1,90 @@ +/** + * Visual verification: flow index, breadcrumb level menu, and hover delay + * (docs/spec/large-model-nav.md) on models/llm-memory-db-mssql, whose six + * flows carry index.md descriptions. + * + * Writes to tmp/large-model-nav-shots/: + * 0. the landing System overview, Process Flows chip current (dark) + * 1. breadcrumbs with the ▾ on the flow crumb (dark) + * 2. the level menu open on that crumb (dark, then light) + * 3. the flow index with a row hovered and its description in the pane (dark, then light) + * 4. a process node 100 ms into a hover (no fade yet) and after it settles + * + * NOT run by `bun run test` — manual visual check only. + */ + +import { chromium } from 'playwright'; +import type { Page } from 'playwright'; +import { resolve, join } from 'path'; +import { mkdirSync } from 'fs'; +import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; + +const ROOT = resolve(import.meta.dir, '../..'); +const MODEL = join(ROOT, 'models/llm-memory-db-mssql'); +const OUT = join(ROOT, 'tmp/large-model-nav-shots'); +mkdirSync(OUT, { recursive: true }); + +const PORT = 3321; +const handle = serveCommand(MODEL, { port: PORT }); +await new Promise(r => setTimeout(r, 500)); + +const browser = await chromium.launch(); + +async function openFlow(page: Page, dfd: string): Promise { + await page.goto(`http://localhost:${PORT}/#view=flow&dfd=${dfd}`); + await page.waitForFunction(() => window.__IGNATIUS_FLOW_READY__ === true, null, { timeout: 30_000 }); + await page.waitForTimeout(300); +} + +async function shoot(theme: 'dark' | 'light'): Promise { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + if (theme === 'dark') { + await page.goto(`http://localhost:${PORT}/#view=flow`); + await page.waitForFunction(() => window.__IGNATIUS_FLOW_READY__ === true, null, { timeout: 30_000 }); + await page.waitForTimeout(300); + await page.screenshot({ path: join(OUT, '0-landing-overview-dark.png') }); + } + await openFlow(page, 'work-planning'); + if (theme === 'light') { + await page.locator('.theme-toggle').click(); + await page.waitForTimeout(200); + } + if (theme === 'dark') { + await page.screenshot({ path: join(OUT, '1-crumbs-dark.png'), clip: { x: 0, y: 0, width: 1440, height: 120 } }); + } + + await page.locator('[data-ignatius="flow-crumb-menu-button"]').first().click(); + await page.waitForTimeout(200); + await page.screenshot({ path: join(OUT, `2-level-menu-${theme}.png`), clip: { x: 0, y: 0, width: 1100, height: 520 } }); + await page.keyboard.press('Escape'); + + await page.locator('[data-ignatius="flow-index-button"]').click(); + await page.waitForTimeout(200); + await page.locator('[data-ignatius="flow-index-row"][data-path$="/memory-lifecycle"]').first().hover(); + await page.waitForTimeout(150); + await page.screenshot({ path: join(OUT, `3-index-${theme}.png`) }); + await page.keyboard.press('Escape'); + + if (theme === 'dark') { + const box = await page.locator('[data-token^="proc:"]').first().boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.waitForTimeout(100); + await page.screenshot({ path: join(OUT, '4a-hover-100ms.png') }); + await page.waitForTimeout(HOVER_INTENT_MS + 100); + await page.screenshot({ path: join(OUT, '4b-hover-settled.png') }); + } + } + await page.close(); +} + +try { + await shoot('dark'); + await shoot('light'); + console.log(`screenshots in ${OUT}`); +} finally { + await browser.close(); + handle.stop(); +} +process.exit(0); diff --git a/test/visual/screenshot-lineage-highlight.ts b/test/visual/screenshot-lineage-highlight.ts index 0eb68b9..4ca568f 100644 --- a/test/visual/screenshot-lineage-highlight.ts +++ b/test/visual/screenshot-lineage-highlight.ts @@ -19,6 +19,7 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { mkdirSync } from 'fs'; import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; const ROOT = resolve(import.meta.dir, '../..'); const MODELS = join(ROOT, 'models', 'key-inherited'); @@ -53,11 +54,17 @@ try { note('Saved tmp/lineage-before.png'); // Hover License; report fade state of each node on the lineage chain. - const state = await page.evaluate(() => { + const licenseFound = await page.evaluate(() => { const cy = window.__IGNATIUS_CY__!; const license = cy.$id('License'); - if (license.empty()) return null; + if (license.empty()) return false; license.emit('mouseover'); + return true; + }); + // Hover focus applies only once the pointer rests on the target (motion.ts). + await page.waitForTimeout(HOVER_INTENT_MS + 100); + const state = licenseFound ? await page.evaluate(() => { + const cy = window.__IGNATIUS_CY__!; const faded = (id: string) => { const n = cy.$id(id); return n.empty() ? null : n.hasClass('faded'); @@ -68,9 +75,8 @@ try { Party: faded('Party'), PartyType: faded('PartyType'), }; - }); + }) : null; - await page.waitForTimeout(300); await page.screenshot({ path: join(TMP, 'lineage-after.png') }); note(`Saved tmp/lineage-after.png — fade map: ${JSON.stringify(state)}`); @@ -85,10 +91,13 @@ try { if (ok) note('Lineage lit License→Identity→Party; stopped at referential PartyType.'); } + await page.evaluate(() => { + window.__IGNATIUS_CY__!.$id('License').emit('mouseout'); + }); + // Leaving clears the fade only after the same settle delay. + await page.waitForTimeout(HOVER_INTENT_MS + 100); const restored = await page.evaluate(() => { - const cy = window.__IGNATIUS_CY__!; - cy.$id('License').emit('mouseout'); - return cy.elements().filter((e: CyEle) => e.hasClass('faded')).length; + return window.__IGNATIUS_CY__!.elements().filter((e: CyEle) => e.hasClass('faded')).length; }); if (restored !== 0) fail(`${restored} elements still faded after mouseout`); else note('all elements restored on mouseout'); diff --git a/test/visual/screenshot-predicate-hover.ts b/test/visual/screenshot-predicate-hover.ts index b6fef23..48aa598 100644 --- a/test/visual/screenshot-predicate-hover.ts +++ b/test/visual/screenshot-predicate-hover.ts @@ -16,6 +16,7 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { mkdirSync } from 'fs'; import { serveCommand } from '../../src/server/server'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; const ROOT = resolve(import.meta.dir, '../..'); const MODELS = join(ROOT, 'models', 'key-inherited'); @@ -78,21 +79,28 @@ try { note('Saved tmp/predicate-before.png (forward labels)'); // Hover: emit mouseover on the node — fires the delegated cy.on handler. + await page.evaluate((id: string) => { + window.__IGNATIUS_CY__!.$id(id).emit('mouseover'); + }, target.id); + // Reverse predicates apply only once the pointer rests on the target (motion.ts). + await page.waitForTimeout(HOVER_INTENT_MS + 100); const after = await page.evaluate((id: string) => { const cy = window.__IGNATIUS_CY__!; const node = cy.$id(id); - node.emit('mouseover'); return node.connectedEdges().map((e: CyEdge) => ({ id: e.id(), label: e.data('edgeLabel') })); }, target.id); - await page.waitForTimeout(500); await page.screenshot({ path: join(TMP, 'predicate-after.png') }); note('Saved tmp/predicate-after.png (reverse labels on child-end edges)'); + await page.evaluate((id: string) => { + window.__IGNATIUS_CY__!.$id(id).emit('mouseout'); + }, target.id); + // Leaving restores the forward predicate only after the same settle delay. + await page.waitForTimeout(HOVER_INTENT_MS + 100); const restored = await page.evaluate((id: string) => { const cy = window.__IGNATIUS_CY__!; const node = cy.$id(id); - node.emit('mouseout'); return node.connectedEdges().map((e: CyEdge) => ({ id: e.id(), label: e.data('edgeLabel') })); }, target.id); diff --git a/test/visual/test-dd-spotlight-grid.ts b/test/visual/test-dd-spotlight-grid.ts index 4a80863..a7ece35 100644 --- a/test/visual/test-dd-spotlight-grid.ts +++ b/test/visual/test-dd-spotlight-grid.ts @@ -14,6 +14,7 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { mkdirSync } from 'fs'; import { SYNTHETIC_DIAGRAM_IDS } from '../../src/flows/flow-derive-levels'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; const ROOT = resolve(import.meta.dir, '../..'); const TMP = join(ROOT, 'tmp', 'dd-spotlight-grid'); @@ -372,7 +373,7 @@ try { if (targetCardCount === 0) fail(`No .dict-grid-card with data-entity-id="${hoverTarget}" found`); await targetCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); await shot('08-cp3-hover-spotlight.png'); // Collect actual lit ids (cards with .dict-grid-card--spotlit). @@ -419,7 +420,7 @@ try { // Move pointer off the card onto the page background. await page.mouse.move(10, 10); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); await shot('09-cp3-mouseout-cleared.png'); const litAfterMouseOut = await page.evaluate(() => @@ -622,7 +623,7 @@ try { await targetCard.scrollIntoViewIfNeeded(); await page.waitForTimeout(200); await targetCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); const litBeforeRetarget = await page.evaluate(() => document.querySelectorAll('.dict-grid-card--spotlit').length @@ -634,7 +635,7 @@ try { await dimCard.scrollIntoViewIfNeeded(); await page.waitForTimeout(200); await dimCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); await shot('17-cp3-hover-retarget-on-dim.png'); const newLitIds = await page.evaluate(() => { @@ -855,7 +856,7 @@ try { if (paymentMethodOnScreenForHover) { await paymentMethodCardForHover.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); } // Check SVG text content for "settles" predicate label. @@ -928,7 +929,7 @@ try { await connectedCard.scrollIntoViewIfNeeded(); await page.waitForTimeout(200); await connectedCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); } const fallbackTexts = await page.evaluate(() => { @@ -985,7 +986,7 @@ try { if (fallbackConnected !== null) { const fallbackConnectedCard = page.locator(`.dict-grid-card[data-entity-id="${fallbackConnected}"]`); await fallbackConnectedCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); } const anyTexts = await page.evaluate(() => { @@ -1320,7 +1321,7 @@ try { // is the connected card whose in-edge pill we want to see). const paymentCardForHover45 = page.locator(`.dict-grid-card[data-entity-id="Payment"]`); await paymentCardForHover45.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); const svgTexts5Hover = await page.evaluate(() => { const svg = document.querySelector('.spotlight-overlay'); @@ -1409,7 +1410,7 @@ try { // CP14: hover PaymentMethod (the connected card) to reveal its pill. const paymentMethodCard46 = page.locator(`.dict-grid-card[data-entity-id="PaymentMethod"]`); await paymentMethodCard46.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); const svgTexts6Hover = await page.evaluate(() => { const svg = document.querySelector('.spotlight-overlay'); @@ -3030,7 +3031,7 @@ try { // CP14: Hover the entity card to reveal its flow-line pill, then check the payload. const cp12EntityCardForHover = page.locator(`.dict-grid-card[data-entity-id="${cp12EntityId}"]`); await cp12EntityCardForHover.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); // Verify the data payload appears as SVG text in the pill. const svgTextsCp12 = await page.evaluate(() => { @@ -3661,7 +3662,7 @@ try { note(`CP14.2: Hovering connected card "${cp14ConnectedOnScreen}"`); const cp14HoverCard = page.locator(`.dict-grid-card[data-entity-id="${cp14ConnectedOnScreen}"]`); await cp14HoverCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); await shot('58-cp14-hover-connected-card.png'); const cp14HoverState = await page.evaluate(() => { @@ -3681,7 +3682,7 @@ try { note('\n── CP14.3: Mouse-out → pills disappear ──────────────────────────────────'); await page.mouse.move(10, 10); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); await shot('59-cp14-mouseout-no-pills.png'); const cp14MouseOutState = await page.evaluate(() => { @@ -3703,7 +3704,7 @@ try { // Already pinned on cp14BaseEntity. Hover the connected card again. await cp14HoverCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); // Verify the active spotlight is still cp14BaseEntity (not the hovered card). const cp14PinState = await page.evaluate((pinnedId: string) => { @@ -3793,7 +3794,7 @@ try { // Hover the target card — should reveal ≥2 pills (bundled out + in). const cp14TargetCard = page.locator(`.dict-grid-card[data-entity-id="${cp14BothPair.target}"]`); await cp14TargetCard.hover(); - await page.waitForTimeout(300); + await page.waitForTimeout(HOVER_INTENT_MS + 100); await shot('60-cp14-bundled-pills.png'); // Assert pills do not overlap by reading their SVG bounding boxes. diff --git a/test/visual/test-graph-inherited-lines.ts b/test/visual/test-graph-inherited-lines.ts index 49b040d..cef4bbf 100644 --- a/test/visual/test-graph-inherited-lines.ts +++ b/test/visual/test-graph-inherited-lines.ts @@ -21,6 +21,7 @@ import { chromium } from 'playwright'; import { resolve, join } from 'path'; import { mkdirSync } from 'fs'; +import { HOVER_INTENT_MS } from '../../src/app/logic/motion'; const ROOT = resolve(import.meta.dir, '../..'); const TMP = join(ROOT, 'tmp', 'graph-inherited-lines'); @@ -68,20 +69,27 @@ async function shot(name: string): Promise { // synthetic 'mouseover' carries `originalEvent.shiftKey` — exactly what the // GraphView handler reads. The node is LEFT hovered (no mouseout) so the rays + // 3-tier opacity persist for the screenshot and the tier readout. -async function selectAndFrame(id: string): Promise { - return await page.evaluate((nodeId: string) => { +async function selectAndFrame(id: string): Promise { + await page.evaluate((nodeId: string) => { const cy = window.__IGNATIUS_CY__; - if (!cy) return -1; + if (!cy) return; const node = cy.$(`#${nodeId}`); - if (node.empty()) return -1; + if (node.empty()) return; cy.elements().unselect(); node.select(); node.emit({ type: 'mouseover', target: node, originalEvent: { shiftKey: true } }); + }, id); + // The dotted inherited rays draw only once the hover settles (motion.ts). + await Bun.sleep(HOVER_INTENT_MS + 100); + return await page.evaluate((nodeId: string) => { + const cy = window.__IGNATIUS_CY__; + if (!cy) return [] as string[]; + const node = cy.$(`#${nodeId}`); + if (node.empty()) return [] as string[]; const inherited = cy.edges('.inherited'); // Fit the hovered node + its inherited targets into view. - const targets = node.union(inherited.connectedNodes()); - cy.fit(targets, 80); - return inherited.length; + cy.fit(node.union(inherited.connectedNodes()), 80); + return inherited.map((e: { target(): { id(): string } }) => e.target().id()); }, id); } @@ -171,7 +179,7 @@ try { await shot('00-graph-initial.png'); - const identityCount = await selectAndFrame('Identity'); + const identityCount = (await selectAndFrame('Identity')).length; await Bun.sleep(500); await shot('01-identity-selected.png'); note(`Identity inherited dotted lines: ${identityCount}`); @@ -214,7 +222,7 @@ try { : 'missing tier', ); - const itinCount = await selectAndFrame('ITIN'); + const itinCount = (await selectAndFrame('ITIN')).length; await Bun.sleep(500); await shot('02-itin-selected-transitive.png'); note(`ITIN inherited dotted lines (transitive): ${itinCount}`); @@ -239,18 +247,7 @@ try { // ── Corrected-lineage owner cases (pk-lineage-fix) ─────────────────────── // SSN now reaches the whole party-keyed sales family (party_id key chain). - const ssnTargets = await page.evaluate((nodeId: string) => { - const cy = window.__IGNATIUS_CY__; - if (!cy) return [] as string[]; - const node = cy.$(`#${nodeId}`); - if (node.empty()) return [] as string[]; - cy.elements().unselect(); - node.select(); - node.emit({ type: 'mouseover', target: node, originalEvent: { shiftKey: true } }); - const inherited = cy.edges('.inherited'); - cy.fit(node.union(inherited.connectedNodes()), 80); - return inherited.map((e: { target(): { id(): string } }) => e.target().id()); - }, 'SSN'); + const ssnTargets = await selectAndFrame('SSN'); await Bun.sleep(500); await shot('04-ssn-selected.png'); note(`SSN inherited dotted lines (${ssnTargets.length}): ${ssnTargets.join(', ')}`); @@ -263,18 +260,7 @@ try { } // SI_Line no longer over-connects to Product / Subscription / LineItemType. - const siLineTargets = await page.evaluate((nodeId: string) => { - const cy = window.__IGNATIUS_CY__; - if (!cy) return [] as string[]; - const node = cy.$(`#${nodeId}`); - if (node.empty()) return [] as string[]; - cy.elements().unselect(); - node.select(); - node.emit({ type: 'mouseover', target: node, originalEvent: { shiftKey: true } }); - const inherited = cy.edges('.inherited'); - cy.fit(node.union(inherited.connectedNodes()), 80); - return inherited.map((e: { target(): { id(): string } }) => e.target().id()); - }, 'SI_Line'); + const siLineTargets = await selectAndFrame('SI_Line'); await Bun.sleep(500); await shot('05-si-line-selected.png'); note(`SI_Line inherited dotted lines (${siLineTargets.length}): ${siLineTargets.join(', ')}`);