diff --git a/docs/guides/change-review.md b/docs/guides/change-review.md index aa910ce..ac3585e 100644 --- a/docs/guides/change-review.md +++ b/docs/guides/change-review.md @@ -57,6 +57,8 @@ Green marks an addition, amber a change. On the Graph and Flows views the mark i | Level menus | pulsing green dot after the diagram's name | pulsing amber dot | no row; listed in the Changes panel only | | Changes panel | green bar | amber bar | a solid red bar, a red ✕ before the name, and `removed` in red | +Click an entity's `added`, `changed`, or `removed` badge in the entity dialog or the Dictionary's Read lens to see why. Processes, externals, and stores in the Dictionary carry the same badge for their flow changes. A scrollable popover lists each change behind the badge and flags the ones no note covers. Below that it shows the title and body of every note that covers them. + In the flow index (**☰ Process Flows**, or `i`) and the breadcrumb level menus, a row whose diagram holds changes further down shows a steady dot after its label: red if any of them is unexplained, amber if any is a change or removal, green if all are additions. The Changes panel appears in `serve`, and read-only in an export made from a model with an approved baseline, and only when there is something to review. It lists, in order: diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 388a0a7..14e85b6 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -232,6 +232,24 @@ Every view has a `?` button in the top-right corner. It opens a short, view-awar Every dialog (entity, process, groups, legend, help, settings) keeps keyboard focus inside itself while it is open. Opening one focuses its close button. `Tab` and `Shift` + `Tab` cycle through the dialog's controls and wrap at either end, and focus that lands behind the dialog moves back into it. Escape closes the dialog and returns focus to the control that opened it. When one dialog opens another, such as a JSON value expanded from an entity dialog, only the top one responds to Tab and Escape. +Drag either side edge of a dialog to change its width; a double-click on the edge restores the default. The width is saved in localStorage. Entity, process, Groups, and the other node dialogs share one width; settings, help, legend, and JSON value dialogs each keep their own. + + +### History and tab titles + + +Every navigation is a browser history entry, so Back returns to where you were, dialog included. Navigations are switching views, jumping to a Dictionary section, opening a diagram, and opening an entity or flow-node dialog; closing a dialog and scrolling replace the current entry instead. The URL hash carries what you are looking at, so a copied URL opens the same screen: + +| Param | Example | Meaning | +|---|---|---| +| `view` | `view=dict` | `graph`, `dict`, or `flow` | +| `entity` | `entity=Business` | the open entity dialog | +| `section` | `section=process:Create-Sales-Order` | the Dictionary section in view (`entity`, `process`, `external`, `store`); updated as you scroll, so a refresh returns there | +| `dfd` | `dfd=order-to-cash/Create-Sales-Order` | the diagram in the Flows view | +| `node` | `node=proc:Collect-Payment` | the open flow-node dialog (`proc:`, `ext:`, or a store's `:`) | + +The tab title names the view and what it shows: `DG` Graph, `DD` Dictionary, `DF` Flows, then the open dialog or section or diagram, then the model's `name`. For example `DD - 1.1 Create Sales Order | Key-Inherited`. + ### Zoom diff --git a/docs/guides/flows.md b/docs/guides/flows.md index 58f1c39..cf4abeb 100644 --- a/docs/guides/flows.md +++ b/docs/guides/flows.md @@ -128,7 +128,7 @@ part of that key. The `data:` field is the flow's contract. On a `db:` endpoint it is **always column names** — a string for one column, a list for several — and every name is checked against the entity's `pk` and `columns` (the `flow.unknown_attribute` rule). On a `cluster:` endpoint it is a map from member entity to that entity's column list, checked the same way. On any other endpoint it is a phrase; make it enumerate everything the flow carries rather than a one-word summary. The `label:` is what the diagram shows on the edge chip; the contract sits one click behind it (see [Labels, stacks, clusters, and groups](#labels-stacks-clusters-and-groups)). -Bodies support the same `[[Entity]]` wiki-links as entity files, and they can also link to processes, externals, and stores by name. Links open the target's dialog in place. +Bodies support the same `[[Entity]]` wiki-links as entity files, and they can also link to processes, externals, and stores by name. A process can be named by its id, or by its id with spaces for hyphens: `[[Collect Payment]]` and `[[Collect-Payment]]` both reach `Collect-Payment.md`. Links open the target's dialog in place, except a link to a process that has a sub-DFD, which opens that diagram. That process's dialog, opened from its ⓘ badge, and its Dictionary section carry an **Open sub-diagram** link to the same place. Every process's Dictionary section also has **Show in diagram**, which opens the diagram the process is drawn in. A body may also carry an optional `## Logic` section: a Mermaid diagram of how the process handles the data it receives, named with its own inputs, outputs, and stores (see [Diagrams in the body](folder-format.md#diagrams-in-the-body)). @@ -283,7 +283,7 @@ 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 (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 serve` shows flows in the **Flows** view (`#view=flow`); an open process, external, or store dialog is `node=` (`node=proc:Collect-Payment`); 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`. diff --git a/docs/guides/folder-format.md b/docs/guides/folder-format.md index 09b0ce4..d458b5f 100644 --- a/docs/guides/folder-format.md +++ b/docs/guides/folder-format.md @@ -164,7 +164,7 @@ A customer settles invoices with a [[PaymentMethod|payment method]] on file. `[[Party]]` links to the Party entity and shows "Party". `[[PaymentMethod|payment method]]` links to PaymentMethod but shows "payment method". In the graph viewer the link opens that entity's modal; in the data dictionary it jumps to that entity's section. -The target must match an entity id exactly (case-sensitive). A link to an entity that does not exist renders as muted, non-clickable text and is reported as a `body.unknown_link` finding, so a typo never passes silently. See [Validation and findings](validation.md). +A target can be an entity, a process, an external, or a store, named by its id (case-sensitive) or by its title with spaces for hyphens (`[[Collect Payment]]`). A column's `desc` takes the same links and inline markdown (`**bold**`, `` `code` ``): `desc: "The ordering [[Party]]."`. A link to an entity opens that entity; a link to a process that owns a sub-DFD opens the diagram; another process opens its diagram with its dialog; an external or store opens its Dictionary section. A target that names nothing renders as muted, non-clickable text and is reported as a `body.unknown_link` finding, so a typo never passes silently. See [Validation and findings](validation.md). ### Code in the body @@ -202,7 +202,7 @@ flowchart TD ``` ~~~ -The viewer loads Mermaid the first time a diagram appears, so a model with no `mermaid` fence never downloads it. `ignatius export` and the in-app Export HTML include Mermaid in the file only when some body holds a diagram. Diagrams follow the light or dark theme, with nodes in the entity colors; [Themes and branding](themes-and-branding.md#mermaid-diagrams) covers the defaults and the `theme.mermaid` overrides. A diagram that fails to parse shows its source and Mermaid's error message in its place, so the body stays readable. +The viewer loads Mermaid the first time a diagram appears, so a model with no `mermaid` fence never downloads it. `ignatius export` and the in-app Export HTML include Mermaid in the file only when some body holds a diagram. Diagrams follow the light or dark theme, with nodes in the entity colors; [Themes and branding](themes-and-branding.md#mermaid-diagrams) covers the defaults and the `theme.mermaid` overrides. A diagram that fails to parse shows its source and Mermaid's error message in its place, so the body stays readable. Click a diagram to open it in a full-window dialog: the mouse wheel zooms around the pointer, dragging pans, and the **−**, **Fit**, **1:1**, and **+** buttons set the zoom. A process body is the usual home for a diagram: a `## Logic` section can show the branches, reads, and writes inside one process, named after the process's own inputs, outputs, and stores. The diagram explains the process; it never replaces a flow, store, or process in the DFD. See [`Collect-Payment.md`](../../models/key-inherited/flows/order-to-cash/Collect-Payment.md) in the `key-inherited` example. diff --git a/docs/guides/validation.md b/docs/guides/validation.md index a823c8e..2929b05 100644 --- a/docs/guides/validation.md +++ b/docs/guides/validation.md @@ -59,7 +59,7 @@ All Class A: the entity renders, flagged with a warning. | Rule ID | Class | Meaning | |---|---|---| -| `body.unknown_link` | A | A `[[wiki-link]]` in the body targets an entity that does not exist. The link renders as muted, non-clickable text. | +| `body.unknown_link` | A | A `[[wiki-link]]` in a body or column description names no entity, process, external, or store (an id, or a title whose spaces stand for hyphens). The link renders as muted, non-clickable text. | ### Edge rules diff --git a/docs/spec/model-change-review.md b/docs/spec/model-change-review.md index 2333ea3..407bc65 100644 --- a/docs/spec/model-change-review.md +++ b/docs/spec/model-change-review.md @@ -82,7 +82,7 @@ A note **claim** is unchanged when it names an entity with no item for that enti - SC5 — Editing an entity file or a change note while serving triggers the existing `model-changed` SSE event, and the refetched `diff` reflects the edit. - SC6 — `ignatius approve [path] [--model ]` writes the current parse as the baseline and deletes every file in `.ignatius/changes/`, then prints what it approved (item and note counts) and exits 0. It resolves the model with `pickModel` like the other commands. `POST /api/approve` in serve does the same and broadcasts `model-changed`. It answers 403 to a cross-origin request (`Sec-Fetch-Site` other than `same-origin`, or an `Origin` whose host differs from `Host`) and to a `Host` that is not `localhost` or an IP literal. After either, `/api/model` returns an empty `items` list. - SC7 — Graph view: nodes carry `change` data (`added` / `changed` / `removed`) and an `unexplained` flag; edges carry `change` data. Marks are glows, never an `outline-*` or a node underlay: a node glow is a blurred shape drawn on a canvas behind cytoscape's canvases, sized from the node's rendered bounds so it holds at any zoom, with the same blur and spread as the flow glow (`GLOW_BLUR`, `GLOW_SPREAD`) scaled by zoom and clipped to the outside of the node, so no tint lands inside it; nodes whose glow falls outside the viewport are not drawn; the canvas takes no pointer events and is removed with the graph. An edge glow is a thin cytoscape underlay along the line, whose line also takes the color. Green marks added, amber changed. Removed entities render as ghost nodes (dashed border, reduced opacity, muted glow, and a red ✕ badge at the top-right corner drawn on a canvas above cytoscape at full opacity, dimmed only by the hover, lineage, and search tiers, scaled with zoom within a legible range) built from `removedNodes`, and removed relationships as dashed edges with a muted glow from `removedEdges`. A column-only or doc-only change marks its entity as changed. Glows pulse from one shared animation-frame loop (a 1.8s cycle shared with the flow glow, opacity never below 0.35) that scales each glow by its element's effective opacity, so hover, lineage, and search dimming dim it too; the loop stops when the overlay is off, there are no marks, or the graph is torn down. An unexplained item's glow alternates between its kind color and red on successive pulses, with red held for most of the cycle. Under `prefers-reduced-motion: reduce` (re-read when the setting changes), or when the graph is over `ANIMATION_ELEMENT_LIMIT`, glows hold steady at the pulse midpoint, and an unexplained glow holds a blend leaning toward red. Colors read in both light and dark themes. -- SC8 — Dictionary view: an entity card with change items shows a change badge; column rows show added/changed markers; removed columns render as struck-through rows from the baseline. Removed column markers use `REMOVED_RED` and the removed badge text uses `REMOVED_TEXT` (SC10); the badge stays at full opacity while the rest of its removed card, frame included, is dimmed. Removed entities render in a "Removed since approval" section of the read lens, filtered by the Dictionary search; the browse lens shows a link to it. A body comparison ignores whether a wiki-link target exists, so adding or removing an entity never marks the bodies that link to it. +- SC8 — Dictionary view: an entity card with change items shows a change badge. In the read lens and the entity dialog the badge is a button: clicking it opens a popover, fixed to the viewport beside the badge (below it, or above when there is more room there) and scrolling within at most 360 px, that lists each review item behind the mark, flags those no note covers, and shows the title, file, and body of every note that covers them. A scroll behind it moves it with its badge and closes it once the badge leaves the window; an outside pointerdown or Escape closes it, and Escape does not reach the dialog underneath. Dictionary process, external, and store cards carry the same badge and popover, built by `flowNodeChanges` from the flow items that name the node in any diagram and, for a process, the item adding or removing the diagram it opens. Column rows show added/changed markers; removed columns render as struck-through rows from the baseline. Removed column markers use `REMOVED_RED` and the removed badge text uses `REMOVED_TEXT` (SC10); the badge stays at full opacity while the rest of its removed card, frame included, is dimmed. Removed entities render in a "Removed since approval" section of the read lens, filtered by the Dictionary search; the browse lens shows a link to it. A body comparison ignores whether a wiki-link target exists, so adding or removing an entity never marks the bodies that link to it. - SC9 — Flow view: process, external, and store nodes named by a `flow` item render with a blurred glow behind them on the diagram that owns them, green for added and amber for changed, with no stroke outline and `pointer-events: none`. On a newly added diagram, every node glows `added`, unexplained when the diagram's item is. The glow pulses through CSS keyframes on the same 1.8s cycle as the graph glow. An unexplained node stacks a red glow layer under its kind-color layer, and the two swap on successive pulses, matching the graph; the red layer never falls below half strength. Under `prefers-reduced-motion: reduce`, or when the diagram is over `ANIMATION_ELEMENT_LIMIT` (the SVG carries `data-motion="off"`), both layers hold steady and the red layer shows as a ring outside the kind color. The flow index and the breadcrumb level menus mark their rows the same way. A row is marked by the item for its process in the parent diagram, by a diagram item for the diagram it opens, or by the item adding the diagram that holds it, so every row inside a new diagram is marked `added` with that item's unexplained status. A marked index row's pill glows with the same layers, colors, keyframes, and cycle as a flow node, drawn as CSS `box-shadow`. A marked level menu row instead shows a glowing dot after its label that pulses the same way. A row whose own diagram, or any diagram below it, holds other items shows a static dot after its label: red when any of them is unexplained, else amber when any is not an addition, else green. A marked level menu row shows only its pulsing dot. Removed processes and diagrams have no row; only the Changes panel lists them. Hovering or focusing a marked index row adds a one-line summary under the side pane's title (`Changed: label, outputs · unexplained`). Reduced motion holds row glows and pulsing dots steady; the overlay toggle and a null `diff` (an export with no baseline, or a review the server could not build) remove every row mark. - SC10 — Changes panel (hidden when the diff has no items, claims, notes, or note errors): lists each note (title, rendered body, the items it covers), then unexplained items, then unchanged claims, then note errors. Clicking an entity item navigates the graph to it (ghost nodes included); clicking a flow item opens its diagram. A removed item's row reads as a warning: a solid left bar and a red-circle ✕ before the label in `REMOVED_RED` (the ghost ✕ badge's red, `--change-removed`), and the word `removed` in `REMOVED_TEXT` (`--change-removed-text`: the same red in light mode, `#f87171` in dark, where `#dc2626` text falls under 4.5:1). The panel has an overlay toggle that removes every change class and ghost element without refetching, and, in live mode only, an Approve button that calls `POST /api/approve`. It never overlaps the Findings panel. @@ -122,9 +122,12 @@ M src/app/views/graph/styles.ts steady edge glows, dimmed-tier e A src/app/views/graph/change-glow.ts glow and ghost-badge canvases, pulse loop, glow colors A src/app/components/changes/ChangesPanel.tsx notes, unexplained, claims, toggle, approve M src/app/App.tsx panel mount, toggle state, navigation -M src/app/views/dict/DictionaryView.tsx entity change badge +M src/app/views/dict/DictionaryView.tsx entity and flow-node change badges +M src/app/components/process/ProcessCard.tsx change badge +M src/app/components/flow-node/ExternalCard.tsx change badge +M src/app/components/flow-node/StoreCard.tsx change badge M src/app/components/entity/ColumnsTable.tsx column markers, removed rows -A src/app/components/changes/ChangeBadge.tsx added/changed/removed + unexplained badge +A src/app/components/changes/ChangeBadge.tsx added/changed/removed + unexplained badge, explanation popover M src/app/components/entity/EntityCard.tsx change badge M src/app/components/entity/GridCard.tsx change badge M src/app/components/entity/EntityModal.tsx change badge @@ -212,12 +215,14 @@ src/app/views/graph/change-glow.ts src/app/components/entity/ColumnsTable.tsx column markers — added/changed markers, removed rows src/app/logic/change-marks.ts - entityChanges — per-entity mark from review items + entityChanges — per-entity mark from review items, with the items behind it and the notes that cover them + flowNodeChanges — per-flow-node mark, items, and covering notes, keyed by doc token flowNodeMarks — per-node marks for one diagram path flowRowChanges — a flow index or level menu row's mark, changed fields, and worst change inside GLOW_BLUR, GLOW_SPREAD — glow blur and spread shared by graph and flow src/app/components/changes/ChangeBadge.tsx - ChangeBadge — added/changed/removed label with unexplained marker + ChangeBadge — added/changed/removed label with unexplained marker; a button opening ChangePopover when given its entity's changes + ChangePopover — the items behind the mark and the covering notes ChangeDot — static "contains changes" dot in the worst change's color, or a pulsing dot for a row's own mark src/flow-view/FlowDiagramSvg.tsx ChangeGlow — blurred kind-color layer, red layer when unexplained @@ -400,3 +405,19 @@ Built across 5 checkpoints plus one post-audit iteration of /autopilot, squashed **Deferred items still open:** - None from the review ledger. Pre-existing on base and untouched: a selection made from a panel row is lost on any SSE model refresh; `test-sse-live-reload.ts`, `test-cli-binary.ts`, and `test-large-model-nav.ts` fail or flake on base in this environment. + + +### 2026-11-03 — Change badge explains itself + + +**What changed:** SC8 adds the badge popover: the review items behind an entity's mark and the change notes that cover them. `EntityChanges` carries `items` and `notes`. + +**Why:** A dialog said "changed" with no way to see why from the dialog itself, even when a note explained it. + + +### 2026-11-04 — Flow changes in the Dictionary + + +**What changed:** Dictionary process, external, and store cards show a change badge with the explanation popover; the popover follows its badge on scroll. + +**Why:** A changed process showed no mark in the Dictionary, and a scroll right after opening the popover closed it. diff --git a/docs/spec/process-flows.md b/docs/spec/process-flows.md index 2079931..f238b72 100644 --- a/docs/spec/process-flows.md +++ b/docs/spec/process-flows.md @@ -341,7 +341,7 @@ Extension points: mode dispatch at `src/App.tsx:1067–1119`, elements construct - Position restore + drag-save reuse the `createLayoutStore` machinery under a **distinct `localStorage` key** from the ERD's `ignatius-layout-positions`. Each *rendered* diagram persists under its own fingerprint, looked up from `window.__FLOW_LAYOUT_KEYS__[diagramId]` (the injected id→fingerprint map) — so navigating between DFDs (and into sub-DFDs) keeps each one's saved layout independently. ERD and flow pools never share storage; opening flows cannot evict ERD layouts. - DFD drill-down (client-side, single HTML): a process node with `data.hasSubDfd === true` renders a small "⤵"/"+" affordance at every level of the tree. Because `window.__FLOW_MODEL__` carries the **full recursive `FlowDiagram` tree** (each process's `subDfds`), clicking the affordance swaps the rendered diagram to that process's sub-DFD entirely client-side — re-running the flow layout on the sub-diagram's elements — with a breadcrumb/back affordance to ascend. No per-sub-DFD HTML files are emitted and no navigation occurs; one `flow-.html` is self-contained and works offline. (Supersedes the earlier "href to `flow-.html`" approach, which would have required the CLI to emit a file per sub-DFD and produced dangling links when a sub-file was not generated.) - **Node documentation dialog (ⓘ badge).** Every flow node — process, external, and store — carries a small ⓘ badge (`data-ignatius="flow-info"`) that opens a documentation dialog rendering that node's markdown. The badge's `pointerdown` stops propagation, so it never starts a node drag or a sub-DFD drill — body-click keeps its drill role; the badge is a dedicated, separate affordance. Content by node kind: a **process** and an **external** render their own `bodyHtml`; a non-`db` **store** renders its `stores/.md` body from the model root; a **`db` store** borrows its entity's business narrative from the ERD model — the same content the data dictionary shows. The resolver reads the ERD model from `window.__MODEL__`: **static** mode injects it on the page (`generateFlowGraph`); **live** mode carries it in the `/api/flow` payload (`entityModel`) and the frontend assigns it to `window.__MODEL__` before building the resolver (so `db`-store docs hot-reload on entity edits). Without it the `db`-store badge would resolve to nothing — process/external badges read their own `bodyHtml` from the diagrams and are unaffected. The dialog reuses the ERD modal chrome (`.modal-backdrop` / `.modal` / `.doc-body`). A token that resolves to nothing is a no-op. Implemented as `FlowSurface` (a stateful wrapper around `FlowDiagramSvg`), `buildFlowDocResolver`, and `FlowDocModal` in `src/App.tsx`; the ⓘ badge + an `onOpenDoc(docToken)` prop in `src/flow-view/FlowDiagramSvg.tsx`. Doc tokens are canonical `proc:` / `ext:` / `:`. -- **In-dialog `[[wiki-link]]` routing.** Flow markdown (process / external / store bodies) is parsed with the same `wikiLinkPlugin` as ERD entity bodies (`md.use(wikiLinkPlugin)` in `src/flow-parse.ts`), rendered **optimistically** (no `knownIds`) so every `[[Target]]` becomes a navigable `a.entity-link[data-entity]` anchor. Clicking a link inside the dialog re-resolves the target across **both** flow nodes (processes, externals, stores) **and** ERD entities and swaps the dialog content in place — a process body can link to an entity, a store, or another process. Resolution order for a bare target: entity → process → external → store. Mirrors the wiki-link click delegation at the `src/App.tsx` ERD modal `onClick`. (Supersedes the earlier `db:`-store-click-navigates-to-the-ERD-viewer plan: docs open *in* the flow viewer's dialog, not by navigating away to `graph.html#entity-`.) +- **In-dialog `[[wiki-link]]` routing.** Flow markdown (process / external / store bodies) is parsed with the same `wikiLinkPlugin` as ERD entity bodies (`md.use(wikiLinkPlugin)` in `src/flow-parse.ts`), rendered **optimistically** (no `knownIds`) so every `[[Target]]` becomes a navigable `a.entity-link[data-entity]` anchor. Clicking a link inside the dialog re-resolves the target across **both** flow nodes (processes, externals, stores) **and** ERD entities and swaps the dialog content in place — a process body can link to an entity, a store, or another process. A link to a process that owns a sub-DFD instead closes the dialog and opens that sub-DFD. A bare target resolves as `docs/spec/wiki-entity-links.md` (Client-side resolution) states: the exact target across entity → decomposed process (its diagram) → process → external → store, then the target with spaces as hyphens (`[[Collect Payment]]` → `Collect-Payment`) across the same order. Mirrors the wiki-link click delegation at the `src/App.tsx` ERD modal `onClick`. (Supersedes the earlier `db:`-store-click-navigates-to-the-ERD-viewer plan: docs open *in* the flow viewer's dialog, not by navigating away to `graph.html#entity-`.) ## Module: `src/flow-fingerprint.ts` @@ -688,3 +688,23 @@ The fixtures use entity ids from `models/key-inherited/` as their `db:` store re **What changed:** Authored the full implementation contract for SSADM process flows. Specifies `FlowModel` types and `parseFlows` in a new `src/flow-parse.ts`; `validateFlows` + all `flow.*` rules in a new `src/flow-validate.ts` plus `RuleId` / `RULES` extensions in `src/validate.ts`; `src/flow-fingerprint.ts` (FNV-1a per-diagram topology hash); `src/generators/flow-graph.ts` and `src/generators/flow-dict.ts`; the `ignatius flow` CLI subcommand; `validate` integration; live-server routes; flow render path (`initFlowGraph`) in `App.tsx`; 8 implementation checkpoints defined (CP-4 split into 4a/4b). Derives from `docs/design/process-flows.md`; all settled decisions from the brief are encoded. + + +### 2026-11-03 — Wiki-links to decomposed processes open the sub-DFD + + +**What changed:** A bare `[[wiki-link]]` to a process that owns a sub-DFD closes the dialog and opens that diagram; a title with spaces resolves to its hyphenated id. The process dialog of a decomposed process shows an **Open diagram** link. Contract in `docs/spec/wiki-entity-links.md` (Client-side resolution). + +**Why:** Links to decomposed processes opened the process dialog where readers expected the diagram, and title-form links matched nothing. + +**Superseded:** every in-dialog link swapped the dialog content in place, resolving entity → process → external → store on the exact target. + + +### 2026-11-04 — Show in diagram on every process + + +**What changed:** Every Dictionary process section, and a process dialog opened from the Dictionary, has **Show in diagram**, which opens the diagram the process is drawn in (`inDiagramRef`). A decomposed process also has **Open sub-diagram**. A process dialog in the Flows view shows only **Open sub-diagram**. + +**Why:** Only a decomposed process had a way into the Flows view. + +**Superseded:** one **Open diagram** link, on decomposed processes only. diff --git a/docs/spec/viewer-qol.md b/docs/spec/viewer-qol.md index cfe36e8..8af5e18 100644 --- a/docs/spec/viewer-qol.md +++ b/docs/spec/viewer-qol.md @@ -26,6 +26,19 @@ Viewer fixes: markdown typography, a persisted settings store with a Settings mo - [x] While a `Modal` is the top of `modalStack`, Tab from the last focusable element moves focus to the first, Shift+Tab from the first moves to the last, and focus that lands outside the modal (click on the backdrop excluded) is pulled back to the first focusable element. A stacked modal traps; the one beneath it does not react. - [x] On unmount, focus returns to the element focused when the modal mounted, when that element is still connected. +**Navigation history and tab titles** + +- [x] Every navigation pushes one history entry: a view switch, a Dictionary section jump, a diagram change, and opening an entity or flow-node dialog. Closing a dialog, zoom, pan, and FAB settings rewrite the current entry. A push within 50 ms after a view-switch push replaces it, so switching to Flows and landing on a diagram is one entry; any other two pushes stay two entries. `src/app/logic/history.ts` (`writeHash`, `navigationBase`). +- [x] `section=:` (`entity`, `process`, `external`, `store`) names the Dictionary section in view. A jump (body or column-description link, sidebar, an entity dialog's Processes link) first writes the section at the top of the view into the entry it leaves, then pushes the new one and holds that section just below the floating search bar (the container's `scroll-padding-top`) while content above it renders and grows (`holdScroll`). Back to an entry with no section= scrolls to the top. A jump runs once: a later view switch or model reload restores the remembered scrollTop. Scrolling rewrites section= to the section at the top of the view. On load, section= wins over the remembered scrollTop unless this tab's remembered scrollTop was taken at that same section (`scrollMemoryCovers`), so a refresh returns to the exact position and a shared link to the section's top. +- [x] `node=` (`proc:`, `ext:`, `:`) names the open flow-node dialog, in the Flows view or over the Dictionary. Opening pushes it, closing drops it in place, and a link from the dialog to an entity or diagram, or its Open sub-diagram link, pushes a new entry without it, so Back reopens the dialog. A rebuild of the Flows renderer reopens the URL's node= on the new surface. A deep link opens the dialog once its view has mounted. +- [x] A navigation away from a dialog (an entity dialog's Processes link, in the Dictionary or as a process dialog in Flows) leaves `entity=` on the entry it came from, so Back reopens the dialog. +- [x] The tab title is ` - | `: DG Graph, DD Dictionary, DF Flows; the subject is the open entity or node dialog, else the Dictionary section (an entity id, a process's number and label, an external's label, a store's name), else the Flows diagram (its process's number and label); with no subject, ` | `. `` is `ignatius.yml` `name`, then `branding.title`, then `Ignatius`. +- [x] `test/checks/test-history-navigation.ts` covers each rule. + +**Dialog width** + +- [x] `Modal` wraps `.modal` in `.modal-frame`, which carries the width and a drag handle on each side edge. The frame is centred, so the width changes by twice the drag, between 360 px and 96vw. On release the width is saved in localStorage under `ignatius-modal-width:`, or `ignatius-modal-width:content` for dialogs with no `className` (entity, process, flow doc, edge contract, stack, groups), which therefore share one width. A double-click on a handle restores the default and clears the key. Variant widths sit on the frame: `.modal-frame:has(> .settings-modal)` 520 px, `.modal-frame:has(> .modal-json)` 860 px. Releasing a drag over the backdrop does not close the dialog. `test/checks/test-mermaid-zoom-modal-resize.ts` covers the drag, save, reopen, and reset. + **Markdown typography** - [x] Every markdown site listed in the design (`App.tsx` groups modal, `DictionaryView` group desc, externals, stores, `EntityModal`, `EntityCard`, `FlowNodeModal`, `FlowDocModal`, `StackDialog`, `ChangesPanel`, `ProcessCard`) carries class `md` beside its existing wrapper class. @@ -86,6 +99,8 @@ Viewer fixes: markdown typography, a persisted settings store with a Settings mo - [x] `generateApp` inlines `mermaid.js` (escaped like the app JS) before the app script only when a `mermaid-diagram` div occurs in any entity, group, flow node, cluster or change-note body. `test/checks/test-export-mermaid.ts` asserts presence for a model with a fence and absence for one without. - [x] Mermaid SVG spans its container (`width: 100%`), centred, scaled down when wider, and inherits the `.md` block spacing. - [x] Screenshots: a flowchart and a sequence diagram in a process body, dark and light, plus one with a `theme.mermaid` override (`test/visual/screenshot-mermaid.ts`). +- [x] `holdScroll` stops on the reader's input or a scroll it did not make, except a clamp: content shrinking for a moment pulls scrollTop to the new bottom, and the hold carries on. +- [x] Clicking a rendered diagram, on a page or inside a dialog, opens `MermaidZoom`: a stacked dialog portaled to `document.body` holding a copy of the svg under a fresh render id (`cloneDiagramMarkup`), drawn at its viewBox size and fitted to the viewport (at most 2×). The wheel zooms around the pointer between 10% and 1200%, a drag pans, and −, Fit, 1:1, and + buttons set the zoom. The click listener is on `document` in the capture phase because `.modal-frame` stops click propagation. Escape closes only the zoom dialog. `test/checks/test-mermaid-zoom-modal-resize.ts`. - [x] `useScrollMemory` keeps re-applying the saved position as content grows after a restore. It holds until the user scrolls (wheel, touch, keydown or pointerdown on the container), a scroll it did not make moves the container (a deep link), or about 2 s pass; reaching the position early does not end the hold, because diagrams above it keep growing. No intermediate clamped value is saved. `test-scroll-memory.ts` adds a case with Mermaid diagrams above the saved position: reload, and assert it lands within 4 px. The case is proven by a mutation that removes the continuous restore. **Export as seen** @@ -225,6 +240,14 @@ docs/spec/help-overlay.md ........................... M (help button corner) docs/spec/ignatius-project-config.md ................ M docs/wiki/{feature-map,validate}.md ................. M skills/ignatius-modeling/references/{dfd-authoring,flow-templates,conventions,entity-flow,verification}.md M +src/app/logic/history.ts ............................ A (writeHash push/replace, navigationBase) +src/app/hash-router.ts .............................. M (section=, node=, splitSection) +src/app/hooks/useHashRoute.ts ....................... M (pushing setView, openSection/replaceSection, openNode/closeNode/leaveNode, leaveEntity) +src/app/hooks/useScrollMemory.ts .................... M (holdScroll, shouldRestore) +src/app/App.tsx ..................................... M (tabTitle, section and node reconciles) +src/app/views/dict/DictionaryView.tsx ............... M (section hold, scroll reporting, node dialog reporting) +src/app/views/flow/FlowsView.tsx .................... M (FlowDialogControls, node dialog reporting, queued token) +test/checks/test-history-navigation.ts .............. A ``` @@ -334,12 +357,29 @@ skills/ignatius-modeling/references/conventions.md Code fences — mermaid renders as a diagram skills/ignatius-modeling/references/entity-flow.md E9 narrative — logic flow of an application function against the table + +src/app/logic/history.ts + writeHash — writes the hash as a pushed or replaced entry; a push right after a view-switch push joins it + navigationBase — the current hash without entity=, node=, section= + +src/app/hooks/useScrollMemory.ts + holdScroll — keeps a target scrollTop as content grows, until the reader scrolls or 2 s pass + +src/app/App.tsx + tabTitle — ` - | ` ``` ## Flows +**Back to a dialog from the screen it led to** + +1. Reader opens Business in the Graph → `openEntity` pushes `entity=Business`. +2. Reader clicks Processes → 1.1 Create Sales Order → `openSection` pushes `view=dict§ion=process:Create-Sales-Order`; the Dictionary holds the section at the top as diagrams render. +3. Reader presses Back → popstate reconciles `entity=Business` and `view=graph` → the Business dialog reopens. + + **Tab inside a modal** 1. User opens an entity modal → `Modal` mounts, records `document.activeElement`, focuses close button. @@ -586,3 +626,27 @@ Built across 19 iterations of /autopilot (8 checkpoints, a post-audit iteration) **Deferred items still open:** - `viewer-qol-f-1` (`.claude/project/followups/viewer-qol-f-1.md`): bundle may ship React dev build and StrictMode double effects. + + +### 2026-11-03 — Resizable dialogs and zoomable diagrams + + +**What changed:** Added the Dialog width criteria (`.modal-frame` side handles, saved width) and the `MermaidZoom` criterion under Mermaid. + +**Why:** Dialogs were fixed at 720 px and diagrams could not be enlarged. + + +### 2026-11-04 — Navigation history and tab titles + + +**What changed:** Added the Navigation history and tab titles criteria: pushed entries for every navigation, `section=` and `node=` hash params, the section hold, and per-view tab titles. + +**Why:** Navigations replaced the current entry, Back could not return to a dialog or section, a jump into the Dictionary landed above its target while diagrams rendered, and every tab had the same title. + + +### 2026-11-04 — Correction: history rules as built + + +**What changed:** **Correction:** the audit of the first build (probes against the running app) showed the body described rules the code did not keep. The body now states them: only a push after a view switch joins it; a jump runs once and writes the section being left; a refresh restores the exact position when this tab's memory matches section=; Open sub-diagram and the Flows Processes link keep the dialog's entry; holds ride out clamps. The change tree, outline, and flows list the files and pieces. + +**Why:** A stale section target sent every view switch back to the last jump, and two dialog-leaving paths lost their entries. diff --git a/docs/spec/viewer-ux-polish.md b/docs/spec/viewer-ux-polish.md index ec674d2..e6e0b8b 100644 --- a/docs/spec/viewer-ux-polish.md +++ b/docs/spec/viewer-ux-polish.md @@ -23,7 +23,7 @@ See `docs/design/viewer-ux-polish.md`. ## Success criteria -- [x] #1 — A served/exported model named `Foo` produces `document.title` `Foo` (live tab and static-export HTML ``); a nameless model falls back to `Ignatius`. A check asserts the static-export `<title>`. +- [x] #1 — A served/exported model named `Foo` produces the static-export HTML `<title>` `Foo`; a nameless model falls back to `Ignatius`. A check asserts the static-export `<title>`. In the running app the tab title follows `docs/spec/viewer-qol.md` (Navigation history and tab titles): `DG | Foo`, `DD - Party | Foo`. - [x] #2 — When a spotlit DD card has a bidirectional (`both`) or multi-edge connection to another card, the overlay draws ≥2 distinct `<path>` elements with distinct connection points (no shared endpoint) — not one path with arrowheads at both ends. A unit test on the splitting/geometry helper proves separation; a visual screenshot confirms. - [x] #3 — The readout shows true scale: at Cytoscape `zoom===1` / SVG `scale===1` it reads `100%`, regardless of model size. The initial view fits and reports its real (non-100%) percentage on a large model. Home/reset fits-to-screen. `setPercent(100)` yields 1:1. On the Graph, every fit (initial view, relayout, Home) uses the band between the bottom of the branding block, search bar and toggles and the top of the footer, 30 px in from each side, so no table sits under them (`test-graph-fit-clear.ts`). A graph too big for that band at the smallest zoom shows its top-left corner there instead of its middle, and a viewport with no room left under the chrome fits the whole canvas. - [x] #4 — Trackpad pinch (`ctrl`+wheel) and Cmd/Ctrl `+`/`-`/`0` zoom the canvas and never the browser page, on both DG and DFD. A unit test covers the resolver's new zoom actions; the page does not zoom (`preventDefault`). @@ -186,3 +186,13 @@ Tests: [`test/checks/test-spotlight-inherited.ts`](../../test/checks/test-spotli **Merged into main (fast-forward) as fdb1317 — 2026-06-19.** Ships in the next minor (0.12.0) per the version policy — seven `feat(viewer)` commits. <!-- appended per checkpoint --> + + +### 2026-11-04 — Tab titles name the view + + +**What changed:** #1 keeps the model name as the export's `<title>`; the running app's tab title now names the view and what it shows (`docs/spec/viewer-qol.md`). + +**Why:** Every tab and history entry read the same, so neither told you which screen it was. + +**Superseded:** `document.title` was the model name alone. diff --git a/docs/spec/wiki-entity-links.md b/docs/spec/wiki-entity-links.md index 4dd5ef8..b9702e5 100644 --- a/docs/spec/wiki-entity-links.md +++ b/docs/spec/wiki-entity-links.md @@ -10,15 +10,20 @@ Derived from `docs/design/wiki-entity-links.md`. - `splitWikiTarget(inner: string): { target: string; label: string }` — splits on the first `|`; both sides trimmed; an empty label falls back to the target. - `wikiLinkPlugin(md): void` — registers an inline rule `before('link', 'wikilink', …)` so code spans (tokenised by the earlier `backticks` rule) and fenced code stay literal. - `WikiLinkEnv = { knownIds?: Set<string>; links?: string[] }` — render-time context passed via `md.render(src, env)`. +- `targetCandidates(target): string[]` — the ids a target may name, in order: the target, then the target with each run of whitespace replaced by `-` (a file name is its title with hyphens). +- `canonicalTarget(target, knownIds): string | null` — the first candidate in `knownIds`, or null. +- `linkStateNeutral(html): string` — rewrites every live anchor and missing span as a live anchor on its hyphenated target, so change review compares two renders of one body equal whichever targets exist. +`parseModels` passes `knownIds` = the entity ids plus the flow node ids, which are the file names under `flows/**`, `externals/`, and `stores/` (index files excluded). A link that resolves to a flow node renders live and is left out of `bodyLinks`, so `body.unknown_link` (title "Unknown link target", message ``[[<target>]] names no entity, process, external, or store.``) flags only targets nothing names. + Rule behaviour, on a `[[…]]` span with no nested `[`, `]`, or newline and a non-empty target: -- Pushes the target onto `env.links` (when present). -- `missing = env.knownIds ? !env.knownIds.has(target) : false` (absent `knownIds` → optimistic, never missing). +- Resolves the target with `canonicalTarget(target, knownIds)`: the target itself when it is a known id, else the target with each run of whitespace replaced by `-` when that is a known id, else null. Absent `knownIds` → optimistic: the target, never missing. +- Pushes the resolved id (the raw target when unresolved) onto `env.links` (when present). - Emits a single `html_inline` token: - - valid → `<a class="entity-link" data-entity="<target>" href="#entity-<target>"><label></a>` + - resolved → `<a class="entity-link" data-entity="<resolved id>" href="#entity-<resolved id>"><label></a>` - missing → `<span class="entity-link entity-link--missing" title="Unknown entity: <target>"><label></span>` - `target` and `label` are escaped via `md.utils.escapeHtml`. @@ -47,7 +52,8 @@ markdown-it 14 ships no types in this repo; the rule types the state/instance me ## Graph surface: `src/App.tsx` -- The modal `.doc-body` div gets an `onClick` delegation handler: narrow `e.target` with `instanceof Element`, `closest('a[data-entity]')`, `preventDefault()`, then `onNavigate(id)` when the id resolves to a node — reusing the FK-link navigation path. No casts. +- The modal `.doc-body` div gets an `onClick` delegation handler: narrow `e.target` with `instanceof Element`, `closest('a[data-entity]')`, `preventDefault()`, then `onNavigate(id)` when the id is an entity or a flow node — reusing the FK-link navigation path. No casts. +- A link to a flow node leaves the entity dialog, and Back reopens it (`followFlowLink` in `App.tsx`). A decomposed process opens its sub-DFD. Another process opens the diagram it is drawn in with its dialog, as one history entry; in the Flows view its dialog opens over the diagram on screen. An external or store opens its Dictionary section, or in the Flows view its dialog. ## Styling @@ -62,6 +68,21 @@ markdown-it 14 ships no types in this repo; the rule types the state/instance me `body.unknown_link` is an `EntityError` and rides the existing finding surfaces unchanged: CLI stderr (Class A, not live-only → appears), dict findings panel + per-entity ⚠ disclosure, graph findings panel + modal Issues section. +## Column descriptions + + +A column's `desc` renders as inline markdown (`md.renderInline`) into `ColumnDef.descHtml`, with the same `env` as its entity's body, so its `[[links]]` resolve against the same ids, feed the same `bodyLinks`, and raise the same `body.unknown_link` finding. `desc` stays the raw text that search and change review read. `ColumnsTable` renders `descHtml` and routes a link click like a body link: in the Dictionary through the section and diagram rules below, in an entity dialog through the entity-dialog rules above. + + +## Client-side resolution + + +Bodies rendered with entity-only `knownIds` (entities) or none (flow bodies) are resolved again in the viewer, once every node id is known. + +- `upgradeMissingLinksInContainer(container, allKnownIds)` (`src/app/dom/body-links.ts`) points each live anchor's `data-entity` at `canonicalTarget(raw, allKnownIds)` when that differs from the raw target, and replaces each missing span whose target resolves with a live anchor on the resolved id. +- `buildFlowDocResolver` (`src/app/logic/doc-resolver.ts`) tries each `targetCandidates` id for a bare token, in the order entity, decomposed process, process, external, store. A process that owns a sub-DFD resolves to `{ kind: 'diagram', diagramRef }`, the `dfd=` reference `diagramRef` (`src/flow-view/flow-nav.ts`) builds for that sub-DFD; the Flows view and the Dictionary open that diagram. A `proc:` token resolves to the process node, carrying `inDiagramRef` (the diagram it is drawn in) and, when the process is decomposed, `diagramRef`. Its Dictionary section shows **Show in diagram** and **Open sub-diagram** for them; a process dialog in Flows shows only **Open sub-diagram**. + + ## Tests @@ -77,3 +98,42 @@ markdown-it 14 ships no types in this repo; the rule types the state/instance me **What changed:** Added `[[Entity]]` / `[[Entity|label]]` body links. New `src/wikilink.ts` inline rule; deferred body render in `parse.ts` with `ModelNode.bodyLinks`; `body.unknown_link` validator rule; graph click delegation; dict native-anchor nav; `.entity-link` styles in both surfaces. Dogfooded into all four sample models (`broken-demo` carries one intentional broken link). + + +### 2026-11-03 — Titles resolve to ids; links to decomposed processes open the sub-DFD + + +**What changed:** A target resolves through `canonicalTarget`, so `[[Answer Turn]]` names the process `Answer-Turn` (a file name is its title with hyphens). The renderer, the client upgrade pass `upgradeMissingLinksInContainer` (which now also rewrites live anchors to the resolved id), and the flow doc resolver all apply it. In the flow doc resolver, a bare token naming a process that owns a sub-DFD resolves to `{ kind: 'diagram', diagramRef }`, and the Flows view and Dictionary open that diagram; a `proc:` token from a node's ⓘ badge still opens the process dialog, which links to the diagram. + +**Why:** Process tables written with titles (`[[Answer Turn]]`) rendered links that went nowhere, and a link to a decomposed process opened its dialog instead of its diagram. + +**Superseded:** a target resolved only on an exact id match. + + +### 2026-11-04 — Links in column descriptions + + +**What changed:** Column `desc` renders as inline markdown with `[[links]]` into `descHtml`. + +**Why:** A link written in a column description rendered as literal brackets. + + +### 2026-11-04 — Links to flow nodes are known at parse time + + +**What changed:** `parseModels` adds process, external, and store ids (file names) to `knownIds`, and drops links that reached them from `bodyLinks`. The rule is titled "Unknown link target" and its message names the target and what it failed to match. + +**Why:** An entity body or column description linking a process raised a false `body.unknown_link` warning, worded as if only entities could be linked. + +**Superseded:** `knownIds` held entity ids only; flow-node links were upgraded client-side and warned on. + + +### 2026-11-04 — Entity dialogs follow links to flow nodes + + +**What changed:** An entity dialog in the Graph or Flows view follows a link to a process, external, or store (body or column description) as described under Graph surface; Back reopens the dialog. + +**Why:** Once flow-node links rendered live, clicking one in an entity dialog did nothing. + +**Superseded:** an entity dialog followed links to entities only. + diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index 636eb01..757e1b2 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -22,7 +22,7 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Alternate keys (AK): cardinality derivation, dict/graph key-cell marker, `ak_unknown_column` validation | derive-classification (cardinality) | derive-classification, schema-lint-and-error-ux | derivation | entity-flow E4 | | CLI subcommands (`serve` SPA + `export` unified static + `validate`) — `dict`/`graph`/`flow` removed | cli-and-outputs, unified-app | cli-and-outputs, unified-app | commands, building-from-source, getting-started | verification (runs `ignatius validate`) | | CLI version + self-update (`version`/`--version`, `update`) | — | update-download-progress | commands, getting-started | — | -| Model change review (`.ignatius/` baseline + change notes; graph/Dictionary/Flow highlights; Changes panel; `approve` + `POST /api/approve`; static export carries the review) | model-change-review | model-change-review | change-review, commands, folder-format, modeling-skill | SKILL core rules, change-notes, model-flow M8, verification, dfd-authoring F0, discover-flow, reverse-engineering R0 | +| Model change review (`.ignatius/` baseline + change notes; graph/Dictionary/Flow highlights; Changes panel; entity `ChangeBadge` popover listing the items and covering notes, on Dictionary process/external/store cards too (`flowNodeChanges`); `approve` + `POST /api/approve`; static export carries the review) | model-change-review | model-change-review | change-review, commands, folder-format, modeling-skill | SKILL core rules, change-notes, model-flow M8, verification, dfd-authoring F0, discover-flow, reverse-engineering R0 | | Project config + model discovery (`ignatius.yml`) | ignatius-project-config | ignatius-project-config | getting-started, folder-format | entity-flow E0, model-flow M1–M8, templates | | Themes | cli-and-outputs | cli-and-outputs | themes-and-branding | model-flow M4 | | Branding | branding | branding | themes-and-branding | model-flow M5 | @@ -36,7 +36,7 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Graph arrangement (hierarchical layout one block per colour group — per-group ELK `layered`, `orderRegions` + skyline `packRegions` in [`grouped-layered.ts`](../../src/app/views/graph/grouped-layered.ts); arrangement + hidden set carry over to a new layout key via `layout-store` `previous`, new nodes seated beside neighbours; undo/redo history of position + hidden snapshots (`graph-edits.ts`, `Cmd`/`Ctrl`+`Z`, `Cmd`/`Ctrl`+`Shift`+`Z`, `Ctrl`+`Y`); select mode `s` + Shift+drag box select, group drag; hide by right-click or `h`, a whole group by `Shift`+`H` or the Groups modal Hide/Show (`GraphViewHandle.hideGroups`/`showGroups`), `GraphEditBar` "N hidden" list, layouts exclude hidden nodes) | graph-arrangement | graph-arrangement, keyboard-nav-shortcuts, help-overlay, viewer-qol | commands | — | | Graph tree layout (third layout mode, `L` steps organic → hierarchical → tree; outline where each table sits below and right of its key-inheritance parent, deepest parent wins, hubs and classifiers never fallback parents; lookups in a staircase above the tree they serve; every line leaves its parent's bottom edge and enters its child's left side — one trunk per parent with a branch into each child, `cross` links via a street under the parent and an approach column that moves past boxes, lines into a wrapped column sharing one drop with labels on their branches, runs sized for the longer predicate so the reverse one shown on hover fits; a first visit opens in Tree, a browser with a saved arrangement and no stored mode keeps Organic; referenced trees placed first; re-routed on drag; a wide parent's children wrap into columns to its right; [`tree-layout.ts`](../../src/app/views/graph/tree-layout.ts)) | graph-tree-layout | graph-tree-layout, keyboard-nav-shortcuts, help-overlay | commands | — | | Business-narrative body + existence/cascade rules | markdown-driven-erd | ignatius-modeling-skill | modeling-skill | entity-flow E9, templates (body sections) | -| Entity body wiki-links `[[Entity]]` | wiki-entity-links | wiki-entity-links, schema-lint-and-error-ux | folder-format | entity-flow E9 (body authoring) | +| Entity body wiki-links `[[Entity]]` (`canonicalTarget`: a title with spaces resolves to its hyphenated id; in flow bodies and Dictionary bodies a bare link to a process that owns a sub-DFD opens that diagram, and its dialog and Dictionary section carry **Open sub-diagram**; every Dictionary process has **Show in diagram**; column `desc` renders inline markdown with links into `descHtml`) | wiki-entity-links | wiki-entity-links, schema-lint-and-error-ux | folder-format, flows | entity-flow E9 (body authoring) | | The modeling skill itself | ignatius-modeling-skill | ignatius-modeling-skill | modeling-skill | SKILL + all references | | Skill `flow` + `discover` modes (DFD authoring; Socratic business→model discovery, five gates, generates entities + flows; reverse-engineering from existing DB/code/schema in the IDEF1X spirit) | noorm-flow-discovery | noorm-flow-discovery | modeling-skill, flows | SKILL (4-mode router), dfd-authoring, flow-templates, discover-flow, reverse-engineering | | Example / sample instance tables | example-instance-tables | example-instance-tables | folder-format (example rows) | entity-flow E7b (`examples:` frontmatter) + templates | @@ -47,7 +47,8 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | DFD polish round 2: no text-select on nodes (CP14); store/external coloring by `kind` (theme-aware, `theme.flowKinds` override) (CP15); per-process in/out data example tables (CP16) | — | dfd-polish-round2, process-flows (examples) | themes-and-branding (kind colors) | — | | DFD edge-hover data reveal (styled fixed-position tooltip lists full data items passed across an edge, source → target header; legible at any zoom; replaces native `<title>`; gated `db:` column lists no longer need a click) | dfd-edge-hover-data | dfd-edge-hover-data | flows | — | | Arbitrary DFD nesting depth (dotted process numbers preserve the full ancestor chain at any depth — renumberLeaf recurses + prefixes the full relative number; skill F8 authors decompositions recursively, no depth cap) | dfd-nesting-depth | dfd-nesting-depth | flows | dfd-authoring (F8) | -| HTML title from model name (#1; `document.title` = `model._meta.name` else `Ignatius`; SPA runtime effect in `App.tsx` for live + static; `generateApp` rewrites `<title>` with HTML-escaped name on export) | viewer-ux-polish | viewer-ux-polish | — | — | +| HTML title from model name (#1; `generateApp` rewrites the export's `<title>` with the HTML-escaped name) and per-view tab titles (`DG`, `DD`, or `DF`, then `- <subject>`, then the model name; computed in `App.tsx` from the open dialog, Dictionary section, or Flows diagram) | viewer-ux-polish | viewer-ux-polish, viewer-qol | commands (History and tab titles) | — | +| Navigation history (`src/app/logic/history.ts` `writeHash` push/replace with 50 ms coalescing; `useHashRoute` `openSection`/`replaceSection`/`openNode`/`closeNode`/`leaveNode`; `section=` held in view by `holdScroll` and rewritten on scroll; `node=` for flow-node dialogs in Flows and the Dictionary; check `test-history-navigation.ts`) | viewer-qol | viewer-qol | commands (History and tab titles), flows | — | | Entity-modal history + URL sync (#6/#8; `entity=<id>` in the hash is the single source of truth for "which modal is open"; `useHashRoute` owns the history lifecycle — `openEntity` pushState/dedup, `closeEntity` replaceState-drop, `onEntityChange` popstate reconcile; shell `App.tsx` is the single writer — graph tap / dict click / FK hop / flow `db:` store / findings-panel row all route through it; GraphView no longer writes `entity=`, only viewport zoom/pan with flush-time entity re-merge; Back steps the modal stack, close clears the URL) | viewer-ux-polish | viewer-ux-polish | — | — | | Zoom 100% = native 1:1 (#3; `100%` = 1 diagram unit → 1 CSS px, model-size-independent; initial view + Home still fit-to-screen but the readout shows the true percent; the Graph fits between the header chrome and the footer (`fitGraph`); pure [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) helper maps `internalScale × fitScale`; DFD viewBox stays = world box so drag/minimap/pan untouched; graph readout = `cy.zoom()*100`) | viewer-ux-polish | viewer-ux-polish | — | — | | Pinch + keyboard zoom → canvas (#4; trackpad pinch = `ctrl`/`meta`+wheel and `Cmd`/`Ctrl` +/-/0 zoom the active canvas, never the browser page, on DG + DFD; native non-passive `wheel` listener on each canvas `preventDefault`s page-zoom while the canvas's own handler still zooms — React `onWheel` is passive so its preventDefault is a no-op; resolver `shortcuts.ts` adds `zoomIn`/`zoomOut`/`zoomReset` actions resolved before the bare-key guards, gated on ctrl/meta only, bypassing the editable guard; `useKeyboardShortcuts` routes to the active view handle, dict no-op) | viewer-ux-polish | viewer-ux-polish, keyboard-nav-shortcuts | commands | — | @@ -62,10 +63,10 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Markdown typography (shared `md` class on every markdown site; one `.md` block in [`styles.css`](../../src/app/styles.css) owns line height, paragraph/list/heading rhythm, `pre`, `blockquote`, `hr`, `img`, tables) | viewer-qol | viewer-qol | — | — | | Settings (FAB "Settings" → `SettingsModal`: titled sections of bordered cards. Appearance holds Theme, a Light/Dark segmented control over native radios calling `useThemeMode.setTheme`, stored in `ignatius-theme`; the header has no theme button. Content holds "Always open examples", a `role="switch"` checkbox that opens every example `<details>`, kept by the module store `useSettings` over localStorage `ignatius-settings`, read with `useSyncExternalStore` so every React root shares it) | viewer-qol | viewer-qol | commands (Settings) | — | | Dictionary sidebar tab + scroll memory (right-edge `.dict-nav-tab` while the sidebar is closed, resting as a lip that slides out on hover or focus, close button in the panel, open state in `ignatius-dict-nav-open`; `useScrollMemory` keeps Dictionary and sidebar `scrollTop` in sessionStorage across SSE reload, view switch, and refresh, holding the restore while Mermaid diagrams grow; a process deep link still wins) | viewer-qol | viewer-qol | commands (Dictionary sidebar) | — | -| Mermaid logic flows (`mermaid` fences in every markdown body → `div.mermaid-diagram`, rendered by a lazily loaded `/static/mermaid.js` sidecar, inlined in an export only when a body has a diagram; bold look from `resolveMermaidStyle` (`themeVariables` + `themeCSS`: entity-colour fills cycled per node, black outlines, shadows, dotted panel), `theme.mermaid` style keys `stroke_width`/`radius`/`shadow`/`node_fills` plus `dark`/`light` variable overrides; every value reaching the diagram stylesheet is checked (`SAFE_MERMAID_VALUE`, ranges, hex) and reported as `config.mermaid_theme_invalid`; worked example in [`Collect-Payment.md`](../../models/key-inherited/flows/order-to-cash/Collect-Payment.md)) | viewer-qol | viewer-qol, ignatius-project-config | folder-format (diagrams in the body), themes-and-branding (Mermaid diagrams), validation (config rules) | dfd-authoring F7 (`## Logic`), flow-templates (process template + worked example), conventions (code in bodies), entity-flow E9, verification (rule table) | +| Mermaid logic flows (`mermaid` fences in every markdown body → `div.mermaid-diagram`, rendered by a lazily loaded `/static/mermaid.js` sidecar, inlined in an export only when a body has a diagram; bold look from `resolveMermaidStyle` (`themeVariables` + `themeCSS`: entity-colour fills cycled per node, black outlines, shadows, dotted panel), `theme.mermaid` style keys `stroke_width`/`radius`/`shadow`/`node_fills` plus `dark`/`light` variable overrides; every value reaching the diagram stylesheet is checked (`SAFE_MERMAID_VALUE`, ranges, hex) and reported as `config.mermaid_theme_invalid`; worked example in [`Collect-Payment.md`](../../models/key-inherited/flows/order-to-cash/Collect-Payment.md); a click opens `MermaidZoom`, a dialog with wheel zoom, drag pan, and Fit/1:1 buttons) | viewer-qol | viewer-qol, ignatius-project-config | folder-format (diagrams in the body), themes-and-branding (Mermaid diagrams), validation (config rules) | dfd-authoring F7 (`## Logic`), flow-templates (process template + worked example), conventions (code in bodies), entity-flow E9, verification (rule table) | | Export as seen (FAB "Export HTML" in live mode → `POST /api/export` with `collectViewState()`; `exportModel` + `scopeViewState` keep this model's layout entries; `generateApp` emits `__VIEW_STATE__`; `apply-view-state.ts` applies it once per export and model, merging layout stores and clearing omitted preference keys) | viewer-qol | viewer-qol | commands (Export HTML) | — | -| Modal focus trap (`Modal` sets `role="dialog"`, `aria-modal`, `aria-labelledby`; Tab and Shift+Tab wrap inside the top of `modalStack`, stray focus is pulled back, focus returns to the opener on close) | viewer-qol | viewer-qol | commands (Dialogs) | — | +| Modal focus trap (`Modal` sets `role="dialog"`, `aria-modal`, `aria-labelledby`; Tab and Shift+Tab wrap inside the top of `modalStack`, stray focus is pulled back, focus returns to the opener on close; `.modal-frame` side handles drag the width, saved in localStorage `ignatius-modal-width:<className>`, or `:content` for the entity, process, Groups, and other node dialogs, which share one width) | viewer-qol | viewer-qol | commands (Dialogs) | — | -◆ **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). +◆ **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; a link to a process that owns a sub-DFD opens that diagram instead. [`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). **Example instance tables — implemented and reconciled.** Structured `examples:` frontmatter → `ModelNode.examples` (`src/parse.ts`) → dict/graph accordions + the `entity.example_unknown_column` validator rule (live-server-only — `ignatius validate` never prints it, so the skill self-checks example keys at authoring time). The skill authors `examples:` frontmatter (entity-flow E7b, templates); older entities may still carry a prose `## Sample rows` body section, which the skill reads when seeding flow examples but no longer emits. diff --git a/docs/wiki/frontend.md b/docs/wiki/frontend.md index ac21bbb..9d40caa 100644 --- a/docs/wiki/frontend.md +++ b/docs/wiki/frontend.md @@ -8,7 +8,7 @@ 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 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=`. +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 `view=`, `entity=`, `node=`, and `section=` in the URL hash, writing through `src/app/logic/history.ts`; `dfd=`, `flowview=`/`collapse=`, and `zoom=`/`pan=` are written by the surface that owns them, through the same `writeHash`, 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=`. 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. @@ -51,6 +51,7 @@ flowchart TD resolve -->|entity| openEntity["onOpenEntity(id) — app-level SelectedEntityModal"] resolve -->|node| FlowNodeModal resolve -->|doc / null| FlowDocModal + resolve -->|diagram| openDiagram["onOpenDiagram(ref) — selectDiagramById"] kind -->|"onOpenContract(edges, ...)"| EdgeContractDialog kind -->|"onOpenStack(node, ...)"| StackDialog StackDialog -->|"member row click, onOpenEntity"| resolve @@ -219,13 +220,13 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur | 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 (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`). **Change review:** destructures `diff`/`refreshDiff` from `useModelData`; owns `changeOverlay` (default on) and `changesCollapsed`; derives `shownChanges`/`shownEntityChanges`; passes `changeReview={shownChanges}` to `GraphView`, `DictionaryView`, and `FlowsView`, and `changes={shownEntityChanges?.get(selected.id)}` to `EntityModal`; hosts the parked-target refs and `handleChangeEntity`/`handleChangeDiagram` (see How it works). `ChangesPanel` and `FindingsPanel` render inside one `.side-panel-stack` div, the Changes panel only when `diff` is non-null. **Viewer QoL:** `appRef` on the `.app` root feeds `useMermaid`; `showSettings` hosts `SettingsModal` (passed `themeMode` and `onSetTheme={setTheme}`); `fabMenuRef` (`FabMenuHandle`) backs the copy-link shortcut; `handleResetLayout` is shared by the FAB and `r`; the Groups modal cards render a Hide/Show `.group-card-toggle` per group, chosen by `groupHidden(name)` (every member in `graphEdits.hidden`) and calling `graphViewRef.current?.hideGroups([name])`/`showGroups([name])`; `FabMenu` gets `onShowSettings` and, outside static mode, `onExportHtml={downloadExport}`; `dictNavOpen` initializes from and persists to `ignatius-dict-nav-open`, and `DictionaryView` gets `active={view === 'dict'}`. | +| [`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 `sectionTarget` (the Dictionary `section=` to hold in view) and `nodeDialog` (the open flow-node dialog), and `tabTitle()` (the per-view tab title). `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`). **Change review:** destructures `diff`/`refreshDiff` from `useModelData`; owns `changeOverlay` (default on) and `changesCollapsed`; derives `shownChanges`/`shownEntityChanges`; passes `changeReview={shownChanges}` to `GraphView`, `DictionaryView`, and `FlowsView`, and `changes={shownEntityChanges?.get(selected.id)}` to `EntityModal`; hosts the parked-target refs and `handleChangeEntity`/`handleChangeDiagram` (see How it works). `ChangesPanel` and `FindingsPanel` render inside one `.side-panel-stack` div, the Changes panel only when `diff` is non-null. **Viewer QoL:** `appRef` on the `.app` root feeds `useMermaid`; `showSettings` hosts `SettingsModal` (passed `themeMode` and `onSetTheme={setTheme}`); `fabMenuRef` (`FabMenuHandle`) backs the copy-link shortcut; `handleResetLayout` is shared by the FAB and `r`; the Groups modal cards render a Hide/Show `.group-card-toggle` per group, chosen by `groupHidden(name)` (every member in `graphEdits.hidden`) and calling `graphViewRef.current?.hideGroups([name])`/`showGroups([name])`; `FabMenu` gets `onShowSettings` and, outside static mode, `onExportHtml={downloadExport}`; `dictNavOpen` initializes from and persists to `ignatius-dict-nav-open`, and `DictionaryView` gets `active={view === 'dict'}`. | | [`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 top-row search bars can clear it, since the z-50 branding block otherwise paints over them. [`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 - max(0px, (100vw - 1100px) / 2)))` and the matching `padding-right` past the help toggle (`--toggle-region` + 12px). The Graph `.viewer-search-bar--graph` holds its left edge right of the gutter with a `translateX(max(-50%, …))` clamp and caps its width short of the toggles and its count readout; at 1100px and below the Graph and Flows bars compact. At 768px and below the Dictionary and Graph bars take their own row under the branding. `.side-panel-stack` starts below the top-row bar in every view (`--search-bar-top`, `--flow-search-bar-top`), and above 768px `.flow-crumbs` starts right of the gutter and wraps short of the toggles. `:root` names the clearances: `--toggle-region` (52px, the help toggle's span from the right edge; the header has no theme button, theme lives in Settings → Appearance), `--below-branding` (68px, the row under the branding) and `--dict-bar-clearance` (76px; 136px at 768px and below); `.side-panel-stack` sets `--panel-top` per breakpoint. `.branding-subtitle` is capped at 240px like the title (200px), so the block stays one row tall; both caps are `min(…, 100vw - 194px)` so on a phone the block stops short of the toggles. Checks: `test-dict-search-branding-overlap.ts`, `test-viewer-search-branding-overlap.ts`. | | [`src/app/main.tsx`](../../src/app/main.tsx) | React root mount, reads `window.__MODEL__`/`__THEME_MODE__` globals in static mode. Its first import is `./apply-view-state`. | | [`src/app/apply-view-state.ts`](../../src/app/apply-view-state.ts) | Side-effect module: in static mode calls `applyViewState(window, localStorage, location)` (see How it works). | | [`src/app/mermaid-entry.ts`](../../src/app/mermaid-entry.ts) | Entry for the separate Mermaid bundle: imports `mermaid` and assigns it to `window.__IGNATIUS_MERMAID__`. | -| [`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=<graph\|dict\|flow>&entity=<id>&zoom=<n>&pan=<x>,<y>&dfd=<diagram-ref>&flowview=<per-process\|connected>&collapse=<stores\|clusters\|groups>`. `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/hash-router.ts`](../../src/app/hash-router.ts) | Exports `parseHash`/`serializeHash`, `ViewName`, `FlowViewMode`, `FlowCollapseLevel`, `HashState`, `nextCollapseLevel`. `HashState` carries `view?: 'graph'\|'dict'\|'flow'`, `entity?`, `section?` (`<kind>:<id>`, split by `splitSection`), `node?` (a doc token), `zoom?`, `pan?`, `dfd?`, `flowview?: 'per-process'\|'connected'`, `collapse?: 'stores'\|'clusters'\|'groups'`. Format: `#view=<graph\|dict\|flow>&entity=<id>&zoom=<n>&pan=<x>,<y>&dfd=<diagram-ref>&flowview=<per-process\|connected>&collapse=<stores\|clusters\|groups>`. `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__`, `__CHANGE_REVIEW__` (the export's baked-in `ChangeReview`; absent without a baseline), `__VIEW_STATE__` (the export's `ViewState`: localStorage entries plus hash), `__IGNATIUS_MERMAID__` (the `IgnatiusMermaid` slice of the Mermaid API: `initialize`, `render`), `__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. | @@ -234,10 +235,10 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur | Path | Role | |---|---| | [`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__`/`__CHANGE_REVIEW__` 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, diff, refreshDiff, layoutKeyRef, bannerDismissed, setBannerDismissed }`. `diff: ChangeReview | null` comes from the `/api/model` payload's `diff` field and stays `null` in an export with no baseline or when the server could not build a review; `refreshDiff()` refetches `/api/model` and replaces `diff` only. `ChangeReview` is imported type-only, because [`src/model/change-review.ts`](../../src/model/change-review.ts) imports `flow-markdown` at runtime and that pulls shiki into the bundle. `doModelFetch`/`doFlowFetch` are module-level and throw on a non-OK response; the boot and SSE refetches wrap the flow fetch in `fetchFlowOrNull`, so a failed `/api/flow` logs and leaves the model update applied instead of discarding both. `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/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) | Exports `useHashRoute(opts?)`. Owns `popstate` back/forward restoration and the screen params, writing through [`src/app/logic/history.ts`](../../src/app/logic/history.ts) `writeHash` (push or replace; a push right after a view-switch push joins it) and `navigationBase` (the hash without `entity=`/`node=`/`section=`). `setView` pushes a view switch; `openEntity`/`openNode`/`openSection` push, `closeEntity`/`closeNode`/`replaceSection` replace, and `leaveEntity`/`leaveNode` forget a dialog the next navigation leaves behind. `popstate` invokes `onEntityChange`, `onNodeChange`, and `onSectionChange` with the reconciled value (never pushing), plus `onRestoreDfd`, `onRestoreFlowView`, and `onRestoreCollapseLevel`. Returns `{ view, setView, openEntity, closeEntity, openNode, closeNode, leaveNode, leaveEntity, openSection, replaceSection }`. Contract: [`docs/spec/viewer-qol.md`](../spec/viewer-qol.md) (Navigation history and tab titles). | | [`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, setTheme }`; `setTheme(mode)` is called by `SettingsModal`'s Appearance theme radio group. | | [`src/app/hooks/useSettings.ts`](../../src/app/hooks/useSettings.ts) | Exports `useSettings(): [Settings, update]`. A module-level store behind `useSyncExternalStore`, read from `ignatius-settings` at module load, shared by every React root; `update(patch)` merges, writes localStorage, and notifies subscribers. | -| [`src/app/hooks/useScrollMemory.ts`](../../src/app/hooks/useScrollMemory.ts) | Exports `useScrollMemory(ref, key, deps)`. Saves the element's `scrollTop` to sessionStorage under `key` and restores it when `deps` change, re-applying it as content grows (`ResizeObserver` on the children) until the user scrolls, other code moves the view, or `HOLD_MS` (2000) passes. Scroll events a restore caused never save. Used by `DictionaryView` for the page and the sidebar. | +| [`src/app/hooks/useScrollMemory.ts`](../../src/app/hooks/useScrollMemory.ts) | Exports `holdScroll(el, target, onApply?)` (keeps a target scrollTop as content grows; rides out a clamp to the bottom) and `useScrollMemory(ref, key, deps, shouldRestore?)`, which restores through it. Saves the element's `scrollTop` to sessionStorage under `key` and restores it when `deps` change, re-applying it as content grows (`ResizeObserver` on the children) until the user scrolls, other code moves the view, or `HOLD_MS` (2000) passes. Scroll events a restore caused never save. Used by `DictionaryView` for the page and the sidebar. | | [`src/app/hooks/useMermaid.ts`](../../src/app/hooks/useMermaid.ts) | Exports `useMermaid(rootRef, model, themeMode)`; memoizes `resolveMermaidStyle(model.theme, themeMode)`, renders `.mermaid-diagram` placeholders under the root as they appear or become visible and re-renders on a style-key change (see How it works). | | [`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`. Graph-only `s` (select mode), `h` (hide selection), and `Cmd`/`Ctrl`+`Z` / `Cmd`/`Ctrl`+`Shift`+`Z` / `Ctrl`+`Y` (undo/redo, not while typing) route through `onToggleSelectMode`, `onHideSelection`, `onUndo`, `onRedo`. Newer callbacks: `onHideSelectionGroups` (Shift+H), `onPrevLayout` (Shift+L, graph), `onToggleFlowView` (L/Shift+L, flow), `onResetLayout` (`r`), `onCopyLink` (Cmd/Ctrl+C, wired to `fabMenuRef.current?.copyLink()`, and left to the browser while a text selection is non-collapsed so native copy still works). | @@ -250,7 +251,7 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur | [`src/app/logic/storage-keys.ts`](../../src/app/logic/storage-keys.ts) | Exports `GRAPH_LAYOUT_STORAGE_KEY` (`ignatius-layout-positions`) and `FLOW_LAYOUT_STORAGE_KEY` (`ignatius-flow-layout-positions`), shared by `layout-store`, `FlowsView`, and `view-state.ts`. | | [`src/app/logic/view-state.ts`](../../src/app/logic/view-state.ts) | Exports `collectViewState`, `applyViewState`, and `downloadExport` (see How it works). | | [`src/app/logic/mermaid-render.ts`](../../src/app/logic/mermaid-render.ts) | Exports `loadMermaid`, `renderMermaidIn(root, style)`, and `rerenderAll(root, style)`, where `style` is a `MermaidStyle` (see How it works). `mermaid.initialize` gets `securityLevel: 'strict'`, `theme: 'base'`, `look: 'classic'`, `flowchart.wrappingWidth: 200` (Mermaid's 120px default splits short bold labels mid-word), `sequence.actorFontWeight: 600` (actor weight is inline, out of `themeCSS`'s reach), then the spread `themeVariables` and `themeCSS`. Private `CANVAS_INSET`/`fitPanel` size the rendered svg to the full-width panel. | -| [`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/doc-resolver.ts`](../../src/app/logic/doc-resolver.ts) | Exports `buildFlowDocResolver(diagrams, getEntityModel)` and `splitDocToken(token)`. `FlowDocResult` discriminated union: `entity`/`node`/`doc`/`diagram`; a bare token naming a process that owns a sub-DFD resolves to `diagram` with its `diagramRef`, and a decomposed process's `node` result carries the same `diagramRef`. Bare tokens try `targetCandidates` (the target, then spaces as hyphens). 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<string>` merging all process/external/non-db-store ids with ERD entity ids. | | [`src/app/logic/color.ts`](../../src/app/logic/color.ts) | Exports `hexToRgba` and `blendHex`. | | [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts) | Pure reduction of a `ChangeReview` to render marks. Exports `ChangeMark` (`{ change: ChangeKind; unexplained: boolean }`), `EntityChanges`, `addMark(marks, id, change, unexplained)`, `entityChanges(review)` (per-entity map; skips `relationship` and `flow` items), and `flowNodeMarks(review, diagram, diagramPath)` (per-diagram map keyed by base token), `ChangeSeverity` (`added` | `changed` | `unexplained`), and `flowRowChanges(review, rowKey, opensOwnDiagram)` (a flow index or level-menu row's own mark, its changed fields, and the worst change inside the diagram it opens; read by the flow-view domain's `FlowIndex.tsx` and `LevelMenu.tsx`). `GraphView` also uses `addMark` for relationship marks, keyed `<source>-<target>`. | @@ -267,7 +268,7 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur | Path | Role | |---|---| -| [`src/app/dom/body-links.ts`](../../src/app/dom/body-links.ts) | Exports `resolveBodyClick(e, scrollFn)` (shared body-click handler for entity/process/external/store DD body divs — intercepts `a[data-entity]` and `.entity-link--missing` spans) and `upgradeMissingLinksInContainer(container, knownIds)` (rewrites `.entity-link--missing` spans to live `<a>` anchors once the target id is known). | +| [`src/app/dom/body-links.ts`](../../src/app/dom/body-links.ts) | Exports `resolveBodyClick(e, scrollFn)` (shared body-click handler for entity/process/external/store DD body divs — intercepts `a[data-entity]` and `.entity-link--missing` spans) and `upgradeMissingLinksInContainer(container, knownIds)` (points live anchors at `canonicalTarget`'s id and rewrites `.entity-link--missing` spans to live `<a>` anchors once the target id is known). | | [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts) | Exports `applyThemeCssVars(theme, mode)` and mode-aware color constants including `SPOTLIGHT_LINE_INHERITED`. Sets all CSS custom properties on `document.documentElement`, including spotlight vars `--spotlight-line-out`, `--spotlight-line-in`, `--spotlight-line-flow`, `--spotlight-line-inherited`. Called by `useThemeMode`. Also exports `CHANGE_COLORS` (per-mode `added`/`changed`/`unexplained` hex values, read by `buildStyles`) and sets `--change-added`, `--change-changed`, `--change-unexplained`. Also exports `REMOVED_RED` (`--change-removed`, both modes) and `REMOVED_TEXT` (`--change-removed-text`, lightened in dark mode for text contrast). | ### UI components (`components/ui/`) @@ -354,7 +355,7 @@ Change-review checks: [`test/checks/test-change-review-graph.ts`](../../test/che | `views/graph/grouped-layered.ts` | Hierarchical layout by colour group: `layoutByGroup` runs ELK `layered` per group region of a `buildScratchCore(cy, true)` mirror, `orderRegions` puts referenced groups first, `packRegions` skyline-packs the blocks. | | `views/graph/tree-layout.ts` | Tree layout ([`docs/spec/graph-tree-layout.md`](../spec/graph-tree-layout.md)): `buildForest` picks each table's tree parent (deepest key-inheritance parent; hubs and classifiers never fallback parents) and assigns each lookup to one consumer; `layoutTree` places trees depth first with lookups in a staircase above each tree, orders trees so referenced ones come first, packs them into columns, and returns a route per edge (`TreeRoute`: `down` on the parent's shared trunk (`trunkDx`), or `cross` lane/street/approach with `runLabel` when lines into a wrapped column share a drop, or `slot.jog` when a line from above drops through a clear column and crosses above its child to get past boxes, plus `entryDy`). Every line leaves its parent's bottom and enters the child's left side. `routeGeometry` gives a line's pinned ends and bends for the current boxes, drawing a `down` route as a cross link when its child no longer sits below and right of its trunk. GraphView applies it as `segments` bypasses and re-routes on node moves. | | `views/graph/graph-edits.ts` | `createEditHistory` (undo/redo over `GraphSnapshot` = positions + hidden + `LayoutMode`), `GraphEditState`, `USER_HIDDEN_CLASS`, and the `LayoutMode` type `GraphView` re-exports. `nextLayoutMode` / `parseLayoutMode` / `LAYOUT_MODE_NAME` drive the `L` cycle and the stored mode; `loadLayoutMode(stored, hasSavedArrangement)` picks the mode on load. | -| `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. Takes `active` (whether the Dictionary is the current view). Two `useScrollMemory` calls keep the page (`ignatius-scroll-dict`) and the sidebar (`ignatius-scroll-dict-nav`) scroll positions, re-restored when `active`/`dictNavOpen` or the model changes. The sidebar is `inert` while closed, has a "Contents" header with a close button, and shows an "Open sidebar" tab when closed, which rests as a 14px lip (accent edge plus a dot grip, `--dict-nav-tab-lip`) and slides fully out on hover or focus; the icon and label sit in a `.dict-nav-tab-content` span; focus moves between tab and close button only when one of them toggled it. 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. **Change review:** props `changeReview` and `entityChanges`; passes `change={entityChanges?.get(n.id)?.mark}` to each `GridCard` and `changes=` to each `EntityCard`. `removedNodes` (from `changeReview.removedNodes`, filtered by the active search) feeds a "N removed since approval" link in the browse lens, whose `showRemoved` switches to the read lens inside `flushSync` and scrolls to `#dict-removed`, and a read-lens "Removed since approval" section of `EntityCard`s built from `changeReview.removedEdges`. | +| `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); openFlowNode(token); dismissFlowNode(); topSection(); }`; its links and sidebar call `onJumpToSection`, it holds `sectionTarget` in view once per `seq`, and reports the section at the top as the reader scrolls (`onSectionInView`). 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. Takes `active` (whether the Dictionary is the current view). Two `useScrollMemory` calls keep the page (`ignatius-scroll-dict`) and the sidebar (`ignatius-scroll-dict-nav`) scroll positions, re-restored when `active`/`dictNavOpen` or the model changes. The sidebar is `inert` while closed, has a "Contents" header with a close button, and shows an "Open sidebar" tab when closed, which rests as a 14px lip (accent edge plus a dot grip, `--dict-nav-tab-lip`) and slides fully out on hover or focus; the icon and label sit in a `.dict-nav-tab-content` span; focus moves between tab and close button only when one of them toggled it. 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`, `scrollToProcess`) ask the shell for a `section=` jump; `scrollToMissing` scrolls with `scrollBehaviorWithin(el)`. Process, external, and store cards carry change badges from `flowNodeChanges`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. **Change review:** props `changeReview` and `entityChanges`; passes `change={entityChanges?.get(n.id)?.mark}` to each `GridCard` and `changes=` to each `EntityCard`. `removedNodes` (from `changeReview.removedNodes`, filtered by the active search) feeds a "N removed since approval" link in the browse lens, whose `showRemoved` switches to the read lens inside `flushSync` and scrolls to `#dict-removed`, and a read-lens "Removed since approval" section of `EntityCard`s built from `changeReview.removedEdges`. | | `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. **Change review:** `FlowsViewProps.changeReview` seeds `initFlowGraphCore`'s `initialChangeReview` parameter; later changes arrive through `FlowChromeCallbacks.onRegisterChangeReview` (the same register-a-setter pattern as `onRegisterSearchTokens`), so a toggle re-renders the current diagram without re-mounting. Each render computes `diagramPath` from every id in the breadcrumb stack (synthetic levels included, matching how change items key diagrams) and passes `changeMarks: flowNodeMarks(review, diagram, diagramPath)` to `FlowDiagramSvg`. `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). | diff --git a/docs/wiki/parser.md b/docs/wiki/parser.md index a337164..9fc7bbf 100644 --- a/docs/wiki/parser.md +++ b/docs/wiki/parser.md @@ -38,12 +38,12 @@ flowchart TD ```mermaid flowchart LR - Pass1["pass 1: scan data/**/*.md → RawNode.body (raw), collect all entity ids"] --> KnownIds["knownIds: Set<string>"] + Pass1["pass 1: scan data/**/*.md → RawNode.body (raw), collect all entity ids"] --> KnownIds["knownIds: entity ids + flow node file names"] KnownIds --> Pass2["pass 2: md.render(rawNode.body, env) per node"] - Pass2 --> Known{"target in knownIds?"} - Known -->|yes| Link["a.entity-link href=#entity-Target"] - Known -->|no| Missing["span.entity-link--missing"] - Pass2 --> BodyLinks["env.links → ModelNode.bodyLinks"] + Pass2 --> Known{"canonicalTarget(target, knownIds)?"} + Known -->|id| Link["a.entity-link href=#entity-Id"] + Known -->|null| Missing["span.entity-link--missing"] + Pass2 --> BodyLinks["env.links minus flow-node hits → ModelNode.bodyLinks; column desc → descHtml"] ``` `wikiLinkPlugin` ([`src/model/wikilink.ts`](../../src/model/wikilink.ts)) is a markdown-it inline rule registered before the `link` rule, so `[[…]]` is recognized ahead of standard link syntax. It never raises an error itself: an unresolved target renders as a non-navigating span, and is reported separately by `validate.ts`'s `body.unknown_link` rule. @@ -143,7 +143,7 @@ A dangling edge (unknown target, or an FK column absent from the child's PK) is | Path | Exports | Role | |------|---------|------| | [`src/model/parse.ts`](../../src/model/parse.ts) | `parseModels(dir): Promise<ParseResult>`, `normalizePredicate()`, `ModelNode`, `ModelEdge`, `Model`, `ParseResult`, `ModelMeta`, `Predicate`, `ColumnDef`, `SubtypeCluster`, `GroupConfig`, `Cardinality`, `ThemeConfig`, `Branding`, `HarnessMode` | Reads `ignatius.yml` (`_meta`, `theme:`, `branding:`) and `data/**/*.md` + `groups/*.md`; owns all derivation logic and the shared `MarkdownIt` instance | -| [`src/model/wikilink.ts`](../../src/model/wikilink.ts) | `WikiLinkEnv`, `splitWikiTarget()`, `wikiLinkPlugin(md)`, `linkStateNeutral()` | markdown-it inline rule for `[[Target]]` / `[[Target\|label]]`; `linkStateNeutral(html)` rewrites missing-link spans as live anchors so two renders of one body compare equal whichever targets exist; registered onto `parse.ts`'s `md` via `md.use(wikiLinkPlugin)`. Types markdown-it's state/instance via minimal local interfaces (`InlineToken`, `InlineState`, `MarkdownItLike`) rather than casting to `any`, because markdown-it 14 ships no types in this repo. | +| [`src/model/wikilink.ts`](../../src/model/wikilink.ts) | `WikiLinkEnv`, `splitWikiTarget()`, `targetCandidates()`, `canonicalTarget()`, `wikiLinkPlugin(md)`, `linkStateNeutral()` | markdown-it inline rule for `[[Target]]` / `[[Target\|label]]`; a target resolves to itself or, failing that, to itself with spaces as hyphens (`canonicalTarget`); `linkStateNeutral(html)` rewrites every link as a live anchor on its hyphenated target so two renders of one body compare equal whichever targets exist; registered onto `parse.ts`'s `md` via `md.use(wikiLinkPlugin)`. Types markdown-it's state/instance via minimal local interfaces (`InlineToken`, `InlineState`, `MarkdownItLike`) rather than casting to `any`, because markdown-it 14 ships no types in this repo. | | [`src/model/markdown-mermaid.ts`](../../src/model/markdown-mermaid.ts) | `mermaidFencePlugin(md)` | markdown-it plugin turning `mermaid` fences into `.mermaid-diagram` placeholders; types markdown-it through minimal local interfaces (`FenceToken`, `MarkdownItLike`) | | [`src/model/model-index.ts`](../../src/model/model-index.ts) | `buildModelIndex(model): ModelIndex`, `endpointKey(source, target)`, `ModelIndex` | Pure, no Bun/Node/DOM imports; browser-safe. Builds `nodeById`, `edgesBySource`/`edgesByTarget`/`edgeByEndpointPair`, `pkByNode`/`columnsByNode`, `akColumnsByNode`/`fkColumnsByNode`, subtype-cluster maps, `nodesByGroup` | | [`src/model/change-review.ts`](../../src/model/change-review.ts) | `diffSnapshots()`, `parseChangeNote()`, `crossCheck()`, `ModelSnapshot`, `ChangeKind`, `FlowNodeKind`, `ChangeItem`, `ChangeNote`, `ChangeNoteError`, `ReviewedItem`, `ChangeClaim`, `ChangeReview` | Diff of two `ModelSnapshot`s and the note cross-check. Imports `md` and `parseFrontmatter` from [`src/flows/flow-markdown.ts`](../../src/flows/flow-markdown.ts) at runtime | diff --git a/skills/ignatius-modeling/references/verification.md b/skills/ignatius-modeling/references/verification.md index 466209f..979d13c 100644 --- a/skills/ignatius-modeling/references/verification.md +++ b/skills/ignatius-modeling/references/verification.md @@ -35,7 +35,7 @@ Parse stderr. Format: `<sev> <ruleId> <location> <message>` (two spaces betwe | `entity.missing_columns` | warn | A | No columns defined | Add `columns:` map with at least the PK column types | | `entity.invalid_field_type` | warn | A | Invalid field shape | `pk` must be an array of strings, `columns` must be a map — fix the field shape | | `entity.unknown_group` | warn | A | Unknown group | Create `groups/<name>.md` or correct the `group:` value | -| `body.unknown_link` | warn | A | Body `[[wiki-link]]` target not in model | Fix the entity id inside `[[…]]` or author the target entity (only `[[…]]` links are checked — author body links as wiki-links, never `[X](X.md)`) | +| `body.unknown_link` | warn | A | Body or column-description `[[wiki-link]]` names no entity, process, external, or store | Fix the id inside `[[…]]` or author the target (only `[[…]]` links are checked — author body links as wiki-links, never `[X](X.md)`) | | `edge.unknown_target` | error | B | Edge target not in model | Add the missing entity file or correct the `target:` name | | `edge.dangling_fk_column` | warn | A | FK column not on source entity | Add the column to the entity's `columns` map or fix the `on:` mapping | | `cluster.missing_basetype` | error | B | Subtype cluster basetype not in model | Add the basetype entity file or fix the basetype name | diff --git a/src/app/App.tsx b/src/app/App.tsx index 79790be..9d6e604 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,8 +1,12 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { semanticColors, resolveFlowKindPalette, type FlowKindKey, type FlowKindEntry } from '../theme/theme-defaults'; -import { parseHash, serializeHash, nextCollapseLevel } from './hash-router'; -import type { HashState, FlowViewMode, FlowCollapseLevel } from './hash-router'; +import { parseHash, nextCollapseLevel, splitSection } from './hash-router'; +import { findDiagramByRef } from '../flow-view/flow-nav'; +import { currentHash, writeHash } from './logic/history'; +import { buildFlowDocResolver, nodeToken } from './logic/doc-resolver'; +import type { FlowDiagram, FlowProcess } from '../flows/flow-parse'; +import type { HashState, FlowViewMode, FlowCollapseLevel, ViewName } from './hash-router'; import { RULES } from '../model/validate'; import type { EntityError } from '../model/validate'; import type { @@ -17,6 +21,7 @@ import { hexToRgba } from './logic/color'; import { buildAllFlowNodeIds } from './logic/flow-node-ids'; import { entityMatches, searchFlowDiagrams } from './logic/search'; import { Modal } from './components/ui/Modal'; +import { MermaidZoom } from './components/ui/MermaidZoom'; import { HelpModal } from './components/ui/HelpModal'; import { SettingsModal } from './components/ui/SettingsModal'; import { downloadExport } from './logic/view-state'; @@ -35,7 +40,7 @@ import { entityChanges } from './logic/change-marks'; import { LegendModal } from './views/flow/LegendModal'; import { FlowsView } from './views/flow/FlowsView'; import type { FlowsViewHandle } from './views/flow/FlowsView'; -import { DictionaryView } from './views/dict/DictionaryView'; +import { DictionaryView, scrollMemoryCovers } from './views/dict/DictionaryView'; import type { DictionaryViewHandle } from './views/dict/DictionaryView'; import { GraphView } from './views/graph/GraphView'; import type { GraphViewHandle, LayoutMode } from './views/graph/GraphView'; @@ -45,7 +50,47 @@ import { useHashRoute } from './hooks/useHashRoute'; import { useThemeMode } from './hooks/useThemeMode'; import { useMermaid } from './hooks/useMermaid'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; -import { scrollBehaviorWithin } from './logic/motion'; + +const VIEW_ABBREVIATIONS: Record<ViewName, string> = { graph: 'DG', dict: 'DD', flow: 'DF' }; + +function processTitle(process: FlowProcess): string { + return `${process.dottedNumber} ${process.label}`.trim(); +} + +function sectionTitle(section: string, diagrams: FlowDiagram[] | null): string { + const parts = splitSection(section); + if (!parts) return section; + const { kind, id } = parts; + if (kind === 'entity') return id.replace(/_/g, ' '); + for (const diagram of walkDiagrams(diagrams ?? [])) { + if (kind === 'process') { + const process = diagram.processes.find(p => p.id === id); + if (process) return processTitle(process); + } else if (kind === 'external') { + const external = diagram.externals.find(e => e.id === id); + if (external) return external.label; + } else { + const store = diagram.storeRefs.find(ref => ref.name === id); + if (store) return store.displayName; + } + } + return id.replace(/-/g, ' '); +} + +function diagramTitle(ref: string, diagrams: FlowDiagram[] | null): string { + const path = findDiagramByRef(diagrams ?? [], ref); + const diagram = path?.[path.length - 1]; + if (!path || !diagram) return ref; + const process = path[path.length - 2]?.processes.find(p => p.id === diagram.id); + return process ? processTitle(process) : diagram.title; +} + +function* walkDiagrams(diagrams: readonly FlowDiagram[]): Generator<FlowDiagram> { + for (const diagram of diagrams) { + yield diagram; + yield* walkDiagrams(diagram.subDfds); + } +} export function App() { const graphRef = useRef<HTMLDivElement>(null); @@ -92,8 +137,23 @@ export function App() { const graphSearchBarRef = useRef<SearchBarHandle>(null); const flowSearchBarRef = useRef<SearchBarHandle>(null); - const { view, setView, openEntity, closeEntity } = useHashRoute({ - onRestoreDfd: (dfdId) => flowsViewRef.current?.selectDiagramById(dfdId), + // The Dictionary section to bring into view; seq re-requests the same section. + const [sectionTarget, setSectionTarget] = useState<{ section: string; seq: number } | null>(() => { + const section = parseHash(location.hash).section; + return section && !scrollMemoryCovers(section) ? { section, seq: 0 } : null; + }); + const [nodeDialog, setNodeDialog] = useState<{ token: string; title: string } | null>(null); + // A node= the view could not open yet (a deep link, or Back onto a view that is still mounting). + const pendingNodeRef = useRef<string | null>(parseHash(location.hash).node ?? null); + const linkedNodeRef = useRef<string | null>(null); + const [activeDfd, setActiveDfd] = useState<string | null>(null); + const activeDfdRef = useRef<string | null>(null); + + const { view, setView, openEntity, closeEntity, openNode, closeNode, leaveNode, leaveEntity, openSection, replaceSection } = useHashRoute({ + // Re-selecting the diagram on screen would re-render it and drop the dialog Back is reopening. + onRestoreDfd: (dfdId) => { + if (dfdId !== activeDfdRef.current) flowsViewRef.current?.selectDiagramById(dfdId); + }, // popstate reconcile: open/switch/close the modal to MATCH the hash. Sets // React state ONLY — never pushes history (we are responding to Back/Forward). onEntityChange: (id) => { @@ -106,6 +166,7 @@ export function App() { if (node) { setSelected(node); setShowEntityModal(true); + setEntityModalOpenedFromFlow(parseHash(location.hash).view === 'flow'); } }, // popstate reconcile for a pushed entry (e.g. an entity open) that @@ -113,8 +174,86 @@ export function App() { // collapseLevel state below. React state only, same rule as onEntityChange. onRestoreFlowView: (v) => setFlowView(v), onRestoreCollapseLevel: (l) => setCollapseLevel(l), + onSectionChange: (section) => { + if (section) setSectionTarget(prev => ({ section, seq: (prev?.seq ?? 0) + 1 })); + // An entry with no section= is the Dictionary never scrolled from its top. + else if (parseHash(location.hash).view === 'dict') requestAnimationFrame(() => dictViewRef.current?.scrollToTop()); + document.title = tabTitle(); + }, + onNodeChange: (token) => { + flowsViewRef.current?.dismissFlowDialog(); + dictViewRef.current?.dismissFlowNode(); + setNodeDialog(null); + pendingNodeRef.current = token; + if (token) requestAnimationFrame(openPendingNode); + }, }); + // A link from an entity dialog to a flow node leaves the dialog, and Back reopens it. Routing per target kind: + // docs/spec/wiki-entity-links.md (Graph surface). + function followFlowLink(id: string) { + const result = flowDiagrams ? buildFlowDocResolver(flowDiagrams, model ?? undefined)(id) : null; + if (!result || result.kind === 'entity' || result.kind === 'doc') return; + setShowEntityModal(false); + setEntityModalOpenedFromFlow(false); + leaveEntity(); + if (result.kind === 'diagram') { + handleChangeDiagram(result.diagramRef); + return; + } + const { node, inDiagramRef } = result; + const token = nodeToken(node); + if (viewRef.current === 'flow' && flowsViewRef.current) { + flowsViewRef.current.openFlowToken(token); + return; + } + if (inDiagramRef !== undefined) { + pendingNodeRef.current = token; + linkedNodeRef.current = token; + handleChangeDiagram(inDiagramRef); + return; + } + jumpToSection('displayName' in node ? `store:${node.name}` : `external:${node.id}`); + } + + function jumpToSection(section: string) { + // The entry being left records where the reader was, so Back returns there. + const leaving = viewRef.current === 'dict' ? dictViewRef.current?.topSection() : null; + if (leaving) replaceSection(leaving); + openSection(section); + setSectionTarget(prev => ({ section, seq: (prev?.seq ?? 0) + 1 })); + } + + // Scrolling sets the title directly: a re-render of the Dictionary rewrites every body's HTML, + // which swaps rendered diagrams back to placeholders. + function handleSectionInView(section: string) { + replaceSection(section); + document.title = tabTitle(); + } + + function handleNodeDialogChange(dialog: { token: string; title: string } | null, reason: 'opened' | 'closed' | 'left') { + setNodeDialog(dialog); + // A dialog a link navigated to belongs to that navigation's entry, so Back steps over both at once. + const joinsLink = dialog !== null && linkedNodeRef.current === dialog.token; + linkedNodeRef.current = null; + if (dialog) openNode(dialog.token, joinsLink ? 'replace' : 'push'); + else if (reason === 'closed') closeNode(); + else leaveNode(); + } + + // Opens a node= that arrived before its view could show it; the view reports it back through handleNodeDialogChange. + function openPendingNode() { + const token = pendingNodeRef.current; + if (!token) return; + const current = viewRef.current; + if (current === 'flow' && flowsViewRef.current) flowsViewRef.current.openFlowToken(token); + else if (current === 'dict' && dictViewRef.current) dictViewRef.current.openFlowNode(token); + else return; + pendingNodeRef.current = null; + } + const viewRef = useRef(view); + viewRef.current = view; + const { themeMode, setTheme } = useThemeMode(model?.theme, model); const appRef = useRef<HTMLDivElement>(null); useMermaid(appRef, model, themeMode); @@ -162,9 +301,6 @@ export function App() { // stays mounted-but-inactive across view switches). const [flowSearchTerm, setFlowSearchTerm] = useState(''); const [flowSearchIncludeBody, setFlowSearchIncludeBody] = useState(false); - // Pending scroll target: set by onNavigateToProcess, consumed by the view-switch - // useEffect once the dict container is visible and the process anchor exists. - const pendingScrollProcessIdRef = useRef<string | null>(null); // Pan-free entity opener: selects the node and shows the rich SelectedEntityModal // WITHOUT panning the graph. Used by the flow viewer's db: store ⓘ badge so the // rich dialog opens even when the ERD graph is not mounted (flow surface). @@ -237,10 +373,7 @@ export function App() { // (view/entity/zoom/pan/dfd) without adding a history entry — mirrors how // useHashRoute writes `view` on every change. function writeFlowViewHash(partial: Pick<HashState, 'flowview' | 'collapse'>) { - const current = parseHash(location.hash); - const next: HashState = { ...current, ...partial }; - const serialized = serializeHash(next); - history.replaceState({}, '', serialized ? '#' + serialized : location.pathname); + writeHash({ ...currentHash(), ...partial }, 'replace'); } function handleToggleFlowView() { @@ -257,22 +390,10 @@ export function App() { writeFlowViewHash({ collapse: next }); } - // After a view switch to dict, execute any pending process-scroll that was set - // by onNavigateToProcess. rAF is not enough — React may not have painted the dict - // container yet when rAF fires. Running inside a committed useEffect guarantees - // the DOM is up to date before we attempt scrollIntoView. + // A Dictionary node= deep link opens once the Dictionary has mounted. useEffect(() => { - if (view !== 'dict') return; - const processId = pendingScrollProcessIdRef.current; - if (!processId) return; - pendingScrollProcessIdRef.current = null; - // Use a short rAF inside the effect so the dict container has fully un-hidden - // before we query the element (keep-mounted dict uses display:none visibility). - requestAnimationFrame(() => { - const el = document.getElementById(`process-${processId}`); - if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); - }); - }, [view]); + if (view === 'dict' && model) requestAnimationFrame(openPendingNode); + }, [view, model]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const entity = pendingChangeEntityRef.current; @@ -315,13 +436,20 @@ export function App() { graphViewRef.current?.retheme(themeMode, model, findings.entityErrors); }, [themeMode]); // eslint-disable-line react-hooks/exhaustive-deps - // Document title reflects the model display name in both live and static modes. - // Live: re-runs on every model-change SSE refetch. Static: runs once the - // injected model is read. Falls back to the constant when the model is - // unloaded or unnamed. + // `<view> - <subject> | <name>`: the open dialog, else the Dictionary section (from the URL) or Flows diagram. + function tabTitle(): string { + const name = model?._meta?.name ?? model?.branding?.title ?? 'Ignatius'; + const section = currentHash().section; + const subject = showEntityModal && selected ? selected.id.replace(/_/g, ' ') + : nodeDialog ? nodeDialog.title.replace(/_/g, ' ') + : view === 'dict' && section ? sectionTitle(section, flowDiagrams) + : view === 'flow' && activeDfd ? diagramTitle(activeDfd, flowDiagrams) + : null; + return `${VIEW_ABBREVIATIONS[view]}${subject ? ` - ${subject}` : ''} | ${name}`; + } useEffect(() => { - document.title = model?._meta?.name ?? 'Ignatius'; - }, [model]); + document.title = tabTitle(); + }); // Whether the flow surface is active — hoisted early since it (and // bannerVisible below) are read by both the banner-offset effect and the @@ -607,6 +735,7 @@ export function App() { // history entry the user landed on intact (no replace/push churn). setSelected(node); setShowEntityModal(true); + setEntityModalOpenedFromFlow(parseHash(location.hash).view === 'flow'); } }, [modelIndex]); // eslint-disable-line react-hooks/exhaustive-deps @@ -726,6 +855,11 @@ export function App() { onToggleNav={() => setDictNavOpen(prev => !prev)} active={view === 'dict'} onOpenEntity={id => openEntityById(id)} + onOpenDiagram={handleChangeDiagram} + sectionTarget={sectionTarget} + onJumpToSection={jumpToSection} + onSectionInView={handleSectionInView} + onNodeDialogChange={handleNodeDialogChange} themeMode={themeMode} changeReview={shownChanges} entityChanges={shownEntityChanges} @@ -751,6 +885,12 @@ export function App() { getThemeConfig={() => entityModelRef.current?.theme} onOpenEntity={(id, fromFlow) => openEntityByIdRef.current(id, fromFlow)} onZoomPercentChange={setFlowZoomPercent} + onNodeDialogChange={handleNodeDialogChange} + onActiveDiagramChange={(id) => { + activeDfdRef.current = id; + setActiveDfd(id); + requestAnimationFrame(openPendingNode); + }} searchTokens={flowSearchTokens} changeReview={shownChanges} flowView={flowView} @@ -839,6 +979,7 @@ export function App() { <button className="help-toggle" onClick={() => setShowHelp(true)} title="What am I looking at? (?)" aria-label="Help"> ? </button> + <MermaidZoom /> <FabMenu ref={fabMenuRef} view={view} @@ -898,7 +1039,8 @@ export function App() { // Preserve the fromFlow flag so chained navigations stay in-place. // openEntityById pushes ONE history entry, so an FK hop A→B stacks // …→A→B and Back returns to A. - openEntityById(id, true); + if (modelIndexRef.current?.nodeById.has(id)) openEntityById(id, true); + else followFlowLink(id); } else { const target = modelIndexRef.current?.nodeById.get(id); if (target) { @@ -907,28 +1049,26 @@ export function App() { setSelected(target); openEntity(id); graphViewRef.current?.navigateToEntity(id); + } else { + followFlowLink(id); } } }} processUsages={entityUsageIndex?.get(selected.id)} onNavigateToProcess={(processId) => { if (entityModalOpenedFromFlow && flowsViewRef.current) { - // Flow context: open the target process dialog in-place over the flow. - // Close entity modal first so only one dialog is visible at a time. + // Flow context: the process dialog opens over the flow as its own entry; Back reopens this one. setShowEntityModal(false); setEntityModalOpenedFromFlow(false); - closeEntity(); + leaveEntity(); flowsViewRef.current.openFlowToken(`proc:${processId}`); } else { + // A navigation away from the dialog: Back returns to it, so entity= stays on the old entry. setShowEntityModal(false); - closeEntity(); - // Record the target before setView so the dict-view useEffect picks it - // up after React commits the view switch and the DOM is ready. - pendingScrollProcessIdRef.current = processId; - setView('dict'); + jumpToSection(`process:${processId}`); } }} - allFlowNodeIds={entityModalOpenedFromFlow && appAllFlowNodeIds ? appAllFlowNodeIds : undefined} + allFlowNodeIds={appAllFlowNodeIds ?? undefined} /> )} {branding && ( diff --git a/src/app/components/changes/ChangeBadge.tsx b/src/app/components/changes/ChangeBadge.tsx index d374f66..8e1139d 100644 --- a/src/app/components/changes/ChangeBadge.tsx +++ b/src/app/components/changes/ChangeBadge.tsx @@ -1,17 +1,125 @@ -import type { ChangeMark, ChangeSeverity } from '../../logic/change-marks'; +import { useEffect, useRef, useState } from 'react'; +import type { ChangeMark, ChangeSeverity, ExplainedChanges } from '../../logic/change-marks'; +import type { FlowDiagram } from '../../../flows/flow-parse'; +import { itemLabel } from './ChangesPanel'; + +const POPOVER_WIDTH = 440; +const POPOVER_HEIGHT = 360; +const GAP = 6; +const EDGE = 8; + +type Placement = { left: number; width: number; maxHeight: number } & ({ top: number } | { bottom: number }); + +/** Below the badge when it fits, else on whichever side has more room; clamped to the viewport. */ +function placeBeside(badge: DOMRect): Placement { + const width = Math.min(POPOVER_WIDTH, window.innerWidth - 2 * EDGE); + const left = Math.max(EDGE, Math.min(badge.left, window.innerWidth - width - EDGE)); + const below = window.innerHeight - badge.bottom - GAP - EDGE; + const above = badge.top - GAP - EDGE; + if (below >= POPOVER_HEIGHT || below >= above) return { left, width, maxHeight: Math.min(POPOVER_HEIGHT, below), top: badge.bottom + GAP }; + return { left, width, maxHeight: Math.min(POPOVER_HEIGHT, above), bottom: window.innerHeight - badge.top + GAP }; +} + +/** An entity's or flow node's change mark. Given `changes`, the badge is a button that opens a popover listing what + * changed and the notes that explain it; `diagrams` names flow items by title. */ +export function ChangeBadge({ mark, changes, diagrams = null }: { mark: ChangeMark | undefined; changes?: ExplainedChanges; diagrams?: FlowDiagram[] | null }) { + // Fixed to the viewport: an entity modal clips anything absolute that crosses its edge. + const [placement, setPlacement] = useState<Placement | null>(null); + const rootRef = useRef<HTMLSpanElement>(null); + const open = placement !== null; + + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (e.target instanceof Node && rootRef.current?.contains(e.target)) return; + setPlacement(null); + } + // The popover is fixed to the viewport, so a scroll behind it moves it with its badge, and closes it once the badge leaves the window. + function onScroll(e: Event) { + const root = rootRef.current; + if (!root || (e.target instanceof Node && root.contains(e.target))) return; + const badge = root.getBoundingClientRect(); + setPlacement(badge.bottom < 0 || badge.top > window.innerHeight ? null : placeBeside(badge)); + } + // Capture phase, so Escape closes the popover before the entity modal's own Escape handler sees it. + function onKeyDown(e: KeyboardEvent) { + if (e.key !== 'Escape') return; + e.stopPropagation(); + setPlacement(null); + } + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('scroll', onScroll, true); + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('scroll', onScroll, true); + document.removeEventListener('keydown', onKeyDown, true); + }; + }, [open]); + + function toggle(button: HTMLElement) { + setPlacement(open ? null : placeBeside(button.getBoundingClientRect())); + } -export function ChangeBadge({ mark }: { mark: ChangeMark | undefined }) { if (!mark) return null; + const className = `change-badge change-badge--${mark.change}${mark.unexplained ? ' change-badge--unexplained' : ''}`; + const text = `${mark.change}${mark.unexplained ? ' · unexplained' : ''}`; + if (!changes || changes.items.length === 0) { + return ( + <span className={className} title={mark.unexplained ? 'Not covered by a change note' : undefined}> + {text} + </span> + ); + } + return ( - <span - className={`change-badge change-badge--${mark.change}${mark.unexplained ? ' change-badge--unexplained' : ''}`} - title={mark.unexplained ? 'Not covered by a change note' : undefined} - > - {mark.change}{mark.unexplained ? ' · unexplained' : ''} + <span className="change-badge-anchor" ref={rootRef}> + <button + type="button" + className={`${className} change-badge--button`} + aria-expanded={open} + title="Why did this change?" + onClick={e => { + e.stopPropagation(); + toggle(e.currentTarget); + }} + > + {text} ▾ + </button> + {placement && <ChangePopover changes={changes} placement={placement} diagrams={diagrams} />} </span> ); } +function ChangePopover({ changes, placement, diagrams }: { changes: ExplainedChanges; placement: Placement; diagrams: FlowDiagram[] | null }) { + return ( + <div className="change-popover" style={placement} role="dialog" aria-label="Why this changed" data-ignatius="change-popover"> + <h4>What changed</h4> + <ul className="change-popover-items"> + {changes.items.map((item, i) => ( + <li key={i}> + {itemLabel(item, diagrams)} + {item.status === 'unexplained' && <span className="change-popover-unexplained"> · no note covers this</span>} + </li> + ))} + </ul> + <h4>Why</h4> + {changes.notes.length === 0 ? ( + <p className="change-popover-empty">No change note covers these changes.</p> + ) : ( + changes.notes.map(note => ( + <section key={note.file} className="change-popover-note"> + <div className="change-popover-note-title"> + {note.title} <code>{note.file}</code> + </div> + <div className="md" dangerouslySetInnerHTML={{ __html: note.bodyHtml }} /> + </section> + )) + )} + </div> + ); +} + /** A static dot colored by the worst change inside a flow index or level menu row's diagram, * or, given the row's own `mark`, a dot that pulses in its kind color. */ export function ChangeDot({ severity, mark = null }: { severity: ChangeSeverity | null; mark?: ChangeMark | null }) { diff --git a/src/app/components/entity/ColumnsTable.tsx b/src/app/components/entity/ColumnsTable.tsx index 00bb663..c79a343 100644 --- a/src/app/components/entity/ColumnsTable.tsx +++ b/src/app/components/entity/ColumnsTable.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import type { ModelNode, ModelEdge } from '../../../model/parse'; +import type { ColumnDef, ModelNode, ModelEdge } from '../../../model/parse'; import type { ChangeMark, EntityChanges } from '../../logic/change-marks'; function rowClass(mark: ChangeMark | undefined): string | undefined { @@ -12,7 +12,7 @@ function ChangeTag({ mark }: { mark: ChangeMark | undefined }) { return <span className="change-tag">{mark.change}</span>; } -function RemovedColumnRows({ changes, codeType }: { changes: EntityChanges | undefined; codeType: boolean }) { +function RemovedColumnRows({ changes, codeType, onLink }: { changes: EntityChanges | undefined; codeType: boolean; onLink: (id: string) => void }) { return changes?.removedColumns.map(([name, col]) => ( <tr key={`removed-${name}`} className={rowClass(changes.columns.get(name))}> <td><code>{name}</code></td> @@ -20,11 +20,29 @@ function RemovedColumnRows({ changes, codeType }: { changes: EntityChanges | und <td>—</td> <td>{col.nullable ? 'Yes' : 'No'}</td> <td>{col.default ?? ''}</td> - <td>{col.desc ?? ''}</td> + <DescCell col={col} onLink={onLink} /> </tr> )); } +/** A column's description; its rendered `[[links]]` call `onLink` with their target id. */ +function DescCell({ col, onLink }: { col: ColumnDef; onLink: (id: string) => void }) { + if (!col.descHtml) return <td>{col.desc ?? ''}</td>; + return ( + <td + className="col-desc" + onClick={e => { + if (!(e.target instanceof Element)) return; + const id = e.target.closest('a[data-entity]')?.getAttribute('data-entity'); + if (!id) return; + e.preventDefault(); + onLink(id); + }} + dangerouslySetInnerHTML={{ __html: col.descHtml }} + /> + ); +} + /** * Unified columns table for the entity modal and the data dictionary card. * @@ -46,6 +64,7 @@ export function ColumnsTable({ missingTargets, onNavigateMissing, changes, + onDescLink, }: { node: ModelNode; edges: ModelEdge[]; @@ -56,6 +75,8 @@ export function ColumnsTable({ /** dict variant only — called when clicking a missing FK target. */ onNavigateMissing?: (id: string) => void; changes?: EntityChanges; + /** Follows a `[[link]]` in a column description; defaults to `onNavigate`. */ + onDescLink?: (id: string) => void; }) { const fkTargets: Record<string, string> = {}; for (const edge of edges) { @@ -121,11 +142,11 @@ export function ColumnsTable({ </td> <td>{col.nullable ? 'Yes' : 'No'}</td> <td>{col.default != null ? String(col.default) : ''}</td> - <td>{col.desc ?? ''}</td> + <DescCell col={col} onLink={onDescLink ?? onNavigate} /> </tr> ); })} - <RemovedColumnRows changes={changes} codeType /> + <RemovedColumnRows changes={changes} codeType onLink={onDescLink ?? onNavigate} /> </tbody> </table> </div> @@ -179,10 +200,10 @@ export function ColumnsTable({ <td>{renderRoles(name)}</td> <td>{col.nullable ? 'Yes' : 'No'}</td> <td>{col.default ?? ''}</td> - <td>{col.desc ?? ''}</td> + <DescCell col={col} onLink={onDescLink ?? onNavigate} /> </tr> ))} - <RemovedColumnRows changes={changes} codeType={false} /> + <RemovedColumnRows changes={changes} codeType={false} onLink={onDescLink ?? onNavigate} /> </tbody> </table> </div> diff --git a/src/app/components/entity/EntityCard.tsx b/src/app/components/entity/EntityCard.tsx index bc862ce..06c023e 100644 --- a/src/app/components/entity/EntityCard.tsx +++ b/src/app/components/entity/EntityCard.tsx @@ -51,7 +51,7 @@ export function EntityCard({ > <div className="dict-entity-header"> <h2>{node.id}</h2> - <ChangeBadge mark={changes?.mark} /> + <ChangeBadge mark={changes?.mark} changes={changes} /> <ClassificationBadge cls={node.classification} /> {isBasetypeOf && ( <span diff --git a/src/app/components/entity/EntityModal.tsx b/src/app/components/entity/EntityModal.tsx index 8e598df..3aca3ff 100644 --- a/src/app/components/entity/EntityModal.tsx +++ b/src/app/components/entity/EntityModal.tsx @@ -46,6 +46,13 @@ export function EntityModal({ selected, model, nodeById, nodeIdSet, entityErrors } }, [selected.bodyHtml, allFlowNodeIds]); + // In flow context (allFlowNodeIds present), allow any flow node id; + // in graph context, limit to ERD entity nodes only. + function followLink(id: string) { + const hasNode = nodeIdSet ? nodeIdSet.has(id) : (model?.nodes.some(n => n.id === id) ?? false); + if (hasNode || allFlowNodeIds?.has(id)) onNavigate(id); + } + const badges = ( <div className="modal-badges"> <span className={`badge ${selected.classification.toLowerCase()}`}> @@ -65,7 +72,7 @@ export function EntityModal({ selected, model, nodeById, nodeIdSet, entityErrors <span className="pk-label"> PK: {selected.pk.join(', ')} </span> - <ChangeBadge mark={changes?.mark} /> + <ChangeBadge mark={changes?.mark} changes={changes} /> </div> ); @@ -79,6 +86,7 @@ export function EntityModal({ selected, model, nodeById, nodeIdSet, entityErrors if (target) onNavigate(id); }} changes={changes} + onDescLink={followLink} /> <div ref={bodyRef} @@ -93,15 +101,7 @@ export function EntityModal({ selected, model, nodeById, nodeIdSet, entityErrors if (!link) return; e.preventDefault(); const id = link.getAttribute('data-entity'); - // In flow context (allFlowNodeIds present), allow any flow node id; - // in graph context, limit to ERD entity nodes only. - const hasNode = nodeIdSet - ? (id !== null && id !== undefined && nodeIdSet.has(id)) - : (id !== null && id !== undefined && (model?.nodes.some(n => n.id === id) ?? false)); - const allowed = allFlowNodeIds - ? (id && (allFlowNodeIds.has(id) || hasNode)) - : (id && hasNode); - if (allowed && id) onNavigate(id); + if (id) followLink(id); }} dangerouslySetInnerHTML={{ __html: selected.bodyHtml }} /> diff --git a/src/app/components/flow-node/ExternalCard.tsx b/src/app/components/flow-node/ExternalCard.tsx index 8138d89..f2bf7ea 100644 --- a/src/app/components/flow-node/ExternalCard.tsx +++ b/src/app/components/flow-node/ExternalCard.tsx @@ -10,15 +10,19 @@ import type { FlowExternal } from '../../../flows/flow-parse'; export function ExternalCard({ external, children, + headerExtra, }: { external: FlowExternal; children?: ReactNode; + /** Rendered after the name, e.g. a change badge. */ + headerExtra?: ReactNode; }) { return ( <section className="dict-entity-section" id={`external-${external.id}`}> <div className="dict-entity-header"> <span className="flow-external-kind">EXT</span> <h3 className="flow-external-name">{external.label}</h3> + {headerExtra} </div> {children} </section> diff --git a/src/app/components/flow-node/FlowNodeModal.tsx b/src/app/components/flow-node/FlowNodeModal.tsx index 25cc54c..2ddd73d 100644 --- a/src/app/components/flow-node/FlowNodeModal.tsx +++ b/src/app/components/flow-node/FlowNodeModal.tsx @@ -16,12 +16,17 @@ import { StoreCard } from './StoreCard'; * Reuses the Dictionary's FlowIoTable / DictExternalSection / DictStoreSection * components so the dialog and the dictionary show the same structured facts. */ -export function FlowNodeModal({ node, allProcesses, doc, onClose, onNavigate, allFlowNodeIds, canOpenToken, nodeUsageIndex }: { +export function FlowNodeModal({ node, allProcesses, doc, onClose, onNavigate, allFlowNodeIds, canOpenToken, nodeUsageIndex, diagramRef, inDiagramRef, onOpenDiagram }: { node: FlowProcess | FlowExternal | FlowStoreRef; allProcesses: FlowProcess[]; doc: FlowDoc; onClose: () => void; onNavigate: (token: string) => void; + /** A decomposed process's sub-DFD (`dfd=` reference); with `onOpenDiagram`, the header links to it. */ + diagramRef?: string; + /** The diagram the process is drawn in; with `onOpenDiagram`, the header links to it. Omit where it is already shown. */ + inDiagramRef?: string; + onOpenDiagram?: (ref: string) => void; allFlowNodeIds?: ReadonlySet<string>; /** When provided, non-db IO table endpoints whose token resolves become clickable links. */ canOpenToken?: (token: string) => boolean; @@ -103,7 +108,24 @@ export function FlowNodeModal({ node, allProcesses, doc, onClose, onNavigate, al } return ( - <Modal title={doc.title.replace(/_/g, ' ')} onClose={onClose}> + <Modal + title={doc.title.replace(/_/g, ' ')} + onClose={onClose} + headerExtra={onOpenDiagram && (inDiagramRef !== undefined || diagramRef !== undefined) && ( + <span className="open-diagram-links"> + {inDiagramRef !== undefined && ( + <button type="button" className="open-diagram-link" data-target="in" onClick={() => onOpenDiagram(inDiagramRef)}> + Show in diagram → + </button> + )} + {diagramRef !== undefined && ( + <button type="button" className="open-diagram-link" data-target="sub" onClick={() => onOpenDiagram(diagramRef)}> + Open sub-diagram → + </button> + )} + </span> + )} + > {renderFacts()} {doc.bodyHtml && ( <div diff --git a/src/app/components/flow-node/StoreCard.tsx b/src/app/components/flow-node/StoreCard.tsx index c331fb1..8d1d861 100644 --- a/src/app/components/flow-node/StoreCard.tsx +++ b/src/app/components/flow-node/StoreCard.tsx @@ -9,9 +9,12 @@ import type { FlowStoreRef } from '../../../flows/flow-parse'; export function StoreCard({ store, children, + headerExtra, }: { store: FlowStoreRef; children?: ReactNode; + /** Rendered after the name, e.g. a change badge. */ + headerExtra?: ReactNode; }) { if (store.kind === 'db') return null; return ( @@ -19,6 +22,7 @@ export function StoreCard({ <div className="dict-entity-header"> <span className="flow-store-kind">{store.kind.toUpperCase()}</span> <h3 className="flow-store-name">{store.displayName}</h3> + {headerExtra} </div> {children} </section> diff --git a/src/app/components/process/ProcessCard.tsx b/src/app/components/process/ProcessCard.tsx index 645f682..5ba28eb 100644 --- a/src/app/components/process/ProcessCard.tsx +++ b/src/app/components/process/ProcessCard.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { FlowProcess } from '../../../flows/flow-parse'; import type { FlowError } from '../../../flows/flow-validate'; import { RULES } from '../../../model/validate'; @@ -13,6 +14,9 @@ export function ProcessCard({ onScrollToSection, externalIds, nonDbStoreNames, + headerExtra, + onShowInDiagram, + onOpenSubDiagram, }: { process: FlowProcess; allProcesses: FlowProcess[]; @@ -24,6 +28,12 @@ export function ProcessCard({ externalIds: Record<string, true>; /** O(1) set of known non-db store names — same purpose. */ nonDbStoreNames: Record<string, true>; + /** Rendered after the label, e.g. a change badge. */ + headerExtra?: ReactNode; + /** Opens the diagram the process is drawn in. */ + onShowInDiagram?: () => void; + /** Set when the process owns a sub-DFD: opens it. */ + onOpenSubDiagram?: () => void; }) { // Non-db endpoint handlers for the DD card. The token is `kind:name` (e.g. `ext:Customer`). @@ -49,6 +59,19 @@ export function ProcessCard({ <div className="dict-entity-header"> <span className="flow-dotted-number">{process.dottedNumber}</span> <h2 className="flow-process-label">{process.label}</h2> + {headerExtra} + <span className="open-diagram-links"> + {onShowInDiagram && ( + <button type="button" className="open-diagram-link" data-target="in" onClick={onShowInDiagram}> + Show in diagram → + </button> + )} + {onOpenSubDiagram && ( + <button type="button" className="open-diagram-link" data-target="sub" onClick={onOpenSubDiagram}> + Open sub-diagram → + </button> + )} + </span> </div> {procErrors.length > 0 && ( diff --git a/src/app/components/ui/MermaidZoom.tsx b/src/app/components/ui/MermaidZoom.tsx new file mode 100644 index 0000000..284d3d3 --- /dev/null +++ b/src/app/components/ui/MermaidZoom.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { cloneDiagramMarkup } from '../../logic/mermaid-render'; +import { Modal } from './Modal'; + +const MIN_SCALE = 0.1; +const MAX_SCALE = 12; +const BUTTON_STEP = 1.25; + +type View = { scale: number; x: number; y: number }; + +function clampScale(scale: number): number { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); +} + +/** Keeps the diagram point under (cx, cy) fixed while the scale changes. */ +function zoomAround(view: View, nextScale: number, cx: number, cy: number): View { + const scale = clampScale(nextScale); + const ratio = scale / view.scale; + return { scale, x: cx - (cx - view.x) * ratio, y: cy - (cy - view.y) * ratio }; +} + +/** Opens a clicked Mermaid diagram in a zoomable dialog. Listens on `document` in the capture phase: the markup is + * injected HTML, and a `Modal` frame stops a click's propagation. Stacked and portaled for the reasons `JsonValue`'s is. */ +export function MermaidZoom() { + const [markup, setMarkup] = useState<string | null>(null); + + useEffect(() => { + function onClick(e: MouseEvent) { + if (!(e.target instanceof Element)) return; + const svg = e.target.closest('.mermaid-diagram[data-rendered] > svg'); + if (!(svg instanceof SVGSVGElement)) return; + setMarkup(cloneDiagramMarkup(svg)); + } + document.addEventListener('click', onClick, true); + return () => document.removeEventListener('click', onClick, true); + }, []); + + if (markup === null) return null; + return createPortal( + <Modal title="Diagram" onClose={() => setMarkup(null)} className="mermaid-zoom-modal" stacked> + <ZoomStage markup={markup} /> + </Modal>, + document.body, + ); +} + +function ZoomStage({ markup }: { markup: string }) { + const viewportRef = useRef<HTMLDivElement>(null); + const contentRef = useRef<HTMLDivElement>(null); + const [view, setView] = useState<View>({ scale: 1, x: 0, y: 0 }); + const viewRef = useRef(view); + viewRef.current = view; + + function diagramSize(): { width: number; height: number } | null { + const box = contentRef.current?.querySelector('svg')?.viewBox.baseVal; + return box?.width ? { width: box.width, height: box.height } : null; + } + + function fit() { + const viewport = viewportRef.current; + const size = diagramSize(); + if (!viewport || !size) return; + const scale = clampScale(Math.min(viewport.clientWidth / size.width, viewport.clientHeight / size.height, 2)); + setView({ + scale, + x: (viewport.clientWidth - size.width * scale) / 2, + y: (viewport.clientHeight - size.height * scale) / 2, + }); + } + + function zoomAtCentre(nextScale: number) { + const viewport = viewportRef.current; + if (!viewport) return; + setView(v => zoomAround(v, nextScale, viewport.clientWidth / 2, viewport.clientHeight / 2)); + } + + // The panel copy carries the inline sizing `fitPanel` gave it; the stage draws it at its natural size instead. + useEffect(() => { + const svg = contentRef.current?.querySelector('svg'); + const size = diagramSize(); + if (!svg || !size) return; + svg.removeAttribute('style'); + svg.setAttribute('width', String(size.width)); + svg.setAttribute('height', String(size.height)); + fit(); + }, [markup]); + + // React registers wheel listeners as passive, and a passive listener cannot stop the page from scrolling. + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = viewport.getBoundingClientRect(); + const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.002)); + setView(v => zoomAround(v, v.scale * factor, e.clientX - rect.left, e.clientY - rect.top)); + }; + viewport.addEventListener('wheel', onWheel, { passive: false }); + return () => viewport.removeEventListener('wheel', onWheel); + }, []); + + function startPan(e: ReactPointerEvent<HTMLDivElement>) { + if (e.button !== 0) return; + const viewport = e.currentTarget; + viewport.setPointerCapture(e.pointerId); + const start = { px: e.clientX, py: e.clientY, x: viewRef.current.x, y: viewRef.current.y }; + const onMove = (ev: PointerEvent) => { + setView(v => ({ ...v, x: start.x + ev.clientX - start.px, y: start.y + ev.clientY - start.py })); + }; + const onUp = () => { + viewport.removeEventListener('pointermove', onMove); + viewport.removeEventListener('pointerup', onUp); + viewport.removeEventListener('pointercancel', onUp); + }; + viewport.addEventListener('pointermove', onMove); + viewport.addEventListener('pointerup', onUp); + viewport.addEventListener('pointercancel', onUp); + } + + return ( + <div className="mermaid-zoom"> + <div className="mermaid-zoom-controls"> + <button type="button" onClick={() => zoomAtCentre(view.scale / BUTTON_STEP)} aria-label="Zoom out">−</button> + <button type="button" onClick={fit}>Fit</button> + <button type="button" onClick={() => zoomAtCentre(1)}>1:1</button> + <button type="button" onClick={() => zoomAtCentre(view.scale * BUTTON_STEP)} aria-label="Zoom in">+</button> + <span className="mermaid-zoom-level">{Math.round(view.scale * 100)}%</span> + </div> + <div className="mermaid-zoom-viewport" ref={viewportRef} onPointerDown={startPan} data-ignatius="mermaid-zoom-viewport"> + <div + className="mermaid-zoom-content" + ref={contentRef} + style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }} + dangerouslySetInnerHTML={{ __html: markup }} + /> + </div> + </div> + ); +} diff --git a/src/app/components/ui/Modal.tsx b/src/app/components/ui/Modal.tsx index 9f34d1f..d06cfc9 100644 --- a/src/app/components/ui/Modal.tsx +++ b/src/app/components/ui/Modal.tsx @@ -1,5 +1,5 @@ -import { useEffect, useId, useRef } from 'react'; -import type { ReactNode } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'; /** * Open modals, outermost first. Every instance listens for ESC, Tab and focusin @@ -7,7 +7,7 @@ import type { ReactNode } from 'react'; * dismiss the modal that opened it along with itself, and both would fight over focus. * * Backdrop clicks need no equivalent guard: React bubbles a stacked dialog's click - * through its opener's `.modal`, portal or not, and that handler stops propagation. + * through its opener's `.modal-frame`, portal or not, and that handler stops propagation. */ const modalStack: object[] = []; @@ -21,6 +21,14 @@ const FOCUSABLE = [ '[tabindex]', ].map(sel => `${sel}:not([tabindex="-1"])`).join(', '); +const MIN_WIDTH = 360; +const WIDTH_KEY_PREFIX = 'ignatius-modal-width:'; + +function savedWidth(key: string): number | null { + const width = Number(localStorage.getItem(key)); + return Number.isFinite(width) && width >= MIN_WIDTH ? width : null; +} + /** Skips elements with no layout box, such as those inside a closed `<details>`. */ function focusableIn(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(el => el.getClientRects().length > 0); @@ -34,6 +42,8 @@ function focusableIn(root: HTMLElement): HTMLElement[] { * `headerExtra` renders inside `.modal-header` after the `<h1>` (for badges etc.). * `stacked` marks a dialog opened from within another one — it lifts the * backdrop above the opener's. + * Either side edge drags to resize; the width persists per `className` in + * localStorage, and a double-click on an edge restores the default. */ export function Modal({ title, onClose, children, className, headerExtra, stacked }: { title: string; @@ -46,6 +56,8 @@ export function Modal({ title, onClose, children, className, headerExtra, stacke const closeRef = useRef<HTMLButtonElement>(null); const modalRef = useRef<HTMLDivElement>(null); const titleId = useId(); + const widthKey = `${WIDTH_KEY_PREFIX}${className ?? 'content'}`; + const [width, setWidth] = useState(() => savedWidth(widthKey)); // Read through a ref so the listener effect can stay mount-only. Keying it on // `onClose` instead would re-push this modal's stack entry on every render @@ -105,24 +117,58 @@ export function Modal({ title, onClose, children, className, headerExtra, stacke }; }, []); + // The frame is centred, so each edge moves by half the width change: the width grows by twice the drag. + function startResize(e: ReactPointerEvent<HTMLDivElement>, side: -1 | 1) { + const frame = e.currentTarget.parentElement; + if (!frame || e.button !== 0) return; + e.preventDefault(); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + const startX = e.clientX; + const startWidth = frame.getBoundingClientRect().width; + let latest = startWidth; + const onMove = (ev: PointerEvent) => { + latest = Math.min(window.innerWidth * 0.96, Math.max(MIN_WIDTH, startWidth + 2 * side * (ev.clientX - startX))); + setWidth(latest); + }; + const onUp = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', onUp); + handle.removeEventListener('pointercancel', onUp); + if (latest !== startWidth) localStorage.setItem(widthKey, String(Math.round(latest))); + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + } + + function resetWidth() { + localStorage.removeItem(widthKey); + setWidth(null); + } + const backdropClass = stacked ? 'modal-backdrop modal-backdrop-stacked' : 'modal-backdrop'; + const frameStyle = width === null ? undefined : { width: `${width}px`, maxWidth: '96vw' }; return ( <div className={backdropClass} onClick={onClose}> - <div - className={`modal${className ? ` ${className}` : ''}`} - onClick={e => e.stopPropagation()} - ref={modalRef} - role="dialog" - aria-modal="true" - aria-labelledby={titleId} - > - <button className="modal-close" onClick={onClose} ref={closeRef}>×</button> - <div className="modal-header"> - <h1 id={titleId}>{title}</h1> - {headerExtra} + <div className="modal-frame" style={frameStyle} onClick={e => e.stopPropagation()}> + <div + className={`modal${className ? ` ${className}` : ''}`} + ref={modalRef} + role="dialog" + aria-modal="true" + aria-labelledby={titleId} + > + <button className="modal-close" onClick={onClose} ref={closeRef}>×</button> + <div className="modal-header"> + <h1 id={titleId}>{title}</h1> + {headerExtra} + </div> + {children} </div> - {children} + <div className="modal-resize modal-resize--left" title="Drag to resize, double-click to reset" onPointerDown={e => startResize(e, -1)} onDoubleClick={resetWidth} /> + <div className="modal-resize modal-resize--right" title="Drag to resize, double-click to reset" onPointerDown={e => startResize(e, 1)} onDoubleClick={resetWidth} /> </div> </div> ); diff --git a/src/app/dom/body-links.ts b/src/app/dom/body-links.ts index 29a23b2..5b19896 100644 --- a/src/app/dom/body-links.ts +++ b/src/app/dom/body-links.ts @@ -1,3 +1,5 @@ +import { canonicalTarget } from '../../model/wikilink'; + // Shared body-click resolver for all DD body divs (entity/process/external/store). // Handles live `<a data-entity>` anchors AND `.entity-link--missing` spans // (resolved at click time — survives React reconciliation). @@ -32,13 +34,16 @@ export function resolveBodyClick( /** * Upgrade `.entity-link--missing` spans inside `container` to live anchor - * elements when their target ID appears in `allKnownIds`. + * elements when their target resolves against `allKnownIds`, and point live + * anchors at the id their target names (`canonicalTarget`). * * Entity bodies are rendered at parse time with knownIds = entity IDs only, * so a `[[Customer]]` reference in an entity body emits a missing span even - * when Customer exists as a flow external. This pass resolves those references - * client-side after the full set of node IDs (entities + externals + stores + - * processes) is known. + * when Customer exists as a flow external. Flow bodies are rendered with no + * knownIds, so `[[Answer Turn]]` arrives as a live anchor on `Answer Turn` + * while the process id is `Answer-Turn`. This pass resolves both client-side + * after the full set of node IDs (entities + externals + stores + processes) + * is known. * * Exported for unit testing; the upgrade is side-effecting (mutates the DOM). */ @@ -46,14 +51,20 @@ export function upgradeMissingLinksInContainer( container: ParentNode, allKnownIds: ReadonlySet<string>, ): void { + for (const anchor of container.querySelectorAll<HTMLElement>('a[data-entity]')) { + const raw = anchor.getAttribute('data-entity') ?? ''; + const id = canonicalTarget(raw, allKnownIds); + if (id !== null && id !== raw) anchor.setAttribute('data-entity', id); + } + const spans = container.querySelectorAll<HTMLElement>('span.entity-link--missing'); for (const span of spans) { // Target is encoded in `title="Unknown entity: <target>"`. const title = span.getAttribute('title') ?? ''; const prefix = 'Unknown entity: '; if (!title.startsWith(prefix)) continue; - const target = title.slice(prefix.length); - if (!allKnownIds.has(target)) continue; + const target = canonicalTarget(title.slice(prefix.length), allKnownIds); + if (target === null) continue; // Replace span with a live anchor carrying data-entity so the parent body // div's click delegation picks it up. Href is "#" (neutral) because the diff --git a/src/app/hash-router.ts b/src/app/hash-router.ts index f281ec7..ace2e28 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=<graph|dict|flow>&entity=<id>&zoom=<n>&pan=<x>,<y>&dfd=<diagram-ref>&flowview=<per-process|connected>&collapse=<stores|clusters|groups> +// Format: #view=<graph|dict|flow>&entity=<id>&zoom=<n>&pan=<x>,<y>&dfd=<diagram-ref>&flowview=<per-process|connected>&collapse=<stores|clusters|groups>§ion=<kind>:<id>&node=<token> // All params are optional. Unknown/malformed values are silently dropped. export type ViewName = 'graph' | 'dict' | 'flow'; @@ -34,6 +34,18 @@ export interface HashState { flowview?: FlowViewMode; /** Stack row breakdown level — global setting, deep-linkable per docs/spec/dfd-store-clusters.md. */ collapse?: FlowCollapseLevel; + /** The Dictionary section in view, `<kind>:<id>` (`process:Create-Sales-Order`). */ + section?: string; + /** The open flow-node dialog's token (`proc:Collect-Payment`, `ext:Customer`, `queue:Order-Intake`). */ + node?: string; +} + +const SECTION = /^(entity|process|external|store):(.+)$/; + +/** A `section=` value split into the Dictionary anchor kind and id, or null when malformed. */ +export function splitSection(section: string): { kind: string; id: string } | null { + const match = SECTION.exec(section); + return match && match[1] && match[2] ? { kind: match[1], id: match[2] } : null; } /** @@ -90,6 +102,16 @@ export function parseHash(hash: string): HashState { state.collapse = VALID_COLLAPSE_LEVELS[collapseVal]; } + const sectionVal = params.get('section'); + if (sectionVal !== null && splitSection(sectionVal) !== null) { + state.section = sectionVal; + } + + const nodeVal = params.get('node'); + if (nodeVal !== null && nodeVal.includes(':')) { + state.node = nodeVal; + } + return state; } @@ -132,5 +154,14 @@ export function serializeHash(state: HashState): string { parts.push(`collapse=${state.collapse}`); } + // ':' is legal in a fragment, so `section=process:Create-Sales-Order` stays readable. + if (state.section !== undefined) { + parts.push(`section=${encodeURIComponent(state.section).replaceAll('%3A', ':')}`); + } + + if (state.node !== undefined) { + parts.push(`node=${encodeURIComponent(state.node).replaceAll('%3A', ':')}`); + } + return parts.join('&'); } diff --git a/src/app/hooks/useHashRoute.ts b/src/app/hooks/useHashRoute.ts index 4868a0c..03b6b9e 100644 --- a/src/app/hooks/useHashRoute.ts +++ b/src/app/hooks/useHashRoute.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { parseHash, serializeHash } from '../hash-router'; +import { parseHash } from '../hash-router'; import type { HashState, ViewName, FlowViewMode, FlowCollapseLevel } from '../hash-router'; +import { currentHash, navigationBase, writeHash } from '../logic/history'; export type UseHashRouteOptions = { // Called when a popstate event lands on the flow view with a dfd= param. @@ -11,6 +12,10 @@ export type UseHashRouteOptions = { // null (close it). The shell MUST NOT push history in response — this fires // because the user already navigated (Back/Forward). onEntityChange?: (entityId: string | null) => void; + // Same contract as onEntityChange, for the flow-node dialog's node= token. + onNodeChange?: (token: string | null) => void; + // Same contract, for the Dictionary section= the entry was showing. + onSectionChange?: (section: string | null) => void; // Called on a popstate reconcile when the hash carries a flowview=/collapse= // that differs from the shell's live state — a pushed history entry (e.g. // opening an entity) snapshots whatever flowview/collapse was current at @@ -20,12 +25,11 @@ export type UseHashRouteOptions = { onRestoreCollapseLevel?: (level: FlowCollapseLevel) => void; }; -// Owns hash read/write and popstate/hashchange back/forward restoration, AND the -// browser-history lifecycle of the entity modal (entity= is the single source of -// truth for "which modal is open"). The shell mirrors modal React state from the -// open/close helpers and the onEntityChange reconcile. +// Owns the hash and Back/Forward; push and replace rules are in docs/spec/viewer-qol.md (Navigation history). +// entity=, node=, and section= are the source of truth the shell mirrors. export function useHashRoute(opts?: UseHashRouteOptions): { view: ViewName; + /** Switch views as a navigation: pushes an entry without the old screen's dialog or section. */ setView: (v: ViewName) => void; // Push a history entry whose hash carries entity=<id>. Dedups: if the current // hash already carries this same entity, no new entry is pushed. @@ -33,6 +37,18 @@ export function useHashRoute(opts?: UseHashRouteOptions): { // Clear entity= from the CURRENT entry via replaceState (clean URL, no new // history step). Distinct from browser Back, which unwinds the modal stack. closeEntity: () => void; + /** Push an entry for a flow-node dialog over the current screen; `replace` adds it to the current entry instead. */ + openNode: (token: string, mode?: 'push' | 'replace') => void; + /** Drop node= from the current entry. */ + closeNode: () => void; + /** A node dialog gave way to another screen, whose navigation writes its own entry; Back reopens the dialog. */ + leaveNode: () => void; + /** The same, for an entity dialog. */ + leaveEntity: () => void; + /** Navigate to a Dictionary section (`process:Create-Sales-Order`): one pushed entry. */ + openSection: (section: string) => void; + /** Record the section scrolled into view on the current entry. */ + replaceSection: (section: string) => void; } { const [view, setViewState] = useState<ViewName>(() => { const fromHash = parseHash(location.hash).view; @@ -48,25 +64,30 @@ export function useHashRoute(opts?: UseHashRouteOptions): { onRestoreDfdRef.current = opts?.onRestoreDfd; const onEntityChangeRef = useRef(opts?.onEntityChange); onEntityChangeRef.current = opts?.onEntityChange; + const onNodeChangeRef = useRef(opts?.onNodeChange); + onNodeChangeRef.current = opts?.onNodeChange; + const onSectionChangeRef = useRef(opts?.onSectionChange); + onSectionChangeRef.current = opts?.onSectionChange; const onRestoreFlowViewRef = useRef(opts?.onRestoreFlowView); onRestoreFlowViewRef.current = opts?.onRestoreFlowView; const onRestoreCollapseLevelRef = useRef(opts?.onRestoreCollapseLevel); onRestoreCollapseLevelRef.current = opts?.onRestoreCollapseLevel; - // The entity= we last reconciled to (opened/closed via). Mirrors the hash's - // entity field. Seeded from the initial hash so a deep-link does not trip a - // spurious reconcile against an empty baseline. null = no modal open. - const entityRef = useRef<string | null>(parseHash(location.hash).entity ?? null); + // The entity=/node=/section= we last reconciled to. Seeded from the initial + // hash so a deep link does not trip a spurious reconcile against an empty baseline. + const initial = parseHash(location.hash); + const entityRef = useRef<string | null>(initial.entity ?? null); + const nodeRef = useRef<string | null>(initial.node ?? null); + const sectionRef = useRef<string | null>(initial.section ?? null); - // Write view into hash whenever it changes (does not clobber entity/zoom/pan). + // A first load without view= gets it written in place; every later view change + // already wrote its own entry. useEffect(() => { - const current = parseHash(location.hash); - const next: HashState = { ...current, view }; - const serialized = serializeHash(next); - history.replaceState({}, '', serialized ? '#' + serialized : location.pathname); + const current = currentHash(); + if (current.view !== view) writeHash({ ...current, view }, 'replace'); }, [view]); - // Back/forward navigation: read the hash and reconcile view, dfd, AND entity. + // Back/forward navigation: read the hash and reconcile view, dfd, and the screen params. useEffect(() => { function onPopState() { const fromHash = parseHash(location.hash); @@ -89,52 +110,104 @@ export function useHashRoute(opts?: UseHashRouteOptions): { if (fromHash.collapse) { onRestoreCollapseLevelRef.current?.(fromHash.collapse); } - // Reconcile the entity modal to MATCH the hash. Reconcile never pushes - // history (we are responding to a navigation). Only fire when the hash's - // entity differs from what we last opened, so Back from B→A→(none) walks + // Reconcile each screen param to MATCH the hash, never pushing history (we + // are responding to a navigation), and only when it changed, so Back walks // the stack exactly once per step. const newEntity = fromHash.entity ?? null; if (newEntity !== entityRef.current) { entityRef.current = newEntity; onEntityChangeRef.current?.(newEntity); } + const newNode = fromHash.node ?? null; + if (newNode !== nodeRef.current) { + nodeRef.current = newNode; + onNodeChangeRef.current?.(newNode); + } + const newSection = fromHash.section ?? null; + if (newSection !== sectionRef.current) { + sectionRef.current = newSection; + onSectionChangeRef.current?.(newSection); + } } window.addEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState); }, []); - // Public setter also updates the ref so same-tick reads see the new value. + function forgetScreen() { + entityRef.current = null; + nodeRef.current = null; + sectionRef.current = null; + } + function setView(v: ViewName) { + if (v !== viewRef.current) { + writeHash({ ...navigationBase(), view: v }, 'push', true); + forgetScreen(); + } viewRef.current = v; setViewState(v); } function openEntity(id: string) { - const current = parseHash(location.hash); + const current = currentHash(); // Dedup: an FK hop A→A or a re-open of the same entity must not stack a - // duplicate history entry. - if (current.entity === id) { - entityRef.current = id; - return; + // duplicate history entry. The pushed entry drops a node dialog it was opened from. + if (current.entity !== id) { + const next: HashState = { ...current, entity: id }; + delete next.node; + writeHash(next, 'push'); } - const next: HashState = { ...current, entity: id }; - const serialized = serializeHash(next); - history.pushState({}, '', serialized ? '#' + serialized : location.pathname); entityRef.current = id; + nodeRef.current = null; } function closeEntity() { - const current = parseHash(location.hash); - if (current.entity === undefined) { - entityRef.current = null; - return; - } - const next: HashState = { ...current }; + const next: HashState = { ...currentHash() }; delete next.entity; - const serialized = serializeHash(next); - history.replaceState({}, '', serialized ? '#' + serialized : location.pathname); + writeHash(next, 'replace'); + entityRef.current = null; + } + + function openNode(token: string, mode: 'push' | 'replace' = 'push') { + const current = currentHash(); + if (current.node !== token) { + const next: HashState = { ...current, node: token }; + delete next.entity; + writeHash(next, mode); + } + nodeRef.current = token; entityRef.current = null; } - return { view, setView, openEntity, closeEntity }; + function closeNode() { + const next: HashState = { ...currentHash() }; + delete next.node; + writeHash(next, 'replace'); + nodeRef.current = null; + } + + function leaveNode() { + nodeRef.current = null; + } + + function leaveEntity() { + entityRef.current = null; + } + + function openSection(section: string) { + writeHash({ ...navigationBase(), view: 'dict', section }, 'push'); + forgetScreen(); + sectionRef.current = section; + viewRef.current = 'dict'; + setViewState('dict'); + } + + function replaceSection(section: string) { + const current = currentHash(); + if (current.view !== 'dict' || current.section === section) return; + writeHash({ ...current, section }, 'replace'); + sectionRef.current = section; + } + + return { view, setView, openEntity, closeEntity, openNode, closeNode, leaveNode, leaveEntity, openSection, replaceSection }; } diff --git a/src/app/hooks/useScrollMemory.ts b/src/app/hooks/useScrollMemory.ts index 5e33e24..9b4342a 100644 --- a/src/app/hooks/useScrollMemory.ts +++ b/src/app/hooks/useScrollMemory.ts @@ -13,12 +13,59 @@ function readSaved(key: string): number | null { } } -/** Remembers `ref`'s scrollTop under `key` in sessionStorage and restores it when `deps` change, - * re-applying it as content grows until the user scrolls, something else moves the view, or HOLD_MS passes. */ +/** Scrolls `el` to `target()` and re-applies it as content grows, until the reader scrolls or HOLD_MS passes. + * `onApply` sees each scrollTop the hold produces. Returns a stop function. */ +export function holdScroll(el: HTMLElement, target: () => number, onApply?: (top: number) => void): () => void { + let applied: number | null = null; + const apply = () => { + el.scrollTop = target(); + applied = el.scrollTop; + onApply?.(applied); + }; + apply(); + let retry = requestAnimationFrame(apply); + + // Every report re-applies, the observer's first one included: content shown a moment ago + // (a view switch) has often grown before that first report arrives. + const growth = new ResizeObserver(apply); + for (const child of Array.from(el.children)) growth.observe(child); + // Scroll anchoring would chase the content that grows above, and its + // scroll event would read as someone else moving the view. + el.style.overflowAnchor = 'none'; + const stop = () => { + cancelAnimationFrame(retry); + retry = 0; + growth.disconnect(); + clearTimeout(timer); + el.style.removeProperty('overflow-anchor'); + el.removeEventListener('scroll', onScroll); + for (const type of USER_SCROLL_INPUT) el.removeEventListener(type, stop); + }; + // Content that shrinks for a moment (a diagram swapping its placeholder) clamps scrollTop to the + // new bottom; that is the browser, not someone moving the view, so the hold carries on. + const onScroll = () => { + if (el.scrollTop === applied) return; + const clamped = el.scrollTop >= el.scrollHeight - el.clientHeight - 1 && target() > el.scrollTop; + if (!clamped) { + stop(); + return; + } + applied = el.scrollTop; + onApply?.(applied); + }; + const timer = setTimeout(stop, HOLD_MS); + el.addEventListener('scroll', onScroll, { passive: true }); + for (const type of USER_SCROLL_INPUT) el.addEventListener(type, stop, { passive: true }); + return stop; +} + +/** Remembers `ref`'s scrollTop under `key` in sessionStorage and restores it when `deps` change, holding it as + * content grows (`holdScroll`). `shouldRestore` returning false skips a restore, e.g. when a deep link names the target. */ export function useScrollMemory( ref: RefObject<HTMLElement | null>, key: string, deps: DependencyList, + shouldRestore: () => boolean = () => true, ): void { // The scrollTop a restore produced; its scroll events must not save, or a // value clamped by short content would overwrite the real one. @@ -49,40 +96,9 @@ export function useScrollMemory( useLayoutEffect(() => { const el = ref.current; - if (!el) return; - const target = readSaved(key); - if (target === null) return; - const restore = () => { - el.scrollTop = target; - restoredTopRef.current = el.scrollTop; - }; - restore(); - const retry = Math.abs(el.scrollTop - target) < 1 ? 0 : requestAnimationFrame(restore); - - // The first notification is the observer's initial report, not growth. - let primed = false; - const growth = new ResizeObserver(() => { - if (primed) restore(); - primed = true; - }); - for (const child of Array.from(el.children)) growth.observe(child); - // Scroll anchoring would chase the content that grows above, and its - // scroll event would read as someone else moving the view. - el.style.overflowAnchor = 'none'; - const stop = () => { - cancelAnimationFrame(retry); - growth.disconnect(); - clearTimeout(timer); - el.style.removeProperty('overflow-anchor'); - el.removeEventListener('scroll', onScroll); - for (const type of USER_SCROLL_INPUT) el.removeEventListener(type, stop); - }; - const onScroll = () => { - if (el.scrollTop !== restoredTopRef.current) stop(); - }; - const timer = setTimeout(stop, HOLD_MS); - el.addEventListener('scroll', onScroll, { passive: true }); - for (const type of USER_SCROLL_INPUT) el.addEventListener(type, stop, { passive: true }); - return stop; + if (!el || !shouldRestore()) return; + const saved = readSaved(key); + if (saved === null) return; + return holdScroll(el, () => saved, top => { restoredTopRef.current = top; }); }, deps); // eslint-disable-line react-hooks/exhaustive-deps } diff --git a/src/app/logic/change-marks.ts b/src/app/logic/change-marks.ts index 387804a..a4f4f62 100644 --- a/src/app/logic/change-marks.ts +++ b/src/app/logic/change-marks.ts @@ -1,5 +1,5 @@ import type { ColumnDef } from '../../model/parse'; -import type { ChangeKind, ChangeReview } from '../../model/change-review'; +import type { ChangeKind, ChangeNote, ChangeReview, ReviewedItem } from '../../model/change-review'; import type { FlowDiagram } from '../../flows/flow-parse'; /** Gaussian standard deviation of a change glow, in diagram units; graph and flow glows share it so they match. */ @@ -10,13 +10,47 @@ export const GLOW_SPREAD = 3; /** How one rendered element changed since the last approval. */ export type ChangeMark = { change: ChangeKind; unexplained: boolean }; -export type EntityChanges = { +/** A mark with the review items behind it and the change notes that cover any of them. */ +export type ExplainedChanges = { mark: ChangeMark; + items: ReviewedItem[]; + notes: ChangeNote[]; +}; + +export type EntityChanges = ExplainedChanges & { columns: Map<string, ChangeMark>; /** Columns gone from the current model, as the baseline defined them. */ removedColumns: [string, ColumnDef][]; }; +function coveringNotes(review: ChangeReview, items: ReviewedItem[]): ChangeNote[] { + const files = new Set(items.flatMap(item => item.notes)); + return review.notes.filter(note => files.has(note.file)); +} + +/** Per flow node, keyed `proc:<id>`, `ext:<id>`, or `<store kind>:<name>`: items naming the node in any diagram, + * and, for a process, the item adding or removing the diagram it opens. */ +export function flowNodeChanges(review: ChangeReview): Map<string, ExplainedChanges> { + const marks = new Map<string, ChangeMark>(); + const byNode = new Map<string, ReviewedItem[]>(); + for (const item of review.items) { + if (item.scope !== 'flow') continue; + const token = item.node === undefined + ? `proc:${item.diagram}` + : item.nodeKind === 'process' ? `proc:${item.node}` + : item.nodeKind === 'external' ? `ext:${item.node}` + : `${item.storeKind}:${item.node}`; + addMark(marks, token, item.node === undefined ? 'changed' : item.kind, item.status === 'unexplained'); + byNode.set(token, [...(byNode.get(token) ?? []), item]); + } + const result = new Map<string, ExplainedChanges>(); + for (const [token, nodeItems] of byNode) { + const mark = marks.get(token); + if (mark) result.set(token, { mark, items: nodeItems, notes: coveringNotes(review, nodeItems) }); + } + return result; +} + /** An added or removed mark outranks `changed`; unexplained is sticky. */ export function addMark(marks: Map<string, ChangeMark>, id: string, change: ChangeKind, unexplained: boolean): ChangeMark { const prev = marks.get(id); @@ -36,14 +70,16 @@ export function entityChanges(review: ChangeReview): Map<string, EntityChanges> if (item.scope === 'relationship' || item.scope === 'flow') continue; const unexplained = item.status === 'unexplained'; const mark = addMark(entityMarks, item.entity, item.scope === 'entity' ? item.kind : 'changed', unexplained); - const entry: EntityChanges = byEntity.get(item.entity) ?? { mark, columns: new Map(), removedColumns: [] }; + const entry: EntityChanges = byEntity.get(item.entity) ?? { mark, columns: new Map(), removedColumns: [], items: [], notes: [] }; entry.mark = mark; + entry.items.push(item); if (item.scope === 'column') { addMark(entry.columns, item.column, item.kind, unexplained); if (item.def) entry.removedColumns.push([item.column, item.def]); } byEntity.set(item.entity, entry); } + for (const entry of byEntity.values()) entry.notes = coveringNotes(review, entry.items); return byEntity; } diff --git a/src/app/logic/doc-resolver.ts b/src/app/logic/doc-resolver.ts index 61701a2..e2c70ed 100644 --- a/src/app/logic/doc-resolver.ts +++ b/src/app/logic/doc-resolver.ts @@ -1,5 +1,8 @@ import type { Model } from '../../model/parse'; import type { FlowDiagram, FlowProcess, FlowExternal, FlowStoreRef } from '../../flows/flow-parse'; +import { diagramRef } from '../../flow-view/flow-nav'; +import { SYNTHETIC_DIAGRAM_IDS } from '../../flows/flow-derive-levels'; +import { targetCandidates } from '../../model/wikilink'; export type FlowDoc = { title: string; bodyHtml: string }; @@ -10,11 +13,27 @@ export type FlowDoc = { title: string; bodyHtml: string }; * open the facts-rich FlowNodeModal (I/O table or kind+refs + markdown body). * - `doc` — token maps to a plain markdown doc with no structured node (wiki-links * that cannot be enriched, or the empty-state fallback for absent entities). + * - `diagram` — a bare `[[wiki-link]]` names a process that owns a sub-DFD; open that + * diagram (a `dfd=` reference) in the Flows view. + * + * A process `node` result carries `inDiagramRef`, the diagram it is drawn in, and a decomposed + * process also its sub-DFD's `diagramRef`. */ export type FlowDocResult = | { kind: 'entity'; entityId: string } - | { kind: 'node'; node: FlowProcess | FlowExternal | FlowStoreRef; allProcesses: FlowProcess[]; doc: FlowDoc } - | { kind: 'doc'; doc: FlowDoc }; + | { kind: 'node'; node: FlowProcess | FlowExternal | FlowStoreRef; allProcesses: FlowProcess[]; doc: FlowDoc; diagramRef?: string; inDiagramRef?: string } + | { kind: 'doc'; doc: FlowDoc } + | { kind: 'diagram'; diagramRef: string }; + +/** The doc token that names a flow node: `proc:<id>`, `ext:<id>`, or `<store kind>:<name>`. */ +export function nodeToken(node: FlowProcess | FlowExternal | FlowStoreRef): string { + if ('inputs' in node) return `proc:${node.id}`; + if ('displayName' in node) return `${node.kind}:${node.name}`; + return `ext:${node.id}`; +} + +/** `opened` a node dialog (its token and title), `closed` it, or `left` it for another screen or dialog. */ +export type NodeDialogListener = (dialog: { token: string; title: string } | null, reason: 'opened' | 'closed' | 'left') => void; /** Split a doc token into kind prefix + bare name. A bare token (from a * `[[wiki-link]]`) has no kind prefix. */ @@ -31,6 +50,8 @@ export function splitDocToken(token: string): { kind: string | null; name: strin * SelectedEntityModal. * - `{kind:'node', node, allProcesses, doc}` for process / external / non-`db` * store tokens — the caller opens the facts-rich FlowNodeModal. + * - `{kind:'diagram', diagramRef}` for a bare token naming a process that owns + * a sub-DFD — the caller opens that diagram. * - `null` for tokens that cannot be resolved in any namespace (absent * entity, unknown process, etc.) — caller shows an empty-state fallback. * @@ -56,12 +77,19 @@ export function buildFlowDocResolver( // Flat list of all processes (needed for FlowIoTable's allProcesses param). const allProcesses: FlowProcess[] = []; - function walk(d: FlowDiagram) { + const diagramRefByProcess = new Map<string, string>(); + const inDiagramRefByProcess = new Map<string, string>(); + + function walk(d: FlowDiagram, path: FlowDiagram[]) { + const here = [...path, d]; for (const p of d.processes) { if (!processById.has(p.id)) { processById.set(p.id, p); allProcesses.push(p); } + if (!SYNTHETIC_DIAGRAM_IDS.has(d.id) && !inDiagramRefByProcess.has(p.id)) inDiagramRefByProcess.set(p.id, diagramRef(here)); + const sub = d.subDfds.find(child => child.id === p.id); + if (sub && !diagramRefByProcess.has(p.id)) diagramRefByProcess.set(p.id, diagramRef([...here, sub])); } for (const e of d.externals) { if (!externalById.has(e.id)) externalById.set(e.id, e); @@ -72,9 +100,20 @@ export function buildFlowDocResolver( if (!storesByName.has(s.name)) storesByName.set(s.name, s); } } - for (const sub of d.subDfds) walk(sub); + for (const sub of d.subDfds) walk(sub, here); + } + for (const d of diagrams) walk(d, []); + + function processResult(proc: FlowProcess): FlowDocResult { + return { + kind: 'node', + node: proc, + allProcesses, + doc: { title: proc.label || proc.id, bodyHtml: proc.bodyHtml }, + diagramRef: diagramRefByProcess.get(proc.id), + inDiagramRef: inDiagramRefByProcess.get(proc.id), + }; } - diagrams.forEach(walk); // Normalize: if a plain Model (or undefined) was passed, wrap it in a getter // so the resolution path below is uniform. @@ -95,8 +134,7 @@ export function buildFlowDocResolver( // Kind-qualified: resolve against that namespace first. if (kind === 'proc') { const proc = processById.get(name); - if (!proc) return null; - return { kind: 'node', node: proc, allProcesses, doc: { title: proc.label || proc.id, bodyHtml: proc.bodyHtml } }; + return proc ? processResult(proc) : null; } if (kind === 'ext') { const ext = externalById.get(name); @@ -111,15 +149,18 @@ export function buildFlowDocResolver( const store = storesByKindName.get(`${kind}:${name}`); if (store) return { kind: 'node', node: store, allProcesses, doc: { title: store.displayName, bodyHtml: store.bodyHtml ?? '' } }; } - // Bare token (wiki-link): entity check first — business narratives most - // often cross-link entities — then flow node namespaces. - if (entityIds.has(name)) return { kind: 'entity', entityId: name }; - const proc = processById.get(name); - if (proc) return { kind: 'node', node: proc, allProcesses, doc: { title: proc.label || proc.id, bodyHtml: proc.bodyHtml } }; - const ext = externalById.get(name); - if (ext) return { kind: 'node', node: ext, allProcesses, doc: { title: ext.label, bodyHtml: ext.bodyHtml } }; - const store = storesByName.get(name); - if (store) return { kind: 'node', node: store, allProcesses, doc: { title: store.displayName, bodyHtml: store.bodyHtml ?? '' } }; + // Bare token (wiki-link): entity first, since narratives mostly cross-link entities, then flow nodes. + for (const id of targetCandidates(name)) { + if (entityIds.has(id)) return { kind: 'entity', entityId: id }; + const ref = diagramRefByProcess.get(id); + if (ref !== undefined) return { kind: 'diagram', diagramRef: ref }; + const proc = processById.get(id); + if (proc) return processResult(proc); + const ext = externalById.get(id); + if (ext) return { kind: 'node', node: ext, allProcesses, doc: { title: ext.label, bodyHtml: ext.bodyHtml } }; + const store = storesByName.get(id); + if (store) return { kind: 'node', node: store, allProcesses, doc: { title: store.displayName, bodyHtml: store.bodyHtml ?? '' } }; + } return null; }; } diff --git a/src/app/logic/history.ts b/src/app/logic/history.ts new file mode 100644 index 0000000..5bfab47 --- /dev/null +++ b/src/app/logic/history.ts @@ -0,0 +1,38 @@ +import { parseHash, serializeHash } from '../hash-router'; +import type { HashState } from '../hash-router'; + +// A view switch to Flows is followed at once by the diagram it lands on; a push this soon +// after a view-switch push joins it, so Back steps over both. Other pushes stay separate. +const COALESCE_MS = 50; +let lastViewSwitchAt = Number.NEGATIVE_INFINITY; + +/** Params that belong to one screen and never carry over to the next navigation. */ +const SCREEN_PARAMS = ['entity', 'node', 'section'] as const; + +export function currentHash(): HashState { + return parseHash(location.hash); +} + +/** Writes `state` as the hash; `push` adds a history entry, `replace` rewrites the current one. + * `viewSwitch` marks a push that a diagram push may follow and join. */ +export function writeHash(state: HashState, mode: 'push' | 'replace', viewSwitch = false): void { + const serialized = serializeHash(state); + const url = serialized ? `#${serialized}` : location.pathname; + if (url === location.hash || (serialized === '' && location.hash === '')) return; + const now = performance.now(); + if (mode === 'replace') { + history.replaceState({}, '', url); + return; + } + const joinsViewSwitch = !viewSwitch && now - lastViewSwitchAt < COALESCE_MS; + lastViewSwitchAt = viewSwitch ? now : Number.NEGATIVE_INFINITY; + if (joinsViewSwitch) history.replaceState({}, '', url); + else history.pushState({}, '', url); +} + +/** The current hash without the screen params, as the base of a navigation to another screen. */ +export function navigationBase(): HashState { + const base: HashState = { ...currentHash() }; + for (const key of SCREEN_PARAMS) delete base[key]; + return base; +} diff --git a/src/app/logic/mermaid-render.ts b/src/app/logic/mermaid-render.ts index 1802a45..70cdd0c 100644 --- a/src/app/logic/mermaid-render.ts +++ b/src/app/logic/mermaid-render.ts @@ -39,6 +39,11 @@ export function loadMermaid(): Promise<IgnatiusMermaid> { return loading; } +/** A rendered diagram's markup under a new render id, so a second copy in the page keeps its own scoped styles. */ +export function cloneDiagramMarkup(svg: SVGSVGElement): string { + return svg.id ? withFreshId({ id: svg.id, svg: svg.outerHTML }) : svg.outerHTML; +} + function pruneCache(root: ParentNode): void { const live = new Set( Array.from(root.querySelectorAll<HTMLElement>('.mermaid-diagram')).map(el => el.dataset.source ?? el.textContent ?? ''), diff --git a/src/app/styles.css b/src/app/styles.css index 96be05a..3b0c6fd 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -431,10 +431,6 @@ a { border-radius: 8px; } -/* Settings modal */ -.settings-modal { - max-width: 520px; -} .settings-section + .settings-section { margin-top: 20px; } @@ -591,14 +587,38 @@ label.settings-item:hover { z-index: 220; } +/* Carries the width so the resize handles stay on the edges while `.modal` scrolls. */ +.modal-frame { + position: relative; + max-width: 720px; + width: 90%; +} + +.modal-frame:has(> .settings-modal) { + max-width: 520px; +} + +.modal-resize { + position: absolute; + top: 12px; + bottom: 12px; + width: 10px; + cursor: ew-resize; + touch-action: none; +} +.modal-resize--left { left: -5px; } +.modal-resize--right { right: -5px; } +.modal-resize:hover, +.modal-resize:active { + background: linear-gradient(to right, transparent 4px, var(--color-border) 4px, var(--color-border) 6px, transparent 6px); +} + .modal { position: relative; background: var(--color-background); border: 1px solid var(--color-border); border-radius: 12px; padding: 32px; - max-width: 720px; - width: 90%; max-height: 80vh; overflow-y: auto; overflow-x: hidden; @@ -2049,6 +2069,94 @@ button.changes-panel-item:hover { box-shadow: 0 0 0 3px color-mix(in srgb, var(--change-unexplained) 35%, transparent); } +/* Process dialog header and Dictionary process section: the diagram it is drawn in, and its own sub-DFD. */ +.open-diagram-links { + display: inline-flex; + gap: 6px; + margin-left: auto; +} + +.open-diagram-link { + padding: 2px 10px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: none; + color: var(--color-link); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.open-diagram-link:hover { + border-color: currentColor; +} + +.change-badge-anchor { + position: relative; + display: inline-flex; +} + +.change-badge--button { + background: none; + font-family: inherit; + cursor: pointer; +} + +.change-popover { + position: fixed; + z-index: 230; + overflow-y: auto; + padding: 12px 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-background); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + color: var(--color-text); + font-size: 13px; + font-weight: normal; + letter-spacing: normal; + text-transform: none; + white-space: normal; +} + +.change-popover h4 { + margin: 0 0 6px; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.change-popover-items { + margin: 0 0 12px; + padding-left: 18px; +} + +.change-popover-unexplained { + color: var(--change-unexplained-text); +} + +.change-popover-empty { + margin: 0; + color: var(--color-text-muted); +} + +.change-popover-note + .change-popover-note { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--color-surface-alt); +} + +.change-popover-note-title { + margin-bottom: 4px; + font-weight: 600; +} + +.change-popover-note-title code { + font-weight: normal; + color: var(--color-text-muted); +} + .change-row--added { --change-row-color: var(--change-added-text); } @@ -2090,7 +2198,7 @@ button.changes-panel-item:hover { } .dict-entity-section--removed > :not(.dict-entity-header), -.dict-entity-section--removed > .dict-entity-header > :not(.change-badge) { +.dict-entity-section--removed > .dict-entity-header > :not(.change-badge, .change-badge-anchor) { opacity: 0.6; } @@ -3125,7 +3233,7 @@ button.changes-panel-item:hover { font-size: 12px; } -.modal-json { +.modal-frame:has(> .modal-json) { max-width: 860px; } @@ -3336,6 +3444,97 @@ button.changes-panel-item:hover { font-size: 0.9em; } +/* Rendered diagrams open in the zoom dialog on click. */ +.mermaid-diagram[data-rendered] > svg { + cursor: zoom-in; +} + +.modal-frame:has(> .mermaid-zoom-modal) { + max-width: 96vw; + width: 96vw; +} + +.mermaid-zoom-modal { + display: flex; + flex-direction: column; + height: 90vh; + max-height: 90vh; + overflow: hidden; + padding: 24px; +} + +.mermaid-zoom-modal .modal-header { + margin-bottom: 12px; + padding-bottom: 8px; +} + +.mermaid-zoom { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + min-height: 0; +} + +.mermaid-zoom-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.mermaid-zoom-controls button { + min-width: 36px; + padding: 4px 10px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; +} + +.mermaid-zoom-level { + margin-left: 6px; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.mermaid-zoom-viewport { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 10px; + background-color: var(--color-background); + background-image: radial-gradient(var(--color-border) 1px, transparent 1px); + background-size: 18px 18px; + cursor: grab; + touch-action: none; + line-height: normal; +} + +.mermaid-zoom-viewport:active { + cursor: grabbing; +} + +.mermaid-zoom-content { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.mermaid-zoom-content svg { + display: block; + overflow: visible; +} + +/* Same label reset as `.md .mermaid-diagram`: the copy sits outside `.md`, but the host page's margins still reach it. */ +.mermaid-zoom-content :is(p, ul, ol) { + margin: 0; + line-height: 1.5; +} + /* ── CP7: Entity ↔ Process cross-reference ── */ /* DD processes table (appears after examples in DictEntitySection) */ diff --git a/src/app/views/dict/DictionaryView.tsx b/src/app/views/dict/DictionaryView.tsx index eccc1e7..8d679c5 100644 --- a/src/app/views/dict/DictionaryView.tsx +++ b/src/app/views/dict/DictionaryView.tsx @@ -22,7 +22,12 @@ 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 { useScrollMemory } from '../../hooks/useScrollMemory'; +import { holdScroll, useScrollMemory } from '../../hooks/useScrollMemory'; +import { ChangeBadge } from '../../components/changes/ChangeBadge'; +import { flowNodeChanges } from '../../logic/change-marks'; +import { splitSection } from '../../hash-router'; +import { nodeToken } from '../../logic/doc-resolver'; +import type { NodeDialogListener } from '../../logic/doc-resolver'; import type { FlowDocResult } from '../../logic/doc-resolver'; import { SpotlightOverlay } from '../../components/entity/SpotlightOverlay'; import { EntityCard } from '../../components/entity/EntityCard'; @@ -57,8 +62,55 @@ function readStoredLens(): 'read' | 'browse' { export interface DictionaryViewHandle { toggleLens(): void; focusSearch(): void; + /** Open the flow-node dialog for a token (Back/Forward onto a node= entry). */ + openFlowNode(token: string): void; + /** Close the flow-node dialog without reporting it. */ + dismissFlowNode(): void; + /** The section at the top of the view, as `section=` names it, or null. */ + topSection(): string | null; + scrollToTop(): void; } +const SECTION_MEMORY_KEY = 'ignatius-scroll-dict-section'; + +function rememberSection(section: string): void { + try { + sessionStorage.setItem(SECTION_MEMORY_KEY, section); + } catch {} +} + +/** Whether this tab's remembered Dictionary scrollTop was taken at `section`: then a reload restores that exact + * position, and a section= link from elsewhere still jumps to the section's top. */ +export function scrollMemoryCovers(section: string): boolean { + try { + return sessionStorage.getItem(SECTION_MEMORY_KEY) === section; + } catch { + return false; + } +} + +/** Dictionary anchor prefixes, in the order a bare link id is looked up. */ +const SECTION_KINDS = ['entity', 'external', 'store', 'process'] as const; +const SECTION_ID = /^(entity|external|store|process)-/; + +/** The container's top edge below the floating search bar: its `scroll-padding-top`, which `scrollIntoView` honors too. */ +function visibleTop(container: HTMLElement): number { + return container.getBoundingClientRect().top + (Number.parseFloat(getComputedStyle(container).scrollPaddingTop) || 0); +} + +/** The last Dictionary section whose top is at or above the visible top edge. */ +function sectionAtTop(container: HTMLElement): string | null { + const top = visibleTop(container) + 8; + let current: Element | null = null; + for (const el of Array.from(container.querySelectorAll('section[id]'))) { + if (!SECTION_ID.test(el.id)) continue; + if (el.getBoundingClientRect().top > top) break; + current = el; + } + return current ? current.id.replace(SECTION_ID, '$1:') : null; +} + + interface DictionaryViewProps { model: Model; modelIndex: ModelIndex | null; @@ -71,6 +123,15 @@ interface DictionaryViewProps { onToggleNav: () => void; active: boolean; onOpenEntity: (id: string) => void; + /** Takes a `dfd=` reference and shows that diagram in the Flows view. */ + onOpenDiagram: (ref: string) => void; + /** The section= to bring into view; `seq` changes on every request, so the same section can be asked for again. */ + sectionTarget: { section: string; seq: number } | null; + /** A link or nav click inside the Dictionary asks to show a section (`process:Collect-Payment`). */ + onJumpToSection: (section: string) => void; + /** The section at the top of the scrolled view changed. */ + onSectionInView: (section: string) => void; + onNodeDialogChange: NodeDialogListener; themeMode: 'dark' | 'light'; /** Changes to mark; null hides every marker and the Removed section. */ changeReview: ChangeReview | null; @@ -90,6 +151,11 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( onToggleNav, active, onOpenEntity, + onOpenDiagram, + sectionTarget, + onJumpToSection, + onSectionInView, + onNodeDialogChange, themeMode, changeReview, entityChanges, @@ -140,7 +206,9 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( const dictViewRef = useRef<HTMLDivElement>(null); const navPanelRef = useRef<HTMLElement>(null); - useScrollMemory(dictViewRef, 'ignatius-scroll-dict', [active, model]); + // A section jump runs once (its seq); after it, and without one, the remembered scrollTop restores. + const consumedSeqRef = useRef(-1); + useScrollMemory(dictViewRef, 'ignatius-scroll-dict', [active, model], () => sectionTarget === null || sectionTarget.seq === consumedSeqRef.current); useScrollMemory(navPanelRef, 'ignatius-scroll-dict-nav', [dictNavOpen, model]); useEffect(() => { @@ -161,7 +229,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( } // Expose imperative toggleLens for the keyboard shortcut hook (via shell's dictViewRef). - // Deps include `lens` and `switchLens` so the handle always reads the CURRENT lens. + // No deps: the handle rebuilds every render, so it reads the current lens and resolver. useImperativeHandle(ref, () => ({ toggleLens() { switchLens(lens === 'read' ? 'browse' : 'read'); @@ -169,7 +237,65 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( focusSearch() { searchInputRef.current?.focus(); }, - }), [lens]); // eslint-disable-line react-hooks/exhaustive-deps + openFlowNode(token: string) { + openFlowNode(token); + }, + dismissFlowNode() { + setOpenFlowResult(null); + }, + topSection() { + return dictViewRef.current ? sectionAtTop(dictViewRef.current) : null; + }, + scrollToTop() { + if (dictViewRef.current) dictViewRef.current.scrollTop = 0; + }, + })); + + // Hold the requested section at the top while diagrams and examples above it render and grow. + // The scrollTop the hold last produced; scrolling there is the hold, not the reader. + const heldTopRef = useRef<number | null>(null); + useLayoutEffect(() => { + const container = dictViewRef.current; + if (!active || !container || !sectionTarget || sectionTarget.seq === consumedSeqRef.current) return; + const { section, seq } = sectionTarget; + // Consumed even when nothing matches (a stale link, a section the search hides), so scroll memory resumes. + consumedSeqRef.current = seq; + const target = splitSection(section); + const el = target && document.getElementById(`${target.kind}-${target.id}`); + if (!el) return; + return holdScroll( + container, + () => container.scrollTop + el.getBoundingClientRect().top - visibleTop(container), + top => { + heldTopRef.current = top; + rememberSection(section); + }, + ); + }, [active, sectionTarget?.seq, model]); // eslint-disable-line react-hooks/exhaustive-deps + + // Report the section at the top of the view as the user scrolls, so a refresh returns there. + useEffect(() => { + const container = dictViewRef.current; + if (!active || !container) return; + let frame = 0; + const report = () => { + frame = 0; + if (container.scrollTop === heldTopRef.current) return; + heldTopRef.current = null; + const section = sectionAtTop(container); + if (!section) return; + rememberSection(section); + onSectionInView(section); + }; + const onScroll = () => { + if (frame === 0) frame = requestAnimationFrame(report); + }; + container.addEventListener('scroll', onScroll, { passive: true }); + return () => { + container.removeEventListener('scroll', onScroll); + cancelAnimationFrame(frame); + }; + }, [active, onSectionInView]); // Spotlight active id — may be a bare entity id or a "<kind>:<name>" flow-node token. // Detection: flow-node tokens always contain ":" (entity ids are PascalCase, no colon). @@ -460,25 +586,16 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( return a.localeCompare(b); }); - // Scroll-to-anchor navigation (anchor links within the dict panel). + // Dictionary links are navigations: each asks the shell for a section= entry, and the + // shell's sectionTarget brings it into view. function scrollToEntity(entityId: string) { - const el = document.getElementById(`entity-${entityId}`); - if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); + onJumpToSection(`entity:${entityId}`); } - // Generalized scroll: resolves the correct DD section anchor regardless of - // kind (entity / external / store / process). Used by body wiki-link clicks so - // [[Customer]] (an external) reaches #external-Customer even though the href - // written by the wiki plugin always says #entity-*. + // A link's href always says #entity-*; [[Customer]] (an external) must still reach #external-Customer. function scrollToSection(id: string) { - const prefixes = ['entity', 'external', 'store', 'process'] as const; - for (const prefix of prefixes) { - const el = document.getElementById(`${prefix}-${id}`); - if (el) { - el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); - return; - } - } + const kind = SECTION_KINDS.find(prefix => document.getElementById(`${prefix}-${id}`) !== null); + if (kind) onJumpToSection(`${kind}:${id}`); } function scrollToMissing(id: string) { @@ -486,9 +603,29 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); } - // Delegates to the shared module-level resolveBodyClick with the local scrollToSection. + function followBodyLink(id: string) { + const result = resolveFlowDoc(id); + if (result?.kind === 'diagram') onOpenDiagram(result.diagramRef); + else scrollToSection(id); + } + function handleDdBodyClick(e: React.MouseEvent<HTMLDivElement>) { - resolveBodyClick(e, scrollToSection); + resolveBodyClick(e, followBodyLink); + } + + function processDiagramOpeners(processId: string): { onShowInDiagram?: () => void; onOpenSubDiagram?: () => void } { + const result = resolveFlowDoc(`proc:${processId}`); + if (result?.kind !== 'node') return {}; + const { inDiagramRef, diagramRef } = result; + return { + onShowInDiagram: inDiagramRef === undefined ? undefined : () => onOpenDiagram(inDiagramRef), + onOpenSubDiagram: diagramRef === undefined ? undefined : () => onOpenDiagram(diagramRef), + }; + } + + function flowBadge(token: string) { + const changes = flowChanges?.get(token); + return <ChangeBadge mark={changes?.mark} changes={changes} diagrams={allDiagrams} />; } // Build node list filtered by search term. @@ -546,6 +683,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( // Collect all processes / externals / non-db stores from all diagrams (flat). const allDiagrams = flowDiagrams ?? []; + const flowChanges = changeReview ? flowNodeChanges(changeReview) : null; // Include sub-DFD processes recursively, skipping synthetic context/L1 diagrams // introduced by CP4 level derivation — those carry the "System" process and // empty activity stubs that are not user-authored and must not appear in the DD. @@ -710,10 +848,22 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( function openFlowNode(token: string) { const result = resolveFlowDoc(token); if (!result) return; + showFlowResult(result, token); + } + + function showFlowResult(result: FlowDocResult, token: string) { if (result.kind === 'entity') { + setOpenFlowResult(null); + onNodeDialogChange(null, 'left'); onOpenEntity(result.entityId); + } else if (result.kind === 'diagram') { + setOpenFlowResult(null); + onNodeDialogChange(null, 'left'); + onOpenDiagram(result.diagramRef); } else { setOpenFlowResult(result); + const opened = result.kind === 'node' ? nodeToken(result.node) : token; + onNodeDialogChange({ token: opened, title: result.doc.title }, 'opened'); } } @@ -855,8 +1005,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( }, [model, allExternals, allProcessesDeep, allNonDbStores, visibleSetKey, visibleProcessKey, visibleExternalKey, visibleStoreKey]); function scrollToProcess(processId: string) { - const el = document.getElementById(`process-${processId}`); - if (el) el.scrollIntoView({ behavior: scrollBehaviorWithin(el), block: 'start' }); + onJumpToSection(`process:${processId}`); } // Build side-nav subtypeIds per-group for indent styling. @@ -1354,7 +1503,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( memberCluster={modelIndex?.clustersByMemberId.get(n.id)?.[0]} nodeErrors={errorsByEntityId.get(n.id) ?? []} missingTargets={missingTargets} - onNavigate={scrollToSection} + onNavigate={followBodyLink} onNavigateMissing={scrollToMissing} processUsages={entityUsageIndex.get(n.id)} onScrollToProcess={scrollToProcess} @@ -1375,7 +1524,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( memberCluster={modelIndex?.clustersByMemberId.get(n.id)?.[0]} nodeErrors={errorsByEntityId.get(n.id) ?? []} missingTargets={missingTargets} - onNavigate={scrollToSection} + onNavigate={followBodyLink} onNavigateMissing={scrollToMissing} processUsages={entityUsageIndex.get(n.id)} onScrollToProcess={scrollToProcess} @@ -1405,7 +1554,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( memberCluster={undefined} nodeErrors={[]} missingTargets={missingTargets} - onNavigate={scrollToSection} + onNavigate={followBodyLink} onNavigateMissing={scrollToMissing} changes={entityChanges?.get(n.id)} /> @@ -1450,9 +1599,11 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( allProcesses={allProcessesDeep} procErrors={errorsByProcessId.get(proc.id) ?? []} onScrollToEntity={scrollToEntity} - onScrollToSection={scrollToSection} + onScrollToSection={followBodyLink} externalIds={ddExternalIds} nonDbStoreNames={ddNonDbStoreNames} + headerExtra={flowBadge(`proc:${proc.id}`)} + {...processDiagramOpeners(proc.id)} /> ))} </div> @@ -1465,7 +1616,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( {allExternals .filter(e => visibleExternalIds[e.id]) .map(ext => ( - <ExternalCard key={ext.id} external={ext}> + <ExternalCard key={ext.id} external={ext} headerExtra={flowBadge(`ext:${ext.id}`)}> {ext.bodyHtml && ( <div className="dict-entity-body md" @@ -1487,7 +1638,7 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( {allNonDbStores .filter(s => visibleStoreNames[s.name]) .map(store => ( - <StoreCard key={store.name} store={store}> + <StoreCard key={store.name} store={store} headerExtra={flowBadge(`${store.kind}:${store.name}`)}> {store.bodyHtml && ( <div className="dict-entity-body md" @@ -1513,16 +1664,20 @@ const DictionaryView = forwardRef<DictionaryViewHandle, DictionaryViewProps>( node={openFlowResult.node} allProcesses={openFlowResult.allProcesses} doc={openFlowResult.doc} - onClose={() => setOpenFlowResult(null)} + onClose={() => { + setOpenFlowResult(null); + onNodeDialogChange(null, 'closed'); + }} onNavigate={(token) => { const result = resolveFlowDoc(token); - if (!result) return; - if (result.kind === 'entity') { - setOpenFlowResult(null); - onOpenEntity(result.entityId); - } else { - setOpenFlowResult(result); - } + if (result) showFlowResult(result, token); + }} + diagramRef={openFlowResult.diagramRef} + inDiagramRef={openFlowResult.inDiagramRef} + onOpenDiagram={(ref) => { + setOpenFlowResult(null); + onNodeDialogChange(null, 'left'); + onOpenDiagram(ref); }} /> )} diff --git a/src/app/views/flow/FlowsView.tsx b/src/app/views/flow/FlowsView.tsx index 28068e1..e80b491 100644 --- a/src/app/views/flow/FlowsView.tsx +++ b/src/app/views/flow/FlowsView.tsx @@ -15,9 +15,9 @@ import { createLayoutStore } from '../graph/layout-store'; import { FLOW_LAYOUT_STORAGE_KEY } from '../../logic/storage-keys'; import type { PositionMap } from '../graph/layout-store'; import { buildAllFlowNodeIds } from '../../logic/flow-node-ids'; -import { buildFlowDocResolver } from '../../logic/doc-resolver'; +import { nodeToken, buildFlowDocResolver } from '../../logic/doc-resolver'; import { splitDocToken } from '../../logic/doc-resolver'; -import type { FlowDoc, FlowDocResult } from '../../logic/doc-resolver'; +import type { FlowDoc, FlowDocResult, NodeDialogListener } from '../../logic/doc-resolver'; import { buildFlowNodeUsageIndex } from '../../../flows/flow-usage-index'; import { computeElkLayout } from '../../../flow-view/elk-flow-layout'; import { screenScaleToPercent, percentToScreenScale } from '../../../flow-view/zoom-scale'; @@ -25,8 +25,9 @@ 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'; +import { parseHash } from '../../hash-router'; +import { currentHash, navigationBase, writeHash } from '../../logic/history'; +import type { FlowViewMode, FlowCollapseLevel } from '../../hash-router'; import type { Model } from '../../../model/parse'; import type { ChangeReview } from '../../../model/change-review'; import { flowNodeMarks } from '../../logic/change-marks'; @@ -46,7 +47,12 @@ import type { ProcessUsage } from '../../../flows/flow-usage-index'; // Chrome callbacks: typed interface so both initFlowGraphCore and the FlowsView // component share the same contract. The FlowChrome React component drives these // from its useImperativeHandle handle. +/** Open or silently close the flow-node dialog from outside the flow surface. */ +export type FlowDialogControls = { open: (token: string) => void; dismiss: () => void }; + export interface FlowChromeCallbacks { + /** Called when the flow-node dialog opens, closes, or gives way to another screen. */ + onNodeDialogChange?: NodeDialogListener; /** 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. */ @@ -124,16 +130,20 @@ export interface FlowChromeCallbacks { * An absent-entity `db:` token (`null` result) shows an empty-state doc rather * than a dead badge, matching the `flow.unknown_store` finding surface. */ -export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, themeMode, allFlowNodeIds, onRegisterOpen, nodeUsageIndex, entityModel }: { +export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, onOpenDiagram, themeMode, allFlowNodeIds, onRegisterOpen, onDialogChange, nodeUsageIndex, entityModel }: { svgProps: Omit<FlowDiagramSvgProps, 'onOpenDoc' | 'onOpenContract' | 'onOpenStack' | 'themeMode'>; resolveDoc: (token: string) => FlowDocResult | null; onOpenEntity?: (id: string) => void; + /** Takes a `dfd=` reference. */ + onOpenDiagram?: (ref: string) => void; themeMode: 'dark' | 'light'; allFlowNodeIds?: ReadonlySet<string>; - /** Called once on mount with the `open` dispatcher so callers can trigger + /** Called once on mount with the dialog controls so callers can trigger * in-flow navigation from outside the component (e.g. from the app-level - * SelectedEntityModal's process-usage links when opened from the flow view). */ - onRegisterOpen?: (open: (token: string) => void) => void; + * SelectedEntityModal's process-usage links when opened from the flow view), + * and close the dialog on Back. `dismiss` reports nothing. */ + onRegisterOpen?: (controls: FlowDialogControls, registered: boolean) => void; + onDialogChange?: NodeDialogListener; /** Token-keyed usage index (buildFlowNodeUsageIndex) for CP21 Processes section * in external/store dialogs. Optional — omit to suppress the section. */ nodeUsageIndex?: ReadonlyMap<string, ProcessUsage[]>; @@ -142,7 +152,7 @@ export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, themeMode, all }) { // Active dialog state. Exactly one may be open at a time. const [openResult, setOpenResult] = useState< - | { kind: 'node'; node: FlowProcess | FlowExternal | FlowStoreRef; allProcesses: FlowProcess[]; doc: FlowDoc } + | { kind: 'node'; node: FlowProcess | FlowExternal | FlowStoreRef; allProcesses: FlowProcess[]; doc: FlowDoc; diagramRef?: string } | { kind: 'doc'; doc: FlowDoc } | { kind: 'contract'; edges: FlowEdge[]; sourceLabel: string; targetLabel: string } | { kind: 'stack'; node: StackNodeData; feederProcessLabels: string[]; processLabelById: ReadonlyMap<string, string> } @@ -160,6 +170,7 @@ export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, themeMode, all // this fallback prevents a crash and gives the user a clear signal. const { name } = splitDocToken(token); setOpenResult({ kind: 'doc', doc: { title: name, bodyHtml: '<p class="entity-link entity-link--missing">Not found in the entity catalog.</p>' } }); + onDialogChange?.({ token, title: name }, 'opened'); return; } if (result.kind === 'entity') { @@ -167,32 +178,50 @@ export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, themeMode, all // only one modal is visible at a time — both badge clicks and wiki-link // clicks in the flow dialog come through this path. setOpenResult(null); + onDialogChange?.(null, 'left'); onOpenEntity?.(result.entityId); return; } + if (result.kind === 'diagram') { + setOpenResult(null); + onDialogChange?.(null, 'left'); + onOpenDiagram?.(result.diagramRef); + return; + } setOpenResult(result); + const opened = result.kind === 'node' ? nodeToken(result.node) : token; + onDialogChange?.({ token: opened, title: result.doc.title }, 'opened'); }; openRef.current = open; - // Register the `open` dispatcher with the app level on mount so it can route + // Register the dialog controls with the app level on mount so it can route // in-flow navigation (e.g. process-usage link clicks in a flow-opened entity // dialog) without switching the view to the Dictionary or Graph. useEffect(() => { - if (onRegisterOpen) onRegisterOpen((token: string) => openRef.current(token)); + const controls: FlowDialogControls = { open: (token: string) => openRef.current(token), dismiss: () => setOpenResult(null) }; + onRegisterOpen?.(controls, true); + return () => onRegisterOpen?.(controls, false); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const close = () => setOpenResult(null); + const close = () => { + setOpenResult(null); + onDialogChange?.(null, 'closed'); + }; // Opening a contract dialog replaces whichever dialog was open — the same // single-dialog-at-a-time invariant `open` enforces for doc tokens. - const openContract = (edges: FlowEdge[], sourceLabel: string, targetLabel: string) => + const openContract = (edges: FlowEdge[], sourceLabel: string, targetLabel: string) => { setOpenResult({ kind: 'contract', edges, sourceLabel, targetLabel }); + onDialogChange?.(null, 'left'); + }; // Same invariant for a stack — clicking a member inside StackDialog routes // through `open`, which already closes whichever dialog is open first. - const openStack = (node: StackNodeData, feederProcessLabels: string[], processLabelById: ReadonlyMap<string, string>) => + const openStack = (node: StackNodeData, feederProcessLabels: string[], processLabelById: ReadonlyMap<string, string>) => { setOpenResult({ kind: 'stack', node, feederProcessLabels, processLabelById }); + onDialogChange?.(null, 'left'); + }; return ( <> @@ -207,6 +236,12 @@ export function FlowSurface({ svgProps, resolveDoc, onOpenEntity, themeMode, all allFlowNodeIds={allFlowNodeIds} canOpenToken={(tok) => resolveDoc(tok) !== null} nodeUsageIndex={nodeUsageIndex} + diagramRef={openResult.diagramRef} + onOpenDiagram={onOpenDiagram && ((ref) => { + setOpenResult(null); + onDialogChange?.(null, 'left'); + onOpenDiagram(ref); + })} /> )} {openResult?.kind === 'doc' && ( @@ -260,7 +295,7 @@ export function initFlowGraphCore( // always resolve the live entity-id set — or a plain Model for static mode. entityModel?: (() => Model | undefined) | Model, onOpenEntity?: (id: string) => void, - onRegisterOpen?: (open: (token: string) => void) => void, + onRegisterOpen?: (controls: FlowDialogControls, registered: boolean) => void, // Theme config getter so kind-colored stores resolve the live theme.flowKinds overrides. getThemeConfig?: () => ThemeConfig | undefined, // CP21: token-keyed usage index so ext/store dialogs can render Processes section. @@ -507,9 +542,11 @@ export function initFlowGraphCore( createElement(FlowSurface, { resolveDoc, onOpenEntity, + onOpenDiagram: selectDiagramById, themeMode, allFlowNodeIds, onRegisterOpen, + onDialogChange: chromeCallbacks?.onNodeDialogChange, nodeUsageIndex, entityModel: resolvedEntityModel, svgProps: { @@ -679,6 +716,8 @@ export interface FlowsViewHandle { * rendered diagram slides the opposite way. Keyboard arrow-key scrolling. */ panBy(dx: number, dy: number): void; openFlowToken(token: string): void; + /** Close the flow-node dialog without reporting it (Back already moved the URL). */ + dismissFlowDialog(): void; } // --------------------------------------------------------------------------- @@ -709,6 +748,8 @@ export interface FlowsViewProps { onActiveDiagramChange?: (id: string, isInitial: boolean) => void; /** Called whenever the flow ZoomControl readout changes. */ onZoomPercentChange: (pct: number) => void; + /** Called when a flow-node dialog opens, closes, or gives way to another screen. */ + onNodeDialogChange?: NodeDialogListener; /** * Match-set for graph-flow-search CP3: base tokens that keep full opacity in * the rendered diagram, threaded through to FlowDiagramSvg's searchTokens @@ -739,6 +780,7 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( onOpenEntity, onActiveDiagramChange = undefined, onZoomPercentChange, + onNodeDialogChange, searchTokens, changeReview, flowView, @@ -777,9 +819,13 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( // Live fitScale mirror (viewBox→container ratio). Drives percent↔scale // conversion so the readout shows true zoom and setPercent(100) → 1:1. const flowFitScaleRef = useRef(1); - // In-flow open dispatcher — registered by FlowSurface on mount. The shell uses - // this to route process-usage link clicks in a flow-opened entity modal in-place. - const flowOpenRef = useRef<((token: string) => void) | null>(null); + // Registered by the mounted FlowSurface; the shell opens and closes node dialogs through it. + const flowDialogRef = useRef<FlowDialogControls | null>(null); + // A token asked for while no diagram surface is mounted (a diagram switch in progress); the next surface opens it. + const queuedTokenRef = useRef<string | null>(null); + // Read through a ref: the renderer's callbacks are built once per mount. + const onNodeDialogChangeRef = useRef(onNodeDialogChange); + onNodeDialogChangeRef.current = onNodeDialogChange; // Expose the handle to the shell. useImperativeHandle(ref, () => ({ @@ -815,7 +861,12 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( flowPanByRef.current?.(dx, dy); }, openFlowToken(token: string) { - flowOpenRef.current?.(token); + if (flowDialogRef.current) flowDialogRef.current.open(token); + else queuedTokenRef.current = token; + }, + dismissFlowDialog() { + queuedTokenRef.current = null; + flowDialogRef.current?.dismiss(); }, }), []); @@ -836,6 +887,7 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( // Wire chrome callbacks so the FlowChrome component's state stays in sync // with the imperative SVG render core. const chromeCallbacks: FlowChromeCallbacks = { + onNodeDialogChange: (dialog, reason) => onNodeDialogChangeRef.current?.(dialog, reason), onStackChange: (stack) => { flowChromeRef.current?.setStack(stack); }, @@ -867,21 +919,12 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( }, onActiveDiagramChange: (id) => { activeFlowDiagramIdRef.current = id; - // 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 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; - if (isInitial) { - history.replaceState({}, '', newHash); - } else { - history.pushState({}, '', newHash); - } + // The first activation in this effect run (auto-select or deep link, which may carry node=) rewrites + // the entry in place; later ones are navigations to another diagram. + const current = currentHash(); + const isInitial = !initialActivationDone || current.dfd === id; + if (isInitial) writeHash({ ...current, view: 'flow', dfd: id }, 'replace'); + else writeHash({ ...navigationBase(), view: 'flow', dfd: id }, 'push'); initialActivationDone = true; onActiveDiagramChange?.(id, isInitial); }, @@ -926,6 +969,9 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( // CP21: build the token-keyed usage index for ext/store Processes sections. const nodeUsageIndex = buildFlowNodeUsageIndex(diagrams); + // A rebuild (SSE reload, setting toggle) unmounts the open dialog; the new surface reopens the URL's node=. + const hashNow = currentHash(); + if (hashNow.view === 'flow' && hashNow.node) queuedTokenRef.current = hashNow.node; const cleanup = initFlowGraphCore( container, diagrams, @@ -937,7 +983,16 @@ export const FlowsView = forwardRef<FlowsViewHandle, FlowsViewProps>( themeMode, getEntityModel, stableOpenEntity, - (open) => { flowOpenRef.current = open; }, + (controls, registered) => { + if (!registered) { + if (flowDialogRef.current === controls) flowDialogRef.current = null; + return; + } + flowDialogRef.current = controls; + const queued = queuedTokenRef.current; + queuedTokenRef.current = null; + if (queued) controls.open(queued); + }, getThemeConfig, nodeUsageIndex, searchTokens, diff --git a/src/app/views/graph/GraphView.tsx b/src/app/views/graph/GraphView.tsx index 0de2d5a..3120d75 100644 --- a/src/app/views/graph/GraphView.tsx +++ b/src/app/views/graph/GraphView.tsx @@ -39,6 +39,7 @@ import { wrapEntityLabel } from './wrap-label'; import { createLayoutStore } from './layout-store'; import type { PositionMap } from './layout-store'; import { parseHash, serializeHash } from '../../hash-router'; +import { writeHash } from '../../logic/history'; import type { HashState, ViewName } from '../../hash-router'; import { buildInheritedConnections } from '../../logic/spotlight-inherited'; import { createHoverIntent } from '../../logic/motion'; @@ -1376,9 +1377,8 @@ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>( const merged: HashState = { ...next }; if (liveEntity !== undefined) merged.entity = liveEntity; else delete merged.entity; - const serialized = serializeHash(merged); - lastWrittenHash = serialized; - history.replaceState({}, '', serialized ? '#' + serialized : location.pathname); + lastWrittenHash = serializeHash(merged); + writeHash(merged, 'replace'); }, 200); } diff --git a/src/model/parse.ts b/src/model/parse.ts index 27170d5..0cc08b1 100644 --- a/src/model/parse.ts +++ b/src/model/parse.ts @@ -17,7 +17,8 @@ function isRecord(v: unknown): v is Record<string, unknown> { return typeof v === 'object' && v !== null; } -export type ColumnDef = { type: string; nullable?: boolean; desc?: string; default?: string }; +/** `descHtml` is `desc` rendered as inline markdown, `[[links]]` included; parse sets it, the files never do. */ +export type ColumnDef = { type: string; nullable?: boolean; desc?: string; default?: string; descHtml?: string }; export type Predicate = { fwd: string; rev: string }; @@ -183,6 +184,20 @@ function deriveCardinality( export type ParseResult = { model: Model; globalErrors: GlobalError[] }; +/** Ids a body may link to besides entities: a process, external, or store id is its file name. */ +async function flowNodeFileIds(dir: string, indexFileName: string): Promise<Set<string>> { + const ids = new Set<string>(); + for (const [folder, pattern] of [['flows', '**/*.md'], ['externals', '*.md'], ['stores', '*.md']] as const) { + const root = `${dir}/${folder}`; + if (!existsSync(root)) continue; + for await (const path of new Bun.Glob(pattern).scan(root)) { + const file = path.slice(path.lastIndexOf('/') + 1); + if (file !== indexFileName) ids.add(file.replace(/\.md$/, '')); + } + } + return ids; +} + export async function parseModels(dir: string): Promise<ParseResult> { // Parse optional ignatius.yml — single config file for theme, branding, and meta let theme: ThemeConfig = defaultTheme; @@ -485,20 +500,29 @@ export async function parseModels(dir: string): Promise<ParseResult> { // Build final nodes with derived classification. Render each body now that the // full id set is known, so `[[…]]` links resolve and unknown targets are // marked missing + collected for validation. - const knownIds = new Set(rawNodes.map(n => n.id)); + const entityIds = new Set(rawNodes.map(n => n.id)); + const flowNodeIds = await flowNodeFileIds(dir, indexFileName); + const knownIds = new Set([...entityIds, ...flowNodeIds]); const nodes: ModelNode[] = rawNodes.map(rawNode => { - const env: WikiLinkEnv = { knownIds, links: [] }; + const links: string[] = []; + const env: WikiLinkEnv = { knownIds, links }; const bodyHtml = md.render(rawNode.body, env); + // Column descriptions share the body's env, so their links resolve and validate the same way. + const columns: Record<string, ColumnDef> = {}; + for (const [name, column] of Object.entries(rawNode.columns)) { + columns[name] = column.desc ? { ...column, descHtml: md.renderInline(column.desc, env) } : column; + } return { id: rawNode.id, classification: deriveClassification(rawNode), group: rawNode.group, sourcePath: rawNode.sourcePath, pk: rawNode.pk, - columns: rawNode.columns, + columns, alternateKeys: rawNode.alternateKeys, bodyHtml, - bodyLinks: env.links, + // Validation checks these against entity ids, so links that reached a flow node are left out. + bodyLinks: links.filter(id => entityIds.has(id) || !flowNodeIds.has(id)), ...(rawNode.description !== undefined ? { description: rawNode.description } : {}), ...(rawNode.examples !== undefined ? { examples: rawNode.examples } : {}), ...(rawNode.singleton ? { singleton: true } : {}), diff --git a/src/model/validate.ts b/src/model/validate.ts index a397e81..3096525 100644 --- a/src/model/validate.ts +++ b/src/model/validate.ts @@ -162,8 +162,8 @@ export const RULES: Record<RuleId, RuleEntry> = { liveOnly: true, }, 'body.unknown_link': { - title: 'Body links to unknown entity', - explanation: 'A `[[Entity]]` link in the body markdown names an entity that does not exist in the model. The link renders as non-navigating text. Correct the entity id (match it exactly, PascalCase) or remove the link.', + title: 'Unknown link target', + explanation: 'A `[[Target]]` link in a body or column description names no entity, process, external, or store. The link renders as non-navigating text. Correct the target (an id, or a title whose spaces stand for hyphens) or remove the link.', class: 'A', }, 'parse.invalid_yaml': { @@ -410,7 +410,7 @@ function checkBodyLinks(node: ModelNode, nodeIds: Set<string>): EntityError[] { ruleId: 'body.unknown_link', entityId: node.id, severity: 'warning', - message: `Body links to unknown entity '${target}'.`, + message: `[[${target}]] names no entity, process, external, or store.`, }); } return errors; diff --git a/src/model/wikilink.ts b/src/model/wikilink.ts index e7b6550..9a96b7f 100644 --- a/src/model/wikilink.ts +++ b/src/model/wikilink.ts @@ -17,7 +17,7 @@ export interface WikiLinkEnv { /** Ids that resolve. Absent → links render optimistically (no missing mark). */ knownIds?: Set<string>; - /** Mutated in place: every `[[target]]` seen during the render, in order. */ + /** Mutated in place: the id each `[[target]]` resolves to (the target itself when unresolved), in order. */ links?: string[]; } @@ -60,21 +60,39 @@ export function splitWikiTarget(inner: string): { target: string; label: string return { target, label: label || target }; } +/** A file name is its title with hyphens for spaces, so `Answer Turn` names `Answer-Turn`. */ +function hyphenate(target: string): string { + return target.replace(/\s+/g, '-'); +} + +/** The ids a `[[target]]` may name, in order: the target, then its hyphenated form. */ +export function targetCandidates(target: string): string[] { + const hyphenated = hyphenate(target); + return hyphenated === target ? [target] : [target, hyphenated]; +} + +/** The known id a `[[target]]` names, or null. */ +export function canonicalTarget(target: string, knownIds: ReadonlySet<string>): string | null { + return targetCandidates(target).find(id => knownIds.has(id)) ?? null; +} + // Both take HTML-escaped values. function liveLink(target: string, label: string): string { return `<a class="entity-link" data-entity="${target}" href="#entity-${target}">${label}</a>`; } -// MISSING_LINK is a regex built from this markup: keep its literal parts free of regex metacharacters. +// LIVE_LINK and MISSING_LINK are regexes built from this markup: keep its literal parts free of regex metacharacters. function missingLink(target: string, label: string): string { return `<span class="entity-link entity-link--missing" title="Unknown entity: ${target}">${label}</span>`; } const MISSING_LINK = new RegExp(missingLink('([^"]*)', '([^<]*)'), 'g'); +const LIVE_LINK = new RegExp(liveLink('([^"]*)', '([^<]*)').replace('#entity-([^"]*)', '#entity-[^"]*'), 'g'); -/** Rewrite missing-link spans as live anchors, so two renders of one body compare equal whichever targets exist. */ +/** Rewrite every link as a live anchor on its hyphenated target, so two renders of one body compare equal whichever targets exist. */ export function linkStateNeutral(html: string): string { - return html.replace(MISSING_LINK, (_span, target: string, label: string) => liveLink(target, label)); + const neutral = (_match: string, target: string, label: string) => liveLink(hyphenate(target), label); + return html.replace(MISSING_LINK, neutral).replace(LIVE_LINK, neutral); } /** markdown-it plugin: register the `[[…]]` inline rule. */ @@ -97,14 +115,13 @@ export function wikiLinkPlugin(md: MarkdownItLike): void { if (target === '') return false; const env = state.env; - if (env.links) env.links.push(target); - const known = env.knownIds; - const missing = known ? !known.has(target) : false; + const resolved = known ? canonicalTarget(target, known) : target; + if (env.links) env.links.push(resolved ?? target); const esc = md.utils.escapeHtml; const token = state.push('html_inline', '', 0); - token.content = missing ? missingLink(esc(target), esc(label)) : liveLink(esc(target), esc(label)); + token.content = resolved === null ? missingLink(esc(target), esc(label)) : liveLink(esc(resolved), esc(label)); } state.pos = close + 2; diff --git a/test/checks/test-change-badge-popover.ts b/test/checks/test-change-badge-popover.ts new file mode 100644 index 0000000..8a0c734 --- /dev/null +++ b/test/checks/test-change-badge-popover.ts @@ -0,0 +1,158 @@ +// An entity's change badge opens a popover naming what changed and the note that explains it, or that none does; +// it stays inside the viewport and closes on Escape without closing the dialog under it. Shots: tmp/change-badge-popover/. + +import { chromium } from 'playwright'; +import { resolve, join } from 'path'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); +const FIXTURE = join(ROOT, 'models/key-inherited'); +const SHOTS = join(ROOT, 'tmp', 'change-badge-popover'); +const PORT = 3343; +const BASE = `http://localhost:${PORT}`; + +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); +} +mkdirSync(SHOTS, { recursive: true }); + +const fail = (m: string): never => { console.error('FAIL:', m); process.exit(1); }; +function assert(cond: boolean, label: string) { + if (!cond) fail(label); + console.log(` PASS ${label}`); +} + +const root = mkdtempSync(join(tmpdir(), 'ignatius-change-popover-')); +const dir = join(root, 'model'); +const leftovers = new Set([join(FIXTURE, '.ignatius'), join(FIXTURE, '.gitignore')]); +cpSync(FIXTURE, dir, { recursive: true, filter: source => !leftovers.has(source) }); + +const handle = serveCommand(dir, { port: PORT }); +const browser = await chromium.launch(); + +try { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline && !existsSync(join(dir, '.ignatius/baseline.json'))) await Bun.sleep(200); + assert(existsSync(join(dir, '.ignatius/baseline.json')), 'serve writes the baseline'); + + const personFile = join(dir, 'data/identity/Person.md'); + writeFileSync(personFile, readFileSync(personFile, 'utf8').replace(' birthdate:\n', ' nickname:\n type: text\n desc: "Added."\n birthdate:\n')); + const businessFile = join(dir, 'data/identity/Business.md'); + writeFileSync(businessFile, `${readFileSync(businessFile, 'utf8')}\nA business may trade under several names.\n`); + mkdirSync(join(dir, '.ignatius/changes'), { recursive: true }); + const issueInvoice = join(dir, 'flows/order-to-cash/Issue-Invoice.md'); + writeFileSync(issueInvoice, readFileSync(issueInvoice, 'utf8').replace('number: 2\n', 'number: 2\ndescription: "Bills the customer for a confirmed order."\n')); + const collectPayment = join(dir, 'flows/order-to-cash/Collect-Payment.md'); + writeFileSync(collectPayment, `${readFileSync(collectPayment, 'utf8')}\nRetries a declined card once.\n`); + writeFileSync(join(dir, '.ignatius/changes/invoice-description.md'), [ + '---', + 'title: Describe invoicing', + 'flows:', + ' order-to-cash: [Issue-Invoice]', + '---', + '', + 'The flow index now says what Issue Invoice does.', + '', + ].join('\n')); + writeFileSync(join(dir, '.ignatius/changes/person-nickname.md'), [ + '---', + 'title: Give people a nickname', + 'entities:', + ' Person: [nickname]', + '---', + '', + 'Support staff greet customers by nickname.', + '', + ].join('\n')); + await Bun.sleep(1_000); + + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const popover = page.locator('[data-ignatius="change-popover"]'); + + await page.goto(`${BASE}/#entity=Person`); + const badge = page.locator('.modal .change-badge--button'); + await badge.waitFor({ timeout: 15_000 }); + await badge.click(); + await popover.waitFor({ timeout: 5_000 }); + const explained = await popover.innerText(); + assert(explained.includes('Person.nickname') && explained.includes('Give people a nickname') && explained.includes('Support staff greet customers by nickname.'), + 'a noted change lists the column and the note that explains it'); + await page.screenshot({ path: join(SHOTS, 'explained.png') }); + + const topBefore = await popover.evaluate(el => el.getBoundingClientRect().top); + await page.locator('.modal').first().evaluate(el => { el.scrollTop += 40; }); + await page.waitForTimeout(200); + const moved = await popover.count() === 1 ? await popover.evaluate(el => el.getBoundingClientRect().top) : null; + assert(moved !== null && Math.abs(topBefore - moved - 40) < 2, `a scroll behind the popover moves it with its badge (${topBefore} → ${moved})`); + + await page.locator('.modal-header h1').click(); + assert(await popover.count() === 0, 'a click outside closes the popover'); + + await badge.click(); + await popover.waitFor({ timeout: 5_000 }); + await page.keyboard.press('Escape'); + assert(await popover.count() === 0 && await page.locator('.modal').count() === 1, 'Escape closes the popover and leaves the entity dialog open'); + + await page.goto(`${BASE}/#entity=Business`); + await page.reload(); + await badge.waitFor({ timeout: 15_000 }); + await badge.click(); + await popover.waitFor({ timeout: 5_000 }); + const unexplained = await popover.innerText(); + assert(unexplained.includes('no note covers this') && unexplained.includes('No change note covers these changes.'), + 'an unnoted change says no note covers it'); + await page.screenshot({ path: join(SHOTS, 'unexplained.png') }); + + // Dictionary process cards carry the same badge: a noted flow change and an unnoted one. + await page.keyboard.press('Escape'); + await page.goto(`${BASE}/#view=dict`); + await page.reload(); + const processBadge = page.locator('#process-Issue-Invoice .change-badge--button'); + await processBadge.waitFor({ timeout: 15_000 }); + await processBadge.scrollIntoViewIfNeeded(); + await processBadge.click(); + await popover.waitFor({ timeout: 5_000 }); + const flowExplained = await popover.innerText(); + assert(flowExplained.includes('Issue Invoice') && flowExplained.includes('Describe invoicing') && flowExplained.includes('The flow index now says what Issue Invoice does.'), + `a Dictionary process card explains its noted flow change (${flowExplained.replace(/\n/g, ' / ')})`); + await page.screenshot({ path: join(SHOTS, 'process-explained.png') }); + await page.keyboard.press('Escape'); + const unnotedBadge = page.locator('#process-Collect-Payment .change-badge--button'); + await unnotedBadge.scrollIntoViewIfNeeded(); + await unnotedBadge.click(); + await popover.waitFor({ timeout: 5_000 }); + assert((await popover.innerText()).includes('no note covers this'), 'a Dictionary process card flags an unnoted flow change'); + await page.keyboard.press('Escape'); + + // A badge near the bottom of the window opens its popover above it. + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await page.goto(`${BASE}/#view=dict`); + await page.reload(); + const dictBadge = page.locator('#entity-Person .change-badge--button'); + await dictBadge.waitFor({ timeout: 15_000 }); + await dictBadge.evaluate(el => { + const scroller = el.closest('[data-ignatius="dict-view"]') ?? document.scrollingElement; + const rect = el.getBoundingClientRect(); + scroller?.scrollBy(0, rect.bottom - (window.innerHeight - 24)); + }); + await page.waitForTimeout(300); + await dictBadge.click(); + await popover.waitFor({ timeout: 5_000 }); + const placed = await popover.evaluate(el => { + const rect = el.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, viewport: window.innerHeight }; + }); + assert(placed.top >= 0 && placed.bottom <= placed.viewport, `a popover opened near the bottom stays inside the viewport (${JSON.stringify(placed)})`); + await page.screenshot({ path: join(SHOTS, 'flipped.png') }); + + console.log(`\nAll change badge popover checks passed. Screenshots in ${SHOTS}`); +} finally { + await browser.close(); + handle.stop(true); + rmSync(root, { recursive: true, force: true }); +} diff --git a/test/checks/test-history-navigation.ts b/test/checks/test-history-navigation.ts new file mode 100644 index 0000000..b3a4077 --- /dev/null +++ b/test/checks/test-history-navigation.ts @@ -0,0 +1,255 @@ +// Every navigation pushes a history entry and Back returns to the screen and dialog it left; closing a dialog and +// scrolling the Dictionary rewrite the current entry. Tab titles name the view and what it shows. + +import { chromium, type Page } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync } from 'node:fs'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); +const PORT = 3347; +const BASE = `http://localhost:${PORT}`; +const NAME = 'Key-Inherited'; + +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 fail = (m: string): never => { console.error('FAIL:', m); process.exit(1); }; +function assert(cond: boolean, label: string) { + if (!cond) fail(label); + console.log(` PASS ${label}`); +} + +async function hashMatches(page: Page, pattern: RegExp): Promise<boolean> { + try { + await page.waitForFunction(source => new RegExp(source).test(location.hash), pattern.source, { timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +async function titleIs(page: Page, title: string): Promise<boolean> { + try { + await page.waitForFunction(t => document.title === t, title, { timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +const historyLength = (page: Page) => page.evaluate(() => history.length); + +async function sectionTop(page: Page, id: string): Promise<number> { + return page.evaluate((target) => { + const container = document.querySelector('[data-ignatius="dict-view"]'); + const el = document.getElementById(target); + if (!container || !el) return Number.NaN; + const padding = Number.parseFloat(getComputedStyle(container).scrollPaddingTop) || 0; + return el.getBoundingClientRect().top - container.getBoundingClientRect().top - padding; + }, id); +} + +async function openBadge(page: Page, token: string): Promise<void> { + await page.waitForSelector(`g[data-token="${token}"] g[data-ignatius="flow-info"]`, { timeout: 15_000 }); + await page.evaluate((t) => { + document.querySelector(`g[data-token="${t}"] g[data-ignatius="flow-info"]`) + ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); + }, token); + await page.locator('.modal').first().waitFor({ timeout: 5_000 }); +} + +const handle = serveCommand(join(ROOT, 'models/key-inherited'), { port: PORT }); +const browser = await chromium.launch(); + +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + // Graph entity dialog → Processes link → the Dictionary section, held in view while diagrams above render. + await page.goto(`${BASE}/#view=graph&entity=Business`); + await page.locator('.modal .modal-processes a', { hasText: 'Create Sales Order' }).first().waitFor({ timeout: 15_000 }); + assert(await titleIs(page, `DG - Business | ${NAME}`), 'an open entity dialog names the tab "DG - Business"'); + const beforeJump = await historyLength(page); + await page.locator('.modal .modal-processes a', { hasText: 'Create Sales Order' }).first().click(); + assert(await hashMatches(page, /^#view=dict.*section=process:Create-Sales-Order/), 'the Processes link navigates to section=process:Create-Sales-Order'); + assert(await historyLength(page) === beforeJump + 1, 'the jump pushes one history entry'); + await page.waitForTimeout(2_500); + const top = await sectionTop(page, 'process-Create-Sales-Order'); + assert(top >= -2 && top < 40, `Create Sales Order is at the top of the Dictionary after diagrams render (${top})`); + assert(await titleIs(page, `DD - 1.1 Create Sales Order | ${NAME}`), 'the Dictionary names the section in the tab'); + + await page.goBack(); + assert(await hashMatches(page, /view=graph.*entity=Business/), 'Back returns to the graph entry with entity=Business'); + await page.locator('.modal-header h1', { hasText: 'Business' }).waitFor({ timeout: 5_000 }); + assert(await page.locator('.modal-header h1', { hasText: 'Business' }).count() === 1, 'Back reopens the Business dialog'); + + await page.goForward(); + assert(await hashMatches(page, /section=process:Create-Sales-Order/), 'Forward returns to the section'); + await page.waitForTimeout(800); + const forwardTop = await sectionTop(page, 'process-Create-Sales-Order'); + assert(forwardTop >= -2 && forwardTop < 40, `Forward brings the section back into view (${forwardTop})`); + + // Scrolling the Dictionary rewrites section= in place, and a refresh returns there. + const beforeScroll = await historyLength(page); + await page.evaluate(() => { + const container = document.querySelector('[data-ignatius="dict-view"]'); + const el = document.getElementById('process-Issue-Invoice'); + const padding = container ? Number.parseFloat(getComputedStyle(container).scrollPaddingTop) || 0 : 0; + if (container && el) container.scrollTop += el.getBoundingClientRect().top - container.getBoundingClientRect().top - padding + 4; + }); + assert(await hashMatches(page, /section=process:Issue-Invoice/), 'scrolling rewrites section= to the section at the top'); + assert(await historyLength(page) === beforeScroll, 'scrolling adds no history entry'); + await page.reload(); + await page.waitForTimeout(2_500); + const reloadTop = await sectionTop(page, 'process-Issue-Invoice'); + assert(reloadTop >= -8 && reloadTop < 40, `a refresh returns to the scrolled position in the section (${reloadTop})`); + + // Show in diagram → the Flows view, one entry; Back returns to the Dictionary section. + const beforeDiagram = await historyLength(page); + await page.locator('#process-Issue-Invoice .open-diagram-link[data-target="in"]').click(); + assert(await hashMatches(page, /^#view=flow.*dfd=order-to-cash/), 'Show in diagram opens order-to-cash'); + assert(await historyLength(page) === beforeDiagram + 1, 'opening the diagram pushes one entry'); + assert(await titleIs(page, `DF - 1 Order To Cash | ${NAME}`), `the Flows view names the diagram in the tab (${await page.title()})`); + await page.goBack(); + assert(await hashMatches(page, /^#view=dict.*section=process:Issue-Invoice/), 'Back returns to the Dictionary section'); + + // A flow-node dialog is a history entry; closing it rewrites the entry. + await page.goto(`${BASE}/#view=flow&dfd=order-to-cash`); + await page.reload(); + const beforeDialog = await historyLength(page); + await openBadge(page, 'proc:Collect-Payment'); + assert(await hashMatches(page, /node=proc:Collect-Payment/), 'opening a process dialog writes node=proc:Collect-Payment'); + assert(await historyLength(page) === beforeDialog + 1, 'opening the dialog pushes one entry'); + assert(await titleIs(page, `DF - Collect Payment | ${NAME}`), 'an open node dialog names the tab'); + await page.keyboard.press('Escape'); + assert(await hashMatches(page, /^(?!.*node=).*dfd=order-to-cash/), 'closing the dialog drops node='); + assert(await historyLength(page) === beforeDialog + 1, 'closing the dialog adds no entry'); + + // Leaving a node dialog for an entity: Back reopens the node dialog. + await openBadge(page, 'proc:Collect-Payment'); + await page.locator('.modal a', { hasText: 'Payment' }).first().click(); + assert(await hashMatches(page, /entity=/), 'a link in the node dialog opens an entity dialog'); + await page.goBack(); + assert(await hashMatches(page, /node=proc:Collect-Payment/), 'Back returns to the node= entry'); + await page.locator('.modal-header h1', { hasText: 'Collect Payment' }).waitFor({ timeout: 5_000 }); + assert(await page.locator('.modal-header h1', { hasText: 'Collect Payment' }).count() === 1, 'Back reopens the Collect Payment dialog'); + + // A node= deep link opens its dialog. + await page.goto(`${BASE}/#view=flow&dfd=order-to-cash&node=proc:Issue-Invoice`); + await page.reload(); + await page.locator('.modal-header h1', { hasText: 'Issue Invoice' }).waitFor({ timeout: 15_000 }); + assert(await page.locator('.modal-header h1', { hasText: 'Issue Invoice' }).count() === 1, 'a node= deep link opens the Issue Invoice dialog'); + + const dictTop = () => page.evaluate(() => document.querySelector('[data-ignatius="dict-view"]')?.scrollTop ?? -1); + + // A bare view switch is one entry, and titles with no subject name only the view. + await page.goto(`${BASE}/#view=graph`); + await page.reload(); + await page.waitForTimeout(1_500); + assert(await titleIs(page, `DG | ${NAME}`), 'the Graph with nothing open is titled "DG | Key-Inherited"'); + const beforeSwitch = await historyLength(page); + await page.keyboard.press('d'); + assert(await hashMatches(page, /^#view=dict/), 'the d key switches to the Dictionary'); + assert(await historyLength(page) === beforeSwitch + 1, 'a view switch pushes one entry'); + await page.goBack(); + assert(await hashMatches(page, /^#view=graph/), 'Back returns to the Graph'); + + // Back from a jump returns to the section the reader left, even on an entry no scroll had recorded. + await page.goto(`${BASE}/#view=dict`); + await page.reload(); + await page.waitForTimeout(2_000); + const personLink = page.locator('#entity-Person p a.entity-link', { hasText: 'Party' }).first(); + await personLink.scrollIntoViewIfNeeded(); + const leaving = await page.evaluate(() => { + const container = document.querySelector('[data-ignatius="dict-view"]'); + if (!container) return null; + const edge = container.getBoundingClientRect().top + (Number.parseFloat(getComputedStyle(container).scrollPaddingTop) || 0) + 8; + const above = Array.from(container.querySelectorAll('section[id^="entity-"]')).filter(s => s.getBoundingClientRect().top <= edge); + return above[above.length - 1]?.id ?? null; + }); + await personLink.click(); + assert(await hashMatches(page, /section=entity:Party/), 'a body link jumps to section=entity:Party'); + await page.goBack(); + await page.waitForTimeout(1_000); + const backSection = leaving ?? 'none'; + const backTop = await sectionTop(page, backSection); + assert(await hashMatches(page, new RegExp(`section=${backSection.replace('-', ':')}`)) && backTop >= -2 && backTop < 40, + `Back returns to the section the reader left (${backSection}, top ${backTop})`); + + // A jump taken at the very top: Back returns to the top. + await page.goto(`${BASE}/#view=dict`); + await page.reload(); + await page.waitForTimeout(2_000); + await page.locator('[data-ignatius="dict-view"]').evaluate(el => { el.scrollTop = 0; }); + await page.waitForTimeout(300); + await page.evaluate(() => { + const link = document.querySelector<HTMLElement>('[data-ignatius="dict-view"] a.entity-link[data-entity="Business"]'); + link?.click(); + }); + assert(await hashMatches(page, /section=entity:Business/), 'a link clicked at the top jumps to Business'); + await page.goBack(); + await page.waitForTimeout(800); + assert(await dictTop() < 5, `Back from a jump taken at the top returns to the top (${await dictTop()})`); + + // After a jump, a view switch and back keeps the reader's own position, not the jump. + await page.goto(`${BASE}/#view=dict§ion=process:Create-Sales-Order`); + await page.reload(); + await page.waitForTimeout(2_500); + await page.locator('[data-ignatius="dict-view"]').evaluate(el => { el.scrollTop += 3000; }); + await page.waitForTimeout(400); + const readerTop = await dictTop(); + await page.keyboard.press('g'); + await page.waitForTimeout(500); + await page.keyboard.press('d'); + await page.waitForTimeout(1_500); + const returnedTop = await dictTop(); + assert(Math.abs(returnedTop - readerTop) < 4, `switching away and back keeps the reader's position, not the jump (${readerTop} → ${returnedTop})`); + + // An external's section names the tab by its label. + await page.goto(`${BASE}/#view=dict§ion=external:Customer`); + await page.reload(); + assert(await titleIs(page, `DD - Customer | ${NAME}`), 'an external section is titled by its label'); + + // Open sub-diagram from a process dialog: Back reopens the dialog. + await page.goto(`${BASE}/#view=flow&dfd=order-to-cash`); + await page.reload(); + await openBadge(page, 'proc:Create-Sales-Order'); + await page.locator('.modal .open-diagram-link[data-target="sub"]').click(); + assert(await hashMatches(page, /dfd=[^&]*Create-Sales-Order/), 'Open sub-diagram shows the sub-DFD'); + await page.goBack(); + await page.locator('.modal-header h1', { hasText: 'Create Sales Order' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /node=proc:Create-Sales-Order/), 'Back from the sub-diagram returns to the dialog entry and reopens it'); + + // In the Flows view, an entity dialog's Processes link opens the process dialog; Back reopens the entity dialog. + await page.goto(`${BASE}/#view=flow&entity=PaymentMethod&dfd=order-to-cash`); + await page.reload(); + const flowProcessLink = page.locator('.modal .modal-processes a', { hasText: 'Collect Payment' }).first(); + await flowProcessLink.waitFor({ timeout: 15_000 }); + await flowProcessLink.click(); + await page.locator('.modal-header h1', { hasText: 'Collect Payment' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /^(?!.*entity=).*node=proc:Collect-Payment/), 'the Processes link opens the process dialog in the Flows view'); + await page.goBack(); + await page.locator('.modal-header h1', { hasText: 'PaymentMethod' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /view=flow.*entity=PaymentMethod/), 'Back reopens the PaymentMethod dialog in the Flows view'); + + // A node dialog over the Dictionary: deep link, close, and Back from an entity it links to. + await page.goto(`${BASE}/#view=dict&node=proc:Collect-Payment`); + await page.reload(); + await page.locator('.modal-header h1', { hasText: 'Collect Payment' }).waitFor({ timeout: 15_000 }); + assert(await titleIs(page, `DD - Collect Payment | ${NAME}`), 'a node dialog over the Dictionary names the tab'); + await page.locator('.modal a', { hasText: 'PaymentMethod' }).first().click(); + assert(await hashMatches(page, /entity=PaymentMethod/), 'a link in the Dictionary node dialog opens the entity dialog'); + await page.goBack(); + await page.locator('.modal-header h1', { hasText: 'Collect Payment' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /view=dict.*node=proc:Collect-Payment/), 'Back reopens the node dialog over the Dictionary'); + await page.keyboard.press('Escape'); + assert(await hashMatches(page, /^(?!.*node=)#view=dict/), 'closing the node dialog over the Dictionary drops node='); + + console.log('\nAll history navigation checks passed.'); +} finally { + await browser.close(); + handle.stop(true); +} diff --git a/test/checks/test-mermaid-zoom-modal-resize.ts b/test/checks/test-mermaid-zoom-modal-resize.ts new file mode 100644 index 0000000..61207ee --- /dev/null +++ b/test/checks/test-mermaid-zoom-modal-resize.ts @@ -0,0 +1,116 @@ +// A Mermaid diagram opens in a zoomable dialog on click, from a page or from inside another dialog; +// a dialog's edge drags it wider, and the width is saved. Screenshots land in tmp/mermaid-zoom-modal-resize/. + +import { chromium, type Page } from 'playwright'; +import { resolve, join } from 'path'; +import { existsSync, mkdirSync } from 'node:fs'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); +const SHOTS = join(ROOT, 'tmp', 'mermaid-zoom-modal-resize'); +const PORT = 3341; +const BASE = `http://localhost:${PORT}`; + +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); +} +mkdirSync(SHOTS, { recursive: true }); + +const fail = (m: string): never => { console.error('FAIL:', m); process.exit(1); }; +function assert(cond: boolean, label: string) { + if (!cond) fail(label); + console.log(` PASS ${label}`); +} + +async function boxOf(page: Page, selector: string) { + return (await page.locator(selector).first().boundingBox()) ?? fail(`${selector} has no box`); +} + +const handle = serveCommand(join(ROOT, 'models/key-inherited'), { port: PORT }); +const browser = await chromium.launch(); + +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const viewport = page.locator('[data-ignatius="mermaid-zoom-viewport"]'); + const level = page.locator('.mermaid-zoom-level'); + + // A diagram on the Dictionary page. + await page.goto(`${BASE}/#view=dict`); + const diagram = page.locator('#process-Collect-Payment .mermaid-diagram[data-rendered] > svg').first(); + await diagram.waitFor({ timeout: 15_000 }); + await diagram.scrollIntoViewIfNeeded(); + await diagram.click(); + await viewport.waitFor({ timeout: 5_000 }); + assert(await viewport.locator('svg').count() === 1, 'clicking a Dictionary diagram opens it in the zoom dialog'); + await page.screenshot({ path: join(SHOTS, 'zoom-fit.png') }); + + const fitLevel = await level.textContent(); + const box = await boxOf(page, '[data-ignatius="mermaid-zoom-viewport"]'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -600); + await page.waitForTimeout(200); + const zoomedLevel = await level.textContent(); + assert(fitLevel !== zoomedLevel, `the wheel zooms (${fitLevel} → ${zoomedLevel})`); + await page.screenshot({ path: join(SHOTS, 'zoom-wheel.png') }); + + await page.keyboard.press('Escape'); + assert(await viewport.count() === 0, 'Escape closes the zoom dialog'); + + // A diagram inside a process dialog: the dialog frame stops click propagation, so the listener must capture. + await page.goto(`${BASE}/#view=flow&dfd=order-to-cash`); + await page.reload(); + await page.waitForSelector('g[data-token="proc:Collect-Payment"] g[data-ignatius="flow-info"]', { timeout: 15_000 }); + await page.evaluate(() => { + document.querySelector('g[data-token="proc:Collect-Payment"] g[data-ignatius="flow-info"]') + ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); + }); + const inDialog = page.locator('.modal .mermaid-diagram[data-rendered] > svg').first(); + await inDialog.waitFor({ timeout: 10_000 }); + await inDialog.scrollIntoViewIfNeeded(); + await inDialog.click(); + await viewport.waitFor({ timeout: 5_000 }); + assert(await viewport.count() === 1, 'clicking a diagram inside a process dialog opens the zoom dialog'); + await page.keyboard.press('Escape'); + assert(await viewport.count() === 0 && await page.locator('.modal').count() === 1, 'Escape closes only the zoom dialog, leaving the process dialog open'); + + // Resize. + await page.evaluate(() => localStorage.removeItem('ignatius-modal-width:content')); + await page.keyboard.press("Escape"); + await page.goto(`${BASE}/#entity=PaymentMethod`); + await page.reload(); + await page.locator('.modal-frame').first().waitFor({ timeout: 15_000 }); + const before = await boxOf(page, '.modal-frame'); + const handleBox = await boxOf(page, '.modal-resize--right'); + const hx = handleBox.x + handleBox.width / 2; + const hy = handleBox.y + handleBox.height / 2; + await page.mouse.move(hx, hy); + await page.mouse.down(); + await page.mouse.move(hx + 150, hy, { steps: 6 }); + await page.mouse.up(); + const after = await boxOf(page, '.modal-frame'); + assert(Math.abs(after.width - before.width - 300) < 4, `dragging the right edge 150px widens the centred dialog by 300px (${before.width} → ${after.width})`); + assert(await page.locator('.modal').count() === 1, 'releasing a drag does not close the dialog'); + await page.screenshot({ path: join(SHOTS, 'resized.png') }); + + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + const saved = await page.evaluate(() => localStorage.getItem('ignatius-modal-width:content')); + assert(saved === String(Math.round(after.width)), `the width is saved (${saved})`); + + await page.goto(`${BASE}/#entity=Party`); + await page.reload(); + await page.locator('.modal-frame').first().waitFor({ timeout: 15_000 }); + const reopened = await boxOf(page, '.modal-frame'); + assert(Math.abs(reopened.width - after.width) < 2, 'the next dialog opens at the saved width'); + await page.locator('.modal-resize--right').dblclick(); + const reset = await boxOf(page, '.modal-frame'); + assert(Math.abs(reset.width - before.width) < 2 && await page.evaluate(() => localStorage.getItem('ignatius-modal-width:content')) === null, + 'a double-click on the edge restores the default width and clears it'); + + console.log(`\nAll mermaid zoom and modal resize checks passed. Screenshots in ${SHOTS}`); +} finally { + await browser.close(); + handle.stop(true); +} diff --git a/test/checks/test-validate-body-links.ts b/test/checks/test-validate-body-links.ts index 11ddea6..4293cd6 100644 --- a/test/checks/test-validate-body-links.ts +++ b/test/checks/test-validate-body-links.ts @@ -75,4 +75,28 @@ function bodyLinkErrors(r: ReturnType<typeof validateModel>, entityId: string): console.log('PASS: absent bodyLinks produce no findings'); } +// A body or column description may link a process, external, or store by title: that is not an unknown target. +{ + const { parseModels } = await import('../../src/model/parse'); + const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = mkdtempSync(join(tmpdir(), 'ignatius-flow-links-')); + mkdirSync(join(dir, 'data'), { recursive: true }); + mkdirSync(join(dir, 'flows/sales'), { recursive: true }); + mkdirSync(join(dir, 'externals'), { recursive: true }); + writeFileSync(join(dir, 'data/Order.md'), [ + '---', 'entity: Order', 'pk: [order_id]', 'columns:', ' order_id:', ' type: integer', + ' desc: "Assigned by [[Take Order]]."', '---', '', 'Placed by a [[Customer]] through [[Take Order]]; see [[Cart]].', '', + ].join('\n')); + writeFileSync(join(dir, 'flows/sales/Take-Order.md'), '---\nprocess: Take Order\n---\n'); + writeFileSync(join(dir, 'externals/Customer.md'), '---\nexternal: Customer\n---\n'); + const { model } = await parseModels(dir); + const targets = bodyLinkErrors(validateModel(model), 'Order'); + rmSync(dir, { recursive: true, force: true }); + assert(targets.length === 1 && targets[0]?.includes('[[Cart]]') === true, `only the target nothing names is flagged (got ${JSON.stringify(targets)})`); + assert(model.nodes[0]?.bodyHtml.includes('data-entity="Take-Order"') === true, 'a process link renders live at parse time'); + console.log('PASS: links to processes and externals are not unknown targets'); +} + console.log('\nAll body-link validation checks passed.'); diff --git a/test/checks/test-wikilink-opens-sub-dfd.ts b/test/checks/test-wikilink-opens-sub-dfd.ts new file mode 100644 index 0000000..675c0f1 --- /dev/null +++ b/test/checks/test-wikilink-opens-sub-dfd.ts @@ -0,0 +1,205 @@ +// A wiki-link to a process that owns a sub-DFD opens that diagram; its Dictionary section and ⓘ dialog link there too. +// A title link to a leaf process still scrolls to its section. Screenshots land in tmp/wikilink-opens-sub-dfd/. + +import { chromium, type Page } from 'playwright'; +import { resolve, join } from 'path'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { serveCommand } from '../../src/server/server'; + +const ROOT = resolve(import.meta.dir, '../..'); +const BUNDLE = join(ROOT, 'dist/static/index.js'); +const FIXTURE = join(ROOT, 'models/key-inherited'); +const SHOTS = join(ROOT, 'tmp', 'wikilink-opens-sub-dfd'); +const PORT = 3345; +const BASE = `http://localhost:${PORT}`; +const ON_SUB_DFD = /view=flow.*dfd=[^&]*Create-Sales-Order(&|$)/; + +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); +} +mkdirSync(SHOTS, { recursive: true }); + +const fail = (m: string): never => { console.error('FAIL:', m); process.exit(1); }; +function assert(cond: boolean, label: string) { + if (!cond) fail(label); + console.log(` PASS ${label}`); +} + +const root = mkdtempSync(join(tmpdir(), 'ignatius-sub-dfd-link-')); +const dir = join(root, 'model'); +const leftovers = new Set([join(FIXTURE, '.ignatius'), join(FIXTURE, '.gitignore')]); +cpSync(FIXTURE, dir, { recursive: true, filter: source => !leftovers.has(source) }); + +function appendLine(relative: string, line: string): void { + const file = join(dir, relative); + writeFileSync(file, `${readFileSync(file, 'utf8')}\n${line}\n`); +} +appendLine('flows/order-to-cash/Collect-Payment.md', 'Orders come from [[Create Sales Order]].'); +appendLine('flows/order-to-cash/Issue-Invoice.md', 'Paid through [[Collect Payment]].'); +appendLine('data/transactional/SalesOrder.md', 'Written by [[Create Sales Order]] and invoiced by [[Issue Invoice]].'); +const salesOrderFile = join(dir, 'data/transactional/SalesOrder.md'); +writeFileSync(salesOrderFile, readFileSync(salesOrderFile, 'utf8').replace( + 'desc: "Ordering party — foreign key to Party."', + 'desc: "The ordering [[Party]], placed through [[Create Sales Order]]."', +)); + +async function hashMatches(page: Page, pattern: RegExp): Promise<boolean> { + try { + await page.waitForFunction(source => new RegExp(source).test(location.hash), pattern.source, { timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +async function openBadge(page: Page, token: string): Promise<void> { + await page.waitForSelector(`g[data-token="${token}"] g[data-ignatius="flow-info"]`, { timeout: 15_000 }); + await page.evaluate((t) => { + const badge = document.querySelector(`g[data-token="${t}"] g[data-ignatius="flow-info"]`); + badge?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); + }, token); + await page.locator('.modal').first().waitFor({ timeout: 5_000 }); +} + +async function freshDict(page: Page): Promise<void> { + await page.goto(`${BASE}/#view=dict`); + await page.reload(); +} + +const handle = serveCommand(dir, { port: PORT }); +const browser = await chromium.launch(); + +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + // Dictionary: the decomposed process's section links to its diagram. + await freshDict(page); + const sectionLink = page.locator('#process-Create-Sales-Order .open-diagram-link[data-target="sub"]'); + await sectionLink.waitFor({ timeout: 15_000 }); + assert(await page.locator('#process-Collect-Payment .open-diagram-link[data-target="sub"]').count() === 0, 'a leaf process section has no Open sub-diagram link'); + assert(await page.locator('#process-Collect-Payment .open-diagram-link[data-target="in"]').count() === 1, 'every process section has a Show in diagram link'); + await sectionLink.scrollIntoViewIfNeeded(); + await page.screenshot({ path: join(SHOTS, 'dict-section.png') }); + await sectionLink.click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Dictionary: Open sub-diagram shows the sub-DFD'); + + // Dictionary: Show in diagram opens the diagram a leaf process is drawn in. + await freshDict(page); + const showIn = page.locator('#process-Collect-Payment .open-diagram-link[data-target="in"]'); + await showIn.waitFor({ timeout: 15_000 }); + await showIn.click(); + assert(await hashMatches(page, /view=flow.*dfd=order-to-cash(&|$)/), 'Dictionary: Show in diagram opens order-to-cash for Collect Payment'); + + // Dictionary: a body wiki-link naming the process by its title opens the diagram. + await freshDict(page); + const bodyLink = page.locator('#process-Collect-Payment a.entity-link', { hasText: 'Create Sales Order' }); + await bodyLink.waitFor({ timeout: 15_000 }); + await bodyLink.click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Dictionary: [[Create Sales Order]] in a process body shows the sub-DFD'); + + // Dictionary: the same link in an entity body routes the same way. + await freshDict(page); + const entityLink = page.locator('#entity-SalesOrder p > a.entity-link', { hasText: 'Create Sales Order' }); + await entityLink.waitFor({ timeout: 15_000 }); + await entityLink.click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Dictionary: [[Create Sales Order]] in an entity body shows the sub-DFD'); + + // Dictionary: a title link to a leaf process is rewritten to its id and scrolls to its section. + await freshDict(page); + const leafLink = page.locator('#process-Issue-Invoice a.entity-link', { hasText: 'Collect Payment' }); + await leafLink.waitFor({ timeout: 15_000 }); + assert(await leafLink.getAttribute('data-entity') === 'Collect-Payment', 'Dictionary: [[Collect Payment]] is rewritten to the id Collect-Payment'); + await leafLink.click(); + await page.waitForTimeout(800); + const sectionTop = await page.evaluate(() => document.getElementById('process-Collect-Payment')?.getBoundingClientRect().top ?? -1); + const onDict = /view=dict/.test(await page.evaluate(() => location.hash)); + assert(onDict && sectionTop >= 0 && sectionTop < 200, `Dictionary: [[Collect Payment]] scrolls to the leaf process section (top ${sectionTop})`); + + // Column descriptions render [[links]]: an entity link jumps to its section, a process link opens its sub-DFD. + await freshDict(page); + const descEntity = page.locator('#entity-SalesOrder td.col-desc a.entity-link', { hasText: 'Party' }); + await descEntity.waitFor({ timeout: 15_000 }); + await descEntity.click(); + assert(await hashMatches(page, /view=dict.*section=entity:Party(&|$)/), 'Dictionary: [[Party]] in a column description jumps to the Party section'); + await freshDict(page); + const descProcess = page.locator('#entity-SalesOrder td.col-desc a.entity-link', { hasText: 'Create Sales Order' }); + await descProcess.waitFor({ timeout: 15_000 }); + await descProcess.click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Dictionary: [[Create Sales Order]] in a column description shows the sub-DFD'); + + // Graph entity dialog: an entity link in a column description switches the dialog. + await page.goto(`${BASE}/#view=graph&entity=SalesOrder`); + await page.reload(); + const modalDesc = page.locator('.modal td.col-desc a.entity-link', { hasText: 'Party' }); + await modalDesc.waitFor({ timeout: 15_000 }); + await modalDesc.click(); + assert(await hashMatches(page, /entity=Party(&|$)/), 'Graph dialog: [[Party]] in a column description opens Party'); + + // Graph entity dialog: links to flow nodes leave the dialog for the Flows view, and Back returns to it. + const graphDialog = async () => { + await page.goto(`${BASE}/#view=graph&entity=SalesOrder`); + await page.reload(); + await page.locator('.modal-header h1', { hasText: 'SalesOrder' }).waitFor({ timeout: 15_000 }); + }; + await graphDialog(); + await page.locator('.modal p > a.entity-link', { hasText: 'Create Sales Order' }).click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Graph dialog: [[Create Sales Order]] in the body shows the sub-DFD'); + await page.goBack(); + await page.locator('.modal-header h1', { hasText: 'SalesOrder' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /view=graph.*entity=SalesOrder/), 'Graph dialog: Back from the sub-DFD reopens SalesOrder'); + await graphDialog(); + await page.locator('.modal td.col-desc a.entity-link', { hasText: 'Create Sales Order' }).click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Graph dialog: [[Create Sales Order]] in a column description shows the sub-DFD'); + await graphDialog(); + await page.locator('.modal p > a.entity-link', { hasText: 'Issue Invoice' }).click(); + await page.locator('.modal-header h1', { hasText: 'Issue Invoice' }).waitFor({ timeout: 15_000 }); + assert(await hashMatches(page, /view=flow.*dfd=order-to-cash.*node=proc:Issue-Invoice/), 'Graph dialog: [[Issue Invoice]] opens its diagram with its dialog'); + await page.goBack(); + await page.locator('.modal-header h1', { hasText: 'SalesOrder' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /view=graph.*entity=SalesOrder/), 'Graph dialog: one Back from the process dialog reopens SalesOrder'); + + // Flows entity dialog: the same links open in the Flows view, and Back reopens the dialog. + const flowDialog = async () => { + await page.goto(`${BASE}/#view=flow&entity=SalesOrder&dfd=order-to-cash`); + await page.reload(); + await page.locator('.modal-header h1', { hasText: 'SalesOrder' }).waitFor({ timeout: 15_000 }); + }; + await flowDialog(); + await page.locator('.modal p > a.entity-link', { hasText: 'Issue Invoice' }).click(); + await page.locator('.modal-header h1', { hasText: 'Issue Invoice' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /^(?!.*entity=).*node=proc:Issue-Invoice/), 'Flows dialog: [[Issue Invoice]] opens the process dialog'); + await page.goBack(); + await page.locator('.modal-header h1', { hasText: 'SalesOrder' }).waitFor({ timeout: 10_000 }); + assert(await hashMatches(page, /view=flow.*entity=SalesOrder/), 'Flows dialog: Back reopens SalesOrder'); + await flowDialog(); + await page.locator('.modal p > a.entity-link', { hasText: 'Create Sales Order' }).click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Flows dialog: [[Create Sales Order]] shows the sub-DFD'); + + // Flows: the link inside a process dialog closes the dialog and opens the diagram. + await page.goto(`${BASE}/#view=flow&dfd=order-to-cash`); + await page.reload(); + await openBadge(page, 'proc:Collect-Payment'); + await page.locator('.modal a.entity-link', { hasText: 'Create Sales Order' }).click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Flows: [[Create Sales Order]] in a dialog shows the sub-DFD'); + assert(await page.locator('.modal').count() === 0, 'Flows: the dialog closes when its link opens a diagram'); + + // Flows: the ⓘ badge still opens the process dialog, which links to the diagram. + await page.goto(`${BASE}/#view=flow&dfd=order-to-cash`); + await page.reload(); + await openBadge(page, 'proc:Create-Sales-Order'); + const dialogLink = page.locator('.modal .open-diagram-link[data-target="sub"]'); + assert(await dialogLink.count() === 1, 'Flows: the ⓘ badge opens the process dialog with an Open sub-diagram link'); + assert(await page.locator('.modal .open-diagram-link[data-target="in"]').count() === 0, 'Flows: the dialog does not offer the diagram already on screen'); + await page.screenshot({ path: join(SHOTS, 'flow-dialog.png') }); + await dialogLink.click(); + assert(await hashMatches(page, ON_SUB_DFD), 'Flows: Open sub-diagram shows the sub-DFD'); + await page.screenshot({ path: join(SHOTS, 'sub-dfd.png') }); + + console.log(`\nAll sub-DFD wikilink checks passed. Screenshots in ${SHOTS}`); +} finally { + await browser.close(); + handle.stop(true); + rmSync(root, { recursive: true, force: true }); +} diff --git a/test/checks/test-wikilink.ts b/test/checks/test-wikilink.ts index 1d27690..0f3c59a 100644 --- a/test/checks/test-wikilink.ts +++ b/test/checks/test-wikilink.ts @@ -7,7 +7,7 @@ */ import MarkdownIt from 'markdown-it'; -import { wikiLinkPlugin, splitWikiTarget, type WikiLinkEnv } from '../../src/model/wikilink'; +import { wikiLinkPlugin, splitWikiTarget, canonicalTarget, linkStateNeutral, type WikiLinkEnv } from '../../src/model/wikilink'; function assert(cond: boolean, msg: string): asserts cond { if (!cond) { @@ -87,4 +87,25 @@ function render(src: string, knownIds?: string[]): { html: string; links: string console.log('PASS: standard markdown links untouched'); } +// A process is linked by its title, but its id is the file name: `Answer Turn` lives in +// Answer-Turn.md. A table of sub-processes written that way must still navigate. +{ + const { html, links } = render('| 6.1 | [[Answer Turn]] |\n|---|---|\n| x | y |', ['Answer-Turn']); + assert(html.includes('data-entity="Answer-Turn"') && html.includes('>Answer Turn</a>'), `title resolves to the hyphenated id (got: ${html})`); + assert(JSON.stringify(links) === JSON.stringify(['Answer-Turn']), 'validation sees the resolved id, so no unknown_link'); + assert(canonicalTarget('Answer Turn', new Set(['Answer Turn', 'Answer-Turn'])) === 'Answer Turn', 'an exact id wins over the hyphenated form'); + assert(canonicalTarget('Answer Turn', new Set(['Other'])) === null, 'an unknown title stays unresolved'); + console.log('PASS: a title with spaces resolves to its hyphenated id'); +} + +// Change review compares bodies through linkStateNeutral: adding the entity a body links to by title +// must not mark that untouched body changed. +{ + const before = render('See [[Order Line]] and [[Customer]].', ['Customer']).html; + const after = render('See [[Order Line]] and [[Customer]].', ['Customer', 'Order-Line']).html; + assert(before !== after, 'fixture: the two renders differ before neutralising'); + assert(linkStateNeutral(before) === linkStateNeutral(after), `a resolved and an unresolved title link neutralise alike (got: ${linkStateNeutral(before)} vs ${linkStateNeutral(after)})`); + console.log('PASS: link state is neutral across title resolution'); +} + console.log('\nAll wikilink checks passed.'); diff --git a/test/visual/test-cp4-external-store-dup-body.ts b/test/visual/test-cp4-external-store-dup-body.ts index fc98e0d..610fb0f 100644 --- a/test/visual/test-cp4-external-store-dup-body.ts +++ b/test/visual/test-cp4-external-store-dup-body.ts @@ -227,7 +227,7 @@ async function runChecks(theme: 'dark' | 'light'): Promise<void> { // ── D. DFD dialog header uses shared styled class family ──────────────── note('\n── D. DFD dialog header uses .modal-header + .dict-entity-header ────'); - // The modal primitive renders .modal-backdrop > .modal > .modal-header + // The modal primitive renders .modal-backdrop > .modal-frame > .modal > .modal-header await assertExists('.modal-backdrop .modal-header', 'dialog modal-header'); // The facts section uses DictExternalSection which has .dict-entity-header inside .flow-node-dialog-facts await assertExists('.flow-node-dialog-facts .dict-entity-header', 'dialog facts .dict-entity-header');