From 5a3d96c1ec3922c84c24d96815ce73eff4268c84 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 13 Oct 2026 23:09:53 -0400 Subject: [PATCH] feat: show change review in static export, louder glows Export embeds the review of an existing baseline (read-only, never creates one) so an exported file shows what changed. Glows get a more saturated palette with separate light-mode text colors, a 2x pulse, and a longer red phase for unexplained items. Every injected JSON payload now escapes <. --- docs/design/model-change-review.md | 1 + docs/guides/change-review.md | 12 ++-- docs/guides/commands.md | 2 + docs/spec/model-change-review.md | 34 +++++++-- docs/wiki/cli.md | 2 +- docs/wiki/feature-map.md | 2 +- docs/wiki/flow-view.md | 2 +- docs/wiki/frontend.md | 12 ++-- docs/wiki/generators.md | 2 +- docs/wiki/parser.md | 2 +- .../references/verification.md | 2 +- src/app/components/changes/ChangesPanel.tsx | 8 ++- src/app/dom/theme-css-vars.ts | 14 +++- src/app/globals.d.ts | 2 + src/app/hooks/useModelData.ts | 5 +- src/app/styles.css | 44 +++++++----- src/app/views/graph/change-glow.ts | 8 +-- src/cli/cli.ts | 8 ++- src/generators/app.ts | 16 +++-- src/model/change-store.ts | 11 +++ test/checks/test-change-review-dict-flow.ts | 5 +- test/checks/test-change-review-flow-index.ts | 4 +- test/checks/test-change-review-graph.ts | 2 +- test/checks/test-export-change-review.ts | 69 +++++++++++++++++++ 24 files changed, 205 insertions(+), 64 deletions(-) create mode 100644 test/checks/test-export-change-review.ts diff --git a/docs/design/model-change-review.md b/docs/design/model-change-review.md index afdb6ab..5c15961 100644 --- a/docs/design/model-change-review.md +++ b/docs/design/model-change-review.md @@ -52,6 +52,7 @@ The diff alone shows what changed but never why. Notes alone are only as accurat - `.ignatius/` is review state, never committed. A `.gitignore` next to `ignatius.yml` lists it. The modeling skill writes that file for every model it generates; ignatius adds the line when it creates `.ignatius/` and the line is missing. - The diff runs on the parsed model, not file text. Reordering YAML keys or reformatting produces no change item. - A body-only edit is its own kind (`doc`), so prose edits do not read as structural changes. +- `ignatius export` reads the baseline and never writes one, so exporting cannot approve unreviewed edits; it exists because agents hand over exported HTML, and a file that hides the changes defeats the review. - Column granularity is required: a note covers a column change only when it names the column. - Serve creates the baseline from the current state when none exists, so a first run highlights nothing. The modeling skill runs `approve` before its first write to a model with no baseline, so an agent's edits to a never-served model still show. diff --git a/docs/guides/change-review.md b/docs/guides/change-review.md index 1c214c6..aa910ce 100644 --- a/docs/guides/change-review.md +++ b/docs/guides/change-review.md @@ -3,6 +3,8 @@ `ignatius serve` shows what changed in a model since you last approved it. When an agent (or a person) edits entity or flow files, the app marks every added, changed, and removed entity, column, relationship, and flow node. A Changes panel lists each change next to the note that explains it. When the changes look right, `ignatius approve` accepts the current files as the new starting point and the highlights clear. +An exported HTML file carries the same review, read-only, so a file an agent hands you shows what it changed. Write the change note before exporting; see [What it does not do](#what-it-does-not-do). + ```bash $ ignatius serve models/billing # ...an agent adds Invoice.line_total and writes .ignatius/changes/split-invoice-lines.md @@ -44,7 +46,7 @@ The first `serve` of a model writes the baseline from the files as they are, so ## What the app shows -Green marks an addition, amber a change. On the Graph and Flows views the mark is a slow pulsing glow; a change no note explains alternates its glow between its color and red. In the Dictionary it carries an `unexplained` label. With reduced motion turned on in your operating system, or on a view too large to animate, the glows hold steady and an unexplained one stays tinted red. +Green marks an addition, amber a change. On the Graph and Flows views the mark is a pulsing glow; a change no note explains alternates its glow between its color and red. In the Dictionary it carries an `unexplained` label. With reduced motion turned on in your operating system, or on a view too large to animate, the glows hold steady and an unexplained one stays tinted red. | Where | Added | Changed | Removed | |-------|-------|---------|---------| @@ -57,7 +59,7 @@ Green marks an addition, amber a change. On the Graph and Flows views the mark i 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 only in `serve`, and only when there is something to review. It lists, in order: +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: 1. Each note: its title, its body, and the changes it covers. 2. **Unexplained**: changes no note covers. @@ -155,7 +157,9 @@ To reject a change, edit the files back. The diff empties on its own once the fi | Not covered | Instead | |-------------|---------| | Git history | The baseline is a file ignatius writes; it does not read commits or branches. | -| `export` and `validate` | Neither diffs. A static export has no highlights and no Changes panel. | +| `validate` | Does not diff. Review changes in `serve` or `export`. | +| Approving from an export | A static export shows the changes read-only. Approve with `ignatius approve` or from a served model. | +| `export` without a baseline | Carries no review. Export never creates a baseline; serve the model once, or run `ignatius approve`, to set one. | | Removed flow nodes on the diagram | They are listed in the Changes panel; the DFD canvas draws only what exists now. | | Layout | Highlights never move nodes or change saved positions. Ghost nodes are placed near their old neighbours and are not saved. | | Keeping notes | Approve deletes them. Copy anything worth keeping into the entity or flow body first. | @@ -170,7 +174,7 @@ If ignatius cannot build the review, for example in a read-only model folder whe ignatius: could not prepare the change-review baseline: ``` -On each request for the model, when it cannot build the review: +On each request for the model, when it cannot build the review (`export` prints the same line to stderr and writes the HTML without a review): ``` ignatius: could not build the change review: diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 7fd3301..b80ba2d 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -63,6 +63,8 @@ ignatius export [path] -o model.html [--theme light|dark] [--model ] The file includes the Graph, Dictionary, and Flows views with full interactivity: view switching, live Dictionary search, entity dialog, theme toggle, and graph and flow node-position persistence — all offline, from `file://`. The export injects both the entity model and the flow model, so both position-restore keys work without a server. +When the model has an approved baseline (see [Change review](change-review.md)), the export also carries the change review: the same glows, ghost nodes, and Changes panel as a served model, without the Approve button. A model with no baseline exports no review, and export never creates one. The export carries the pending changes and their note text, so run `ignatius approve` first when you want a clean shareable file. + The exit code merges entity global errors, entity Class-B findings, and flow Class-B findings: exit `0` on a clean model, `1` when any of those are present. Warnings (Class A) alone do not fail the command. Note: the older `dict`, `graph`, and `flow` subcommands have been removed. Invoking one prints a one-line error pointing to `export`. diff --git a/docs/spec/model-change-review.md b/docs/spec/model-change-review.md index 68e1118..2333ea3 100644 --- a/docs/spec/model-change-review.md +++ b/docs/spec/model-change-review.md @@ -4,7 +4,7 @@ ## Goal -`ignatius serve` highlights what changed in a model since the last approval. ignatius diffs the parsed model and flows against a stored baseline; agent-written change notes explain the changes; the two are cross-checked. The graph, Dictionary, and Flow views mark added, changed, and removed items, a Changes panel lists them with their notes, and `ignatius approve` (or the panel's Approve button) accepts the current state as the new baseline. +`ignatius serve` highlights what changed in a model since the last approval, and `ignatius export` bakes the same review into the static page. ignatius diffs the parsed model and flows against a stored baseline; agent-written change notes explain the changes; the two are cross-checked. The graph, Dictionary, and Flow views mark added, changed, and removed items, a Changes panel lists them with their notes, and `ignatius approve` (or the panel's Approve button) accepts the current state as the new baseline. ## Approach @@ -17,7 +17,7 @@ Hybrid diff plus change notes, stored in a per-model `.ignatius/` folder, per `d - No git integration. The baseline is a file ignatius writes. -- No diff in `ignatius export` or `validate`. Static mode renders no change highlights and no Changes panel. +- No diff in `validate`. `ignatius export` bakes in the review of an existing baseline (SC14) but never creates or approves one. - No ghost rendering of removed flow nodes on the DFD canvas. Removed flow nodes appear only in the Changes panel. - No reject/revert command. Reverting is done by editing the files back and deleting or editing the note; the note's names otherwise stay listed as claimed but unchanged. - No layout changes: highlights never move nodes and never write `layout-store` positions or the layout fingerprint. @@ -81,14 +81,15 @@ A note **claim** is unchanged when it names an entity with no item for that enti - SC4 — `/api/model` returns a `diff` field: `{ items, claims, notes, noteErrors, removedNodes, removedEdges }`. `notes` carries each note's file name, title, entities, flows, and rendered body HTML. `removedNodes`/`removedEdges` carry the baseline's `ModelNode`/`ModelEdge` for each removed entity/relationship. Existing payload keys are unchanged. When the review cannot be built (unwritable model folder, unreadable notes), `diff` is `null`, the error is logged, and the rest of the payload is served unchanged. A baseline that does not parse or lacks `version: 1`, an object `model`, and an array `flows` is treated as missing and rewritten. - 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 3.6s 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. 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. +- 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. -- 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 3.6s 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. 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 static mode (`diff` null) remove every row mark. -- SC10 — Changes panel (live mode only, 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 an Approve button that calls `POST /api/approve`. It never overlaps the Findings panel. +- 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. - SC11 — The modeling skill instructs the agent to write one `.ignatius/changes/.md` note per edit session, with column-level `entities:` and node-level `flows:` frontmatter, and to create `/.gitignore` with `.ignatius/` for every model it generates. Before its first write to a model with no valid baseline, the skill runs `ignatius approve` when `.ignatius/changes/` is empty, and stops and asks the user when notes exist; a session that bootstraps a model runs `approve` after the bootstrap writes. `test-skill-no-repo-paths` stays green. - SC12 — Docs: a guide page for change review, a `commands.md` row for `approve`, and the folder-format guide lists `.ignatius/` and the model `.gitignore`. - SC13 — Every check in `test/checks/` that passed before this change still passes, the new checks pass, and `bunx tsc --noEmit` reports no new error kinds in files this change adds or modifies (the unresolved-cytoscape `Core`/namespace errors already present in the repo's checks are the one tolerated kind). +- SC14 — Static export: when the model has a stored baseline, `ignatius export` embeds the change review as `window.__CHANGE_REVIEW__`, and the static page shows the same glows, ghost nodes, and Changes panel, minus the Approve button. With no baseline the export carries no review. A review that cannot be built (unreadable note, unreadable baseline) exports without it and prints `ignatius: could not build the change review: ` to stderr. Export never writes `.ignatius/` or `.gitignore`. Every injected JSON payload escapes `<` so note titles and descriptions cannot close or reopen the script. ## Checkpoints @@ -101,6 +102,7 @@ A note **claim** is unchanged when it names an entity with no item for that enti | CP3 | Graph highlights and Changes panel: `useModelData` exposes `diff`, GraphView change data + ghost elements, styles, `ChangesPanel`, overlay toggle, approve button | `src/app/hooks/useModelData.ts`, `src/app/views/graph/GraphView.tsx`, `src/app/views/graph/styles.ts`, `src/app/components/changes/ChangesPanel.tsx`, `src/app/App.tsx`, `src/app/styles.css`, `test/checks/test-change-review-graph.ts` | SC7, SC10 in a Playwright check against a served temp model with an edit and a note | | CP4 | Dictionary and Flow highlights | `src/app/views/dict/DictionaryView.tsx`, `src/app/components/entity/ColumnsTable.tsx`, `src/app/components/entity/{EntityCard,GridCard,EntityModal}.tsx`, `src/app/components/changes/ChangeBadge.tsx`, `src/app/logic/change-marks.ts`, `src/app/components/changes/ChangesPanel.tsx` (flow labels), `src/app/App.tsx`, `src/app/views/graph/GraphView.tsx` (shared marks), `src/app/hooks/useModelData.ts`, `src/model/change-review.ts` (`def`, `storeKind`), `test/checks/test-change-review.ts`, `test/checks/test-change-review-graph.ts`, `src/app/views/flow/FlowsView.tsx`, `src/flow-view/FlowDiagramSvg.tsx`, `src/flow-view/FlowChrome.tsx`, `src/flow-view/FlowIndex.tsx`, `src/flow-view/LevelMenu.tsx`, `src/app/dom/theme-css-vars.ts`, `src/app/styles.css`, `test/checks/test-change-review-dict-flow.ts`, `test/checks/test-change-review-flow-index.ts` | SC8, SC9 in Playwright checks | | CP5 | Skill and docs | `skills/ignatius-modeling/SKILL.md`, `skills/ignatius-modeling/references/change-notes.md`, `skills/ignatius-modeling/references/model-flow.md`, `skills/ignatius-modeling/references/verification.md`, `docs/guides/change-review.md`, `docs/guides/commands.md`, `docs/guides/folder-format.md` | SC11, SC12 | +| CP6 | Static export: `readReview`, `window.__CHANGE_REVIEW__` injection, read-only panel, hostile-title escaping | `src/model/change-store.ts`, `src/generators/app.ts`, `src/cli/cli.ts`, `src/app/globals.d.ts`, `src/app/hooks/useModelData.ts`, `src/app/components/changes/ChangesPanel.tsx`, `test/checks/test-export-change-review.ts` | SC14 | ## Change tree @@ -110,7 +112,10 @@ A note **claim** is unchanged when it names an entity with no item for that enti A src/model/change-review.ts snapshot type, diffSnapshots, parseChangeNote, crossCheck A src/model/change-store.ts .ignatius/ I/O: baseline, notes, gitignore, approve M src/server/server.ts baseline on start, diff in /api/model, POST /api/approve -M src/cli/cli.ts approve subcommand +M src/cli/cli.ts approve subcommand; export passes readReview to generateApp +M src/generators/app.ts inject window.__CHANGE_REVIEW__; escape < in every payload +M src/app/globals.d.ts __CHANGE_REVIEW__ global +A test/checks/test-export-change-review.ts export carries the review, read-only, never writes .ignatius/ M src/app/hooks/useModelData.ts expose diff M src/app/views/graph/GraphView.tsx change data on elements, ghost nodes/edges M src/app/views/graph/styles.ts steady edge glows, dimmed-tier edge glows @@ -183,6 +188,7 @@ src/model/change-store.ts ensureGitignore — create or append the .ignatius/ line loadNotes — read .ignatius/changes/*.md buildReview — parse current, load baseline (create if absent), diff, cross-check + readReview — export's read-only review: null without a stored baseline, never writes approve — write baseline, delete notes, return counts src/server/server.ts serveCommand — create baseline at start; diff in /api/model; POST /api/approve; poll baseline.json mtime so a CLI approve refreshes tabs @@ -244,6 +250,12 @@ docs/guides/change-review.md 2. Server finds no `.ignatius/baseline.json`, writes it from the current parse, and ensures `/.gitignore` lists `.ignatius/`. 3. `/api/model` returns `diff.items: []`; no highlights, no Changes panel. +**Export** + +1. User or agent runs `ignatius export -o model.html`. +2. Export loads the stored baseline without writing one; none, or an unreadable one, means no review. +3. With a baseline, `generateApp` embeds the review as `window.__CHANGE_REVIEW__`; the page shows the marks and Changes panel without Approve. + **Agent edit under review** 1. Agent edits `data/billing/Invoice.md` (adds `line_total`) and writes `.ignatius/changes/split-invoice-lines.md`. @@ -274,6 +286,14 @@ docs/guides/change-review.md ## Change log +### 2026-10-11 — Static export carries the review; louder glows + +**What changed:** SC14 adds the review to `ignatius export`, and SC10 hides Approve in static mode. SC7 and SC9 shorten the pulse cycle to 1.8s and weight red longer in the unexplained cycle. The change colors are more saturated. + +**Why:** Agents hand over exported HTML, which showed no changes, so the reviewer could not see what an edit touched. The first palette and pace read as too faint in use. + +**Superseded:** `export` rendered no highlights and no Changes panel; SC10's panel was live-only; the glow cycle was 3.6s with equal kind and red layers; the added and changed colors were `#22c55e`/`#f59e0b` (dark) and `#15803d`/`#d97706` (light), now split into glow colors and text colors. + ### 2026-10-10 — A new diagram marks everything inside it **What changed:** SC9 marks every row inside a newly added diagram, and every node on its canvas, `added` with the diagram item's unexplained status. The Dictionary's removed-marker rules move from SC10 to SC8. The CP4 row and change tree list the flow index files and the modal fix. diff --git a/docs/wiki/cli.md b/docs/wiki/cli.md index 59a3f95..cd55df2 100644 --- a/docs/wiki/cli.md +++ b/docs/wiki/cli.md @@ -17,7 +17,7 @@ tags: [cli, dispatch, model-resolution] | `serve` (alias `server`) | `path`, `--port`/`-p` (default `3000`), `--model`, `--open`/`-o` | Resolves the model dir via `pickModel`, binds via `serveWithPortFallback`; `--open` dynamically imports `open-browser.ts` | | `validate` | `path`, `--model`, `--index` | `parseModels` → `validateModel`; folds in `parseFlows`/`validateFlows` when `/flows` exists; `--index` additionally calls `validateIndex` to recompute router digests and report drift, without writing anything | | `index` | `path`, `--model`, `--agents` | Full parse + validate + flow pipeline, then `buildRouters`/`writeRouters` writes `index.md` routers into every organizing folder; `--agents` also writes `AGENTS.md`/`SKILL.md` and, when the harness resolves to Claude, [`CLAUDE.md`](../../CLAUDE.md) | -| `export` | `path`, `--out`/`-o` (required), `--theme` (`light`\|`dark`), `--model` | Same parse/validate/flow pipeline as `validate`, then `loadEmbeddedBundle()` + `generateApp()` writes one self-contained HTML file | +| `export` | `path`, `--out`/`-o` (required), `--theme` (`light`\|`dark`), `--model` | Same parse/validate/flow pipeline as `validate`, then `readReview()` (null with no baseline; never writes `.ignatius/`) + `loadEmbeddedBundle()` + `generateApp()` writes one self-contained HTML file carrying `window.__CHANGE_REVIEW__` when a baseline exists | | `approve` | `path`, `--model` | Resolves the model dir via `pickModel`, then calls `approve(dir)` from [`src/model/change-store.ts`](../../src/model/change-store.ts): the current parse becomes the change-review baseline and every change note is deleted. Prints `Approved change(s), cleared note(s).` | | `dict`, `graph`, `flow` | none | Removal stubs: write `" was removed — use: ignatius export -o model.html"` to stderr, exit 1 | | `version` | none | Prints `VERSION` (baked from [`package.json`](../../package.json) at compile time) | diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index 35bbba8..65b51f8 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`) | 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; `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 | diff --git a/docs/wiki/flow-view.md b/docs/wiki/flow-view.md index 915221d..0dbdedc 100644 --- a/docs/wiki/flow-view.md +++ b/docs/wiki/flow-view.md @@ -161,7 +161,7 @@ flowchart TD A stack's mark is `added` if every marked member is added, else `changed`; it is unexplained if any marked member is. -`FlowDiagramSvg` takes an optional `changeMarks?: ReadonlyMap | null` prop (`ChangeMark` is the type from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts)). For a marked node the `` gets `data-change` (`added` or `changed`) and, when no note covers it, `data-unexplained="true"`, and `ChangeGlow` renders before the node body inside the same group: a `g.flow-glow` (`pointerEvents="none"`) holding a `flow-glow-kind` layer padded `GLOW_SPREAD` world px around `nodeBounds`, plus a `flow-glow-unexplained` layer at `UNEXPLAINED_GLOW_PAD` (`GLOW_SPREAD + 2`) when `mark.unexplained`. Both layers are blurred by an SVG `feGaussianBlur` filter at `stdDeviation={GLOW_BLUR}`; `GLOW_BLUR` and `GLOW_SPREAD` come from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts) so the graph and flow glows match. Colors and the pulse (`flow-glow-pulse` and `flow-glow-swap` keyframes) come from the `flow-glow*` classes in the frontend's stylesheet, not from this domain. Process, external, store, and stack nodes all take the same glow. The glow never moves a node or changes its size. +`FlowDiagramSvg` takes an optional `changeMarks?: ReadonlyMap | null` prop (`ChangeMark` is the type from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts)). For a marked node the `` gets `data-change` (`added` or `changed`) and, when no note covers it, `data-unexplained="true"`, and `ChangeGlow` renders before the node body inside the same group: a `g.flow-glow` (`pointerEvents="none"`) holding a `flow-glow-kind` layer padded `GLOW_SPREAD` world px around `nodeBounds`, plus a `flow-glow-unexplained` layer at `UNEXPLAINED_GLOW_PAD` (`GLOW_SPREAD + 2`) when `mark.unexplained`. Both layers are blurred by an SVG `feGaussianBlur` filter at `stdDeviation={GLOW_BLUR}`; `GLOW_BLUR` and `GLOW_SPREAD` come from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts) so the graph and flow glows match. Colors and the pulse (`flow-glow-pulse` and `flow-glow-swap` (kind layer) and `flow-glow-red` (red layer, never below half strength) keyframes) come from the `flow-glow*` classes in the frontend's stylesheet, not from this domain. Process, external, store, and stack nodes all take the same glow. The glow never moves a node or changes its size. ### Hover settle path diff --git a/docs/wiki/frontend.md b/docs/wiki/frontend.md index ed66826..acf5c9b 100644 --- a/docs/wiki/frontend.md +++ b/docs/wiki/frontend.md @@ -14,7 +14,7 @@ On a large model all three surfaces share one hover-settle and animation-cutoff [`src/app/App.tsx`](../../src/app/App.tsx) is the entry point. It was decomposed from the original `src/App.tsx` monolith, into a layered [`src/app/`](../../src/app) tree: shell → views → components → ui → logic/dom, downward only. [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. -When an agent edits the model files, the SPA shows what changed since the last approval instead of presenting the new state as if it had always been there. In live mode `/api/model` carries a `ChangeReview` (`diff`); a Changes panel lists it and approves it, and every surface marks the same items: pulsing glows and badges on graph entities with dashed ghosts for removals, badges and per-column row marks in the Dictionary and entity modal, and marked nodes in the flow diagram. Flow index rows glow, level-menu rows carry a pulsing dot, and a row with changes below it carries a static dot. Removed items read in warning red in the Changes panel, the Dictionary, and the ghost node's ✕ badge. In static mode `diff` is `null` and nothing is marked. +When an agent edits the model files, the SPA shows what changed since the last approval instead of presenting the new state as if it had always been there. In live mode `/api/model` carries a `ChangeReview` (`diff`); a Changes panel lists it and approves it, and every surface marks the same items: pulsing glows and badges on graph entities with dashed ghosts for removals, badges and per-column row marks in the Dictionary and entity modal, and marked nodes in the flow diagram. Flow index rows glow, level-menu rows carry a pulsing dot, and a row with changes below it carries a static dot. Removed items read in warning red in the Changes panel, the Dictionary, and the ghost node's ✕ badge. A static export made from a model with an approved baseline carries the review in `window.__CHANGE_REVIEW__`: every surface marks the same items and the Changes panel renders read-only, without Approve. An export with no baseline has a `null` `diff` and nothing is marked. ## How it works @@ -131,7 +131,7 @@ stateDiagram-v2 Settled --> Settled: panelNavigate(id),
runs at once ``` -`applyChangeOverlay` returns early when the layout has not settled or when `changeReviewRef.current` equals `appliedReview` (re-adding ghosts drops their selection); `settleLayout` resets `appliedReview` so each finished layout re-applies. It writes `change` and `unexplained` onto element data, which `buildStyles` selects on (`node[change = "added"]` and so on): recoloured edges with a thin glow underlay, a dashed border and 0.45 opacity for removed ghosts, and an `unexplained` color mix. Node glows are not stylesheet rules: `createChangeGlow(cy, container, colors)` in `views/graph/change-glow.ts` draws blurred node shapes on a canvas behind cytoscape, clipped out of the node itself, culled to the viewport, and pulsed by one rAF loop on a 3.6s cycle (`GLOW_PERIOD_MS`); a `matchMedia('(prefers-reduced-motion: reduce)')` listener holds it at `GLOW_STEADY`. Ghosts get a muted glow plus a full-opacity red ✕ badge drawn on a canvas above cytoscape. `destroy()` removes both canvases and the listener. `ghostStart` places a ghost at the newest position `layoutStore.lastPosition` holds for it, else at the centroid of its present neighbours, else in a row below the graph; `moveToClearSpot` then searches rings outward for a spot clear of other nodes. `gradeSpans` passes `cy.edges()` minus the ghosts to `gradeEdgeSpans`, so ghost edges do not skew the span percentiles. A `panelNavigate` that arrives mid-layout is held in `pendingPanelTarget` and consumed on settle, since the layout's closing fit would otherwise undo the centering. +`applyChangeOverlay` returns early when the layout has not settled or when `changeReviewRef.current` equals `appliedReview` (re-adding ghosts drops their selection); `settleLayout` resets `appliedReview` so each finished layout re-applies. It writes `change` and `unexplained` onto element data, which `buildStyles` selects on (`node[change = "added"]` and so on): recoloured edges with a thin glow underlay, a dashed border and 0.45 opacity for removed ghosts, and an `unexplained` color mix. Node glows are not stylesheet rules: `createChangeGlow(cy, container, colors)` in `views/graph/change-glow.ts` draws blurred node shapes on a canvas behind cytoscape, clipped out of the node itself, culled to the viewport, and pulsed by one rAF loop on a 1.8s cycle (`GLOW_PERIOD_MS`); a `matchMedia('(prefers-reduced-motion: reduce)')` listener holds it at `GLOW_STEADY`. Ghosts get a muted glow plus a full-opacity red ✕ badge drawn on a canvas above cytoscape. `destroy()` removes both canvases and the listener. `ghostStart` places a ghost at the newest position `layoutStore.lastPosition` holds for it, else at the centroid of its present neighbours, else in a row below the graph; `moveToClearSpot` then searches rings outward for a spot clear of other nodes. `gradeSpans` passes `cy.edges()` minus the ghosts to `gradeEdgeSpans`, so ghost edges do not skew the span percentiles. A `panelNavigate` that arrives mid-layout is held in `pendingPanelTarget` and consumed on settle, since the layout's closing fit would otherwise undo the centering. ### A Changes-panel click may need a view switch first @@ -183,14 +183,14 @@ A removed entity exists only as a ghost, so clicking it with the overlay off tur | [`src/app/App.tsx`](../../src/app/App.tsx) (branding gutter, fix 2026-08-01) | The branding block is `position: fixed`, out of document flow, so no stylesheet can know how much room to leave for it. A `brandingRef`-measuring `useEffect` publishes the block's measured width as the `--branding-gutter` CSS custom property (via `ResizeObserver`, re-published on `[branding, logoSrc]` change since a theme swap can change logo width) so the Dictionary view's full-bleed search bar can indent past it below ~1920px viewport width, where the two previously shared a row and the z-50 branding block painted over the z-30 search input. [`src/app/styles.css`](../../src/app/styles.css)'s `.dict-search-bar-inner` computes `padding-left: max(2rem, calc(16px + var(--branding-gutter, 0px) + 12px - var(--dict-bar-left-slack)))`. | | [`src/app/main.tsx`](../../src/app/main.tsx) | React root mount, reads `window.__MODEL__`/`__THEME_MODE__` globals in static mode. | | [`src/app/hash-router.ts`](../../src/app/hash-router.ts) | Exports `parseHash`/`serializeHash`, `ViewName`, `FlowViewMode`, `FlowCollapseLevel`, `HashState`, `nextCollapseLevel`. `HashState` carries `view?: 'graph'\|'dict'\|'flow'`, `entity?`, `zoom?`, `pan?`, `dfd?`, `flowview?: 'per-process'\|'connected'`, `collapse?: 'stores'\|'clusters'\|'groups'`. Format: `#view=&entity=&zoom=&pan=,&dfd=&flowview=&collapse=`. `dfd`'s value is a diagram reference (a bare id, or an id path such as `invoicing/Submit-PCI`) resolved by the flow-view domain's `findDiagramByRef`; `serializeHash` encodes it with `encodeURIComponent` then restores any `%2F` back to `/`, so a path reference stays readable in the address bar instead of percent-encoding its separator. All params optional; unknown/malformed values are silently dropped (`VALID_VIEWS`/`VALID_FLOW_VIEWS`/`VALID_COLLAPSE_LEVELS` lookup tables). | -| [`src/app/globals.d.ts`](../../src/app/globals.d.ts) | `window.__MODEL__`, `__THEME_MODE__`, `__IGNATIUS_MODE__` (`'live'\|'static'`), `__LAYOUT_KEY__`, `__FLOW_MODEL__`, `__FLOW_LAYOUT_KEYS__`, `__FLOW_CLUSTERS__` (the model-root `clusters/` registry, sibling to `__FLOW_MODEL__`, read directly by `FlowsView`'s `initFlowGraphCore` to build `buildFlowData`'s connected-view cluster grouping), `__IGNATIUS_CY__`, `__IGNATIUS_CY_GEN__`, `__IGNATIUS_FLOW_READY__`, `__IGNATIUS_FLOW_GEN__`, `__IGNATIUS_ACTIVE_FLOW_DFD__`, `__IGNATIUS_PERF__`. | +| [`src/app/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), `__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. | ### Hooks | 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__` 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 static mode 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/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/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) | Exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage; calls `applyThemeCssVars` on change (also on `model` identity change). Returns `{ themeMode, toggleTheme }`. | | [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) | Registers exactly ONE global `keydown` listener for the unified SPA shortcuts (g/d/f/l/b/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`. | @@ -294,7 +294,7 @@ Change-review checks: [`test/checks/test-change-review-graph.ts`](../../test/che | `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate` (findings and changes panel rows; held until a running layout settles), `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Change overlay:** takes `changeReview: ChangeReview | null`, mirrored into `changeReviewRef`; an effect on `[changeReview, isActive, cyReady]` calls `applyChangeOverlayRef` (see How it works). `nodeData`/`edgeData`/`graphEdgeId` are shared by the initial element build and the ghost builder. **Hover settles before it repaints:** `cy.on('mouseover'/'mouseout', 'node')` feed `graphHover` (a `createHoverIntent` from `logic/motion.ts`) instead of restyling directly; `showHover(nodeId)` is the `apply` callback — it restores the leaving node's forward predicates, then, for a settled non-null target, shows its reverse predicates plus (`shiftHeld`) either lineage via `enterLineageHover` or a plain `applyFocusTiers` fade, falling back to the selected node's tiers or a full clear on `null`. `shiftHeld` (a plain `let` local to the cy-init effect, updated by the `mouseover` handler's event and by document-level `onShiftKeyDown`/`onShiftKeyUp` listeners) and `hoveredNodeIdRef` (a ref) track live Shift and pointer state so `onShiftKeyDown` can call `graphHover.applyNow(hoveredId)` at once when Shift is pressed over a still-waiting hover. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. | | `views/graph/organic-layout.ts` | Organic (fCoSE-based) layout engine built on `cytoscape-fcose`. `ORGANIC_FALLBACK_THRESHOLD = 500` entities before falling back to layered. `buildScratchCore` runs the multi-seed layout search on a headless mirror core so only winning positions touch the live core; `groupRegions: true` (entity count ≥ 150) wraps each color family in an invisible compound parent so fCoSE decomposes large models into per-family sub-layouts. `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline (expand, fan subtype clusters, dock leaves/isolates, deoverlap). `gradeEdgeSpans(edges: cytoscape.EdgeCollection)` grades the given edges' `span` (`'near'\|'mid'\|'far'`) by percentile within that collection's own length distribution (`GraphView` passes every edge except ghosts), for `styles.ts`'s length-graded de-emphasis. | | `views/graph/navigator.ts` | `mountNavigator`/`teardownNavigator`/`NavigatorInstance`, cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` before `nav.destroy()` to avoid a resize-listener leak that fires on a destroyed core. | -| `views/graph/change-glow.ts` | `createChangeGlow(cy, container, colors)` → `ChangeGlow` with `destroy()`: canvas behind cytoscape for pulsing blurred node glows (clipped out of the node, viewport-culled, one rAF loop, `GLOW_PERIOD_MS` 3600, reduced-motion `matchMedia` listener) and canvas above it for the ghost ✕ badge. Exports `glowColors`, `GLOW_STEADY`, `EDGE_GLOW_SCALE`, `UNEXPLAINED_STEADY_MIX`, `TIER_OPACITY` for `styles.ts`. | +| `views/graph/change-glow.ts` | `createChangeGlow(cy, container, colors)` → `ChangeGlow` with `destroy()`: canvas behind cytoscape for pulsing blurred node glows (clipped out of the node, viewport-culled, one rAF loop, `GLOW_PERIOD_MS` 1800, reduced-motion `matchMedia` listener) and canvas above it for the ghost ✕ badge. Exports `glowColors`, `GLOW_STEADY`, `EDGE_GLOW_SCALE`, `UNEXPLAINED_STEADY_MIX`, `TIER_OPACITY` for `styles.ts`. | | `views/graph/styles.ts` | `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. `.faded` (0.2), `.inherited-dim` (0.5), `edge.inherited` dotted at 0.5 opacity. Length-graded de-emphasis on `edge[span]`. Graph search: `SEARCH_MATCH_BORDER` gold/yellow border on `.search-match`, `.search-dim` at 0.2 opacity, both pushed last in the stylesheet array to win the cascade. Change review: `node[change=...]`/`edge[change=...]` rules use `glowColors(theme, mode)` from `change-glow.ts`, edge glow underlays at `GLOW_STEADY * EDGE_GLOW_SCALE` (node glows live in `change-glow.ts`), the removed-ghost dashed border, and are pushed before the focus and search classes so those still dim changed elements; `[unexplained = "true"]` blends the underlay toward the unexplained color (`UNEXPLAINED_STEADY_MIX`). | | `views/graph/markers.ts` | Exports `drawWarningBadges(cy, svg, entityIds: Set)`, `createMarkerOverlay(container)`, `updateMarkers(cy, svg, theme, mode?)`. | | `views/graph/wrap-label.ts` | `wrapEntityLabel`: underscores → spaces; names longer than ~13 chars break at PascalCase/acronym/digit boundaries. | @@ -322,7 +322,7 @@ Change-review checks: [`test/checks/test-change-review-graph.ts`](../../test/che - **`IoTable`'s rich-link props are opt-in.** Without `onOpenEntity`/`onOpenToken`/`canOpenToken`, a `db:` cell falls back to `onScrollToEntity` (dict scroll-to-anchor) and a non-`db` cell renders as plain text — only `FlowNodeModal` passes the rich-link props. - **Ghost elements exist only while a layout is settled.** `GraphView` removes every `.change-ghost` element before a layout starts and re-adds them on settle; anything iterating `cy.nodes()` or `cy.edges()` for layout, saving, or span grading must exclude the class (`gradeSpans` and the drag-release save (`free` handler) do). Including ghosts saves positions for entities that are not in the model and skews edge-length percentiles. - **`ChangesPanel` is fed `diff`, the markers are fed `shownChanges`.** Passing `shownChanges` to the panel would empty it when "Highlight changes" is unchecked, and passing `diff` to a surface would ignore the checkbox. -- **`diff` is `null` in static mode.** `ChangesPanel` is not rendered and every `changeReview`/`changes`/`change` prop is `null` or `undefined`, so a surface must treat an absent review as no markers rather than as an empty review. +- **`diff` is `null` when a static export has no baseline.** `ChangesPanel` is not rendered and every `changeReview`/`changes`/`change` prop is `null` or `undefined`, so a surface must treat an absent review as no markers rather than as an empty review. An export with a baseline sets `diff` from `window.__CHANGE_REVIEW__` and hides Approve. - **`ChangeReview` is imported type-only in the frontend.** A runtime import of [`src/model/change-review.ts`](../../src/model/change-review.ts) pulls `flow-markdown` and shiki into the browser bundle. - **A relationship change marks a graph edge only.** `entityChanges` skips `relationship` items, so the Dictionary and entity modal show no relationship marker; `GraphView` builds edge marks itself, keyed `-`. - **Layer rule is downward-only by discipline, not build enforcement.** Shell (`App.tsx`) → views (`views/*/`) → components (`components/*/`) → ui (`components/ui/`) → logic/dom (`logic/`, `dom/`); a lower layer importing from a higher one (or `logic/`/`dom/` importing React/DOM) reintroduces the tangled coupling the `src/App.tsx` decomposition ([`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md)) removed. diff --git a/docs/wiki/generators.md b/docs/wiki/generators.md index 5f945b2..b17ac7e 100644 --- a/docs/wiki/generators.md +++ b/docs/wiki/generators.md @@ -36,7 +36,7 @@ A clusters-only model still parses `clusters` before any diagram exists, so `flo | Path | Role | |---|---| -| [`src/generators/app.ts`](../../src/generators/app.ts) (145 lines) | `generateApp(model, flowModel, sourceOrDir, opts)`. Resolves `sourceOrDir` (default `'dist/static'`) to a `BundleContent` via `loadBundleFromDir` when given a string, or uses it directly when already a `BundleContent`. Builds the injection script (`window.__MODEL__`, conditionally `__FLOW_MODEL__`/`__FLOW_LAYOUT_KEYS__`/`__FLOW_CLUSTERS__`, then always `__LAYOUT_KEY__` via `layoutFingerprint(model)` and `__THEME_MODE__` from `opts.themeMode`, default `'dark'`). Escapes `` in the `` rewrite via a local `escapeHtmlText`. | +| [`src/generators/app.ts`](../../src/generators/app.ts) (145 lines) | `generateApp(model, flowModel, sourceOrDir, opts)`. Resolves `sourceOrDir` (default `'dist/static'`) to a `BundleContent` via `loadBundleFromDir` when given a string, or uses it directly when already a `BundleContent`. Builds the injection script (`window.__MODEL__`, conditionally `__FLOW_MODEL__`/`__FLOW_LAYOUT_KEYS__`/`__FLOW_CLUSTERS__`, then `__CHANGE_REVIEW__` when `opts.review` is non-null (the export's change review against an existing baseline), then always `__LAYOUT_KEY__` via `layoutFingerprint(model)` and `__THEME_MODE__` from `opts.themeMode`, default `'dark'`). Escapes every `<` in JSON payloads as `\u003c` via a local `escapeScriptClose` (so `</script` and `<!--<script` in note titles or descriptions stay inert), and `</script` in the inlined JS bundle, and HTML-escapes `&`, `<`, `>` in the `<title>` rewrite via a local `escapeHtmlText`. | | [`src/generators/embedded-bundle.ts`](../../src/generators/embedded-bundle.ts) (119 lines) | Defines `BundleContent = { htmlTemplate, cssContent, jsContent }`. `loadBundleFromDir(bundleDir)` reads `index.html`, extracts the hashed JS/CSS filenames via regex against `src=`/`href=`, falls back to a `Glob('index-*.{js,css}')` scan, and throws a descriptive error (naming the exact `bun build` command and listing the directory's actual contents) if either file still can't be found. `loadEmbeddedBundle()` is the compiled-binary path: it imports `dist/static/index.html`, `dist/static/index.js`, `dist/static/index.css` at module load time with `with { type: 'file' }` so `bun build --compile` embeds them under `$bunfs/`, checks all three exist via `Bun.file().exists()`, and throws pointing at `bun run build:bundle` (or `build:cli`) if any are missing. | ## Constraints diff --git a/docs/wiki/parser.md b/docs/wiki/parser.md index f14ed53..ecae055 100644 --- a/docs/wiki/parser.md +++ b/docs/wiki/parser.md @@ -142,7 +142,7 @@ A dangling edge (unknown target, or an FK column absent from the child's PK) is | [`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/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 | -| [`src/model/change-store.ts`](../../src/model/change-store.ts) | `baselinePath()`, `ensureBaseline()`, `buildReview()`, `approve()`, `ensureGitignore()` | Filesystem side of change review: reads and writes `.ignatius/baseline.json`, reads `.ignatius/changes/*.md`, calls `parseModels` and `parseFlows` for the current snapshot | +| [`src/model/change-store.ts`](../../src/model/change-store.ts) | `baselinePath()`, `ensureBaseline()`, `buildReview()`, `readReview()`, `approve()`, `ensureGitignore()` | Filesystem side of change review: reads and writes `.ignatius/baseline.json`, reads `.ignatius/changes/*.md`, calls `parseModels` and `parseFlows` for the current snapshot | `ModelMeta.indexFile` and `ModelMeta.harness` are populated from `ignatius.yml`'s top-level `index_file:` and `harness:` keys (`harness` is validated against `'auto' | 'claude' | 'agents' | 'both'` and dropped if it does not match). `ModelMeta.flowRules` and `ModelMeta.flowView` are populated the same way from the top-level `flow_rules:` and `flow_view:` blocks (parse.ts:204-221): `flowRules.process_to_process` is copied from the YAML `process_to_process` boolean, and `flowView.adjacencyStacks` is copied from the YAML `adjacency_stacks` boolean nested under `flow_view:`; either field is omitted from its object when the source YAML key is absent or not a boolean, and `_meta` itself stays `undefined` only when every one of `name`/`version`/`description`/`updated`/`flowRules`/`flowView`/`indexFile`/`harness` is absent. `ModelNode.sourcePath` is set for every parsed entity to its path relative to the model root (e.g. `data/catalog/Product.md`) and is optional only for hand-built test fixtures. Both entity and group frontmatter accept a top-level `description:` string, copied onto `ModelNode.description` / `GroupConfig.description` verbatim (distinct from `GroupConfig.desc`, which is the group's body rendered to HTML). diff --git a/skills/ignatius-modeling/references/verification.md b/skills/ignatius-modeling/references/verification.md index 936e31b..1929096 100644 --- a/skills/ignatius-modeling/references/verification.md +++ b/skills/ignatius-modeling/references/verification.md @@ -100,7 +100,7 @@ The linter validates structure; it cannot see whether the business story was cap **Change note (runs last, after the checks above hold):** -Write this session's `.ignatius/changes/<slug>.md`, or update it if the session already wrote one, following `references/change-notes.md`. List every entity, column, diagram, and node the session changed, and nothing else. Skip it when the session only bootstrapped a model. Then tell the user to review the changes in `ignatius serve` (the Changes panel lists the note, anything unexplained, and anything named but unchanged) and to accept them with `ignatius approve <model-root>` or the panel's Approve button. +Write this session's `.ignatius/changes/<slug>.md`, or update it if the session already wrote one, following `references/change-notes.md`. List every entity, column, diagram, and node the session changed, and nothing else. Skip it when the session only bootstrapped a model. Then tell the user to review the changes in `ignatius serve` (the Changes panel lists the note, anything unexplained, and anything named but unchanged) and to accept them with `ignatius approve <model-root>` or the panel's Approve button. When you hand over a rendered file, write the note first and then run `ignatius export <model-root> -o <file>.html`: the export carries the review, so the user sees the same marks and Changes panel. An export made before the note shows every change as unexplained. Only when structure is clean **and** these hold: report "Verified clean — 0 findings, business context captured, sample rows consistent, every flow labelled and stacked legibly, change note written." diff --git a/src/app/components/changes/ChangesPanel.tsx b/src/app/components/changes/ChangesPanel.tsx index 05d2db3..3fe6ca5 100644 --- a/src/app/components/changes/ChangesPanel.tsx +++ b/src/app/components/changes/ChangesPanel.tsx @@ -183,9 +183,11 @@ export function ChangesPanel({ <input type="checkbox" checked={overlay} onChange={e => onOverlayChange(e.target.checked)} /> Highlight changes </label> - <button className="changes-panel-approve" onClick={approve} disabled={approving}> - {approving ? 'Approving…' : 'Approve'} - </button> + {window.__IGNATIUS_MODE__ !== 'static' && ( + <button className="changes-panel-approve" onClick={approve} disabled={approving}> + {approving ? 'Approving…' : 'Approve'} + </button> + )} </div> {approveError && <p className="changes-panel-error" role="alert">{approveError}</p>} <div className="changes-panel-body"> diff --git a/src/app/dom/theme-css-vars.ts b/src/app/dom/theme-css-vars.ts index 8ecfce0..d9c4b90 100644 --- a/src/app/dom/theme-css-vars.ts +++ b/src/app/dom/theme-css-vars.ts @@ -18,8 +18,14 @@ export const SPOTLIGHT_LINE_INHERITED: Record<ThemeMode, string> = { /** Change-review highlight colors per mode, read by `buildStyles`. */ export const CHANGE_COLORS: Record<ThemeMode, { added: string; changed: string; unexplained: string }> = { - dark: { added: '#22c55e', changed: '#f59e0b', unexplained: '#f87171' }, - light: { added: '#15803d', changed: '#d97706', unexplained: '#dc2626' }, + dark: { added: '#00e050', changed: '#ffc400', unexplained: '#ff3b3b' }, + light: { added: '#16a34a', changed: '#ca8a04', unexplained: '#e11d1d' }, +}; + +/** The same hues for text and fills under text: the glow colors fall under 4.5:1 on the light surface. */ +export const CHANGE_TEXT: Record<ThemeMode, { added: string; changed: string; unexplained: string }> = { + dark: CHANGE_COLORS.dark, + light: { added: '#15803d', changed: '#a16207', unexplained: '#dc2626' }, }; /** Warning red for removed items in both modes: the graph's ghost ✕ badge, the Changes panel, and the Dictionary. */ @@ -85,6 +91,10 @@ export function applyThemeCssVars(theme: ThemeConfig, mode: ThemeMode) { root.style.setProperty('--change-added', change.added); root.style.setProperty('--change-changed', change.changed); root.style.setProperty('--change-unexplained', change.unexplained); + const changeText = CHANGE_TEXT[mode]; + root.style.setProperty('--change-added-text', changeText.added); + root.style.setProperty('--change-changed-text', changeText.changed); + root.style.setProperty('--change-unexplained-text', changeText.unexplained); root.style.setProperty('--change-removed', REMOVED_RED); root.style.setProperty('--change-removed-text', REMOVED_TEXT[mode]); diff --git a/src/app/globals.d.ts b/src/app/globals.d.ts index 8cd4e4e..1921337 100644 --- a/src/app/globals.d.ts +++ b/src/app/globals.d.ts @@ -8,6 +8,8 @@ declare global { __THEME_MODE__?: 'dark' | 'light'; __IGNATIUS_MODE__?: 'live' | 'static'; __LAYOUT_KEY__?: string; + // Change review baked into a static export; absent when the model had no approved baseline. + __CHANGE_REVIEW__?: import('../model/change-review').ChangeReview; // Flow-mode globals: injected by generateApp (export) or fetched from /api/flow (live). // __FLOW_MODEL__ is the array of all top-level FlowDiagrams (each carries // its subDfds recursively so drill-down works client-side with no fetches). diff --git a/src/app/hooks/useModelData.ts b/src/app/hooks/useModelData.ts index 9da6414..7f4bffb 100644 --- a/src/app/hooks/useModelData.ts +++ b/src/app/hooks/useModelData.ts @@ -53,7 +53,7 @@ async function doFlowFetch(): Promise<FlowApiPayload> { } // Unified SSE subscription + model/flow fetch + findings state. -// In static mode: reads window.__MODEL__ / __FLOW_MODEL__ once on mount. +// In static mode: reads window.__MODEL__ / __FLOW_MODEL__ / __CHANGE_REVIEW__ once on mount. // In live mode: boots with parallel /api/model + /api/flow, then re-fetches // both on every 'model-changed' SSE event. export function useModelData(opts?: UseModelDataOptions): { @@ -62,7 +62,7 @@ export function useModelData(opts?: UseModelDataOptions): { flowDiagrams: FlowDiagram[] | null; flowClusters: FlowCluster[]; flowFindings: FlowFindings; - /** Changes since the last approval; null in static mode or when the server could not build the review. */ + /** Changes since the last approval; null when there is no review: an export made without a baseline, or one the server could not build. */ diff: ChangeReview | null; /** Refetches `/api/model` and replaces `diff` only. */ refreshDiff: () => Promise<void>; @@ -117,6 +117,7 @@ export function useModelData(opts?: UseModelDataOptions): { const rawDiagrams = window.__FLOW_MODEL__; if (rawDiagrams && rawDiagrams.length > 0) setFlowDiagrams(rawDiagrams); if (window.__FLOW_CLUSTERS__) setFlowClusters(window.__FLOW_CLUSTERS__); + setDiff(window.__CHANGE_REVIEW__ ?? null); return; } diff --git a/src/app/styles.css b/src/app/styles.css index 27b69e0..3e51d39 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1533,17 +1533,23 @@ button.flow-crumb__label:hover { /* Changes panel (docs/spec/model-change-review.md) */ /* Fallbacks until applyThemeCssVars sets CHANGE_COLORS for the active theme. */ :root { - --change-added: #22c55e; - --change-changed: #f59e0b; - --change-unexplained: #f87171; + --change-added: #00e050; + --change-changed: #ffc400; + --change-unexplained: #ff3b3b; + --change-added-text: #00e050; + --change-changed-text: #ffc400; + --change-unexplained-text: #ff3b3b; --change-removed: #dc2626; --change-removed-text: #f87171; } .theme-light { - --change-added: #15803d; - --change-changed: #d97706; - --change-unexplained: #dc2626; + --change-added: #16a34a; + --change-changed: #ca8a04; + --change-unexplained: #e11d1d; + --change-added-text: #15803d; + --change-changed-text: #a16207; + --change-unexplained-text: #dc2626; --change-removed-text: #dc2626; } @@ -1577,7 +1583,7 @@ button.flow-crumb__label:hover { } .changes-panel-approve { - background: var(--change-added); + background: var(--change-added-text); color: var(--color-background); border: none; border-radius: 6px; @@ -1626,7 +1632,7 @@ button.flow-crumb__label:hover { .changes-panel-section--unexplained h4, .changes-panel-section--errors h4 { - color: var(--change-unexplained); + color: var(--change-unexplained-text); } .changes-panel-note + .changes-panel-note { @@ -1722,11 +1728,11 @@ button.changes-panel-item:hover { } .change-badge--added { - color: var(--change-added); + color: var(--change-added-text); } .change-badge--changed { - color: var(--change-changed); + color: var(--change-changed-text); } .change-badge--removed { @@ -1738,11 +1744,11 @@ button.changes-panel-item:hover { } .change-row--added { - --change-row-color: var(--change-added); + --change-row-color: var(--change-added-text); } .change-row--changed { - --change-row-color: var(--change-changed); + --change-row-color: var(--change-changed-text); } .change-row--removed { @@ -1827,27 +1833,33 @@ button.changes-panel-item:hover { to { opacity: 0.85; } } -/* One colour cycle is two pulses; each layer peaks in its own pulse, and both sit at the floor between them. */ +/* The kind layer of an unexplained glow: it peaks in one pulse and fades under the red layer in the next. */ @keyframes flow-glow-swap { 0%, 50%, 100% { opacity: 0.35; } 25% { opacity: 0.85; } 75% { opacity: 0.15; } } +/* The red layer never drops below half strength, so an unexplained mark always reads red. */ +@keyframes flow-glow-red { + 0%, 50%, 100% { opacity: 0.5; } + 25% { opacity: 0.95; } +} + @media (prefers-reduced-motion: no-preference) { [data-ignatius="flow-svg"]:not([data-motion="off"]) .flow-glow rect, :is(.flow-index__pill, .change-dot--pulse)[data-change]::before { - animation: flow-glow-pulse 1.8s ease-in-out infinite alternate; + animation: flow-glow-pulse 0.9s ease-in-out infinite alternate; } [data-ignatius="flow-svg"]:not([data-motion="off"]) .flow-glow .flow-glow-unexplained, :is(.flow-index__pill, .change-dot--pulse)[data-unexplained]::before { - animation: flow-glow-swap 7.2s ease-in-out -3.6s infinite; + animation: flow-glow-red 3.6s ease-in-out -1.8s infinite; } [data-ignatius="flow-svg"]:not([data-motion="off"]) .flow-glow .flow-glow-unexplained + .flow-glow-kind, :is(.flow-index__pill, .change-dot--pulse)[data-unexplained]::after { - animation: flow-glow-swap 7.2s ease-in-out infinite; + animation: flow-glow-swap 3.6s ease-in-out infinite; } } diff --git a/src/app/views/graph/change-glow.ts b/src/app/views/graph/change-glow.ts index 5e465bd..84c2059 100644 --- a/src/app/views/graph/change-glow.ts +++ b/src/app/views/graph/change-glow.ts @@ -5,8 +5,8 @@ import { blendHex } from '../../logic/color'; import { GLOW_BLUR, GLOW_SPREAD } from '../../logic/change-marks'; import { animationsAllowed } from '../../logic/motion'; -// One full pulse, matching the flow glow's 1.8s alternate keyframes. -const GLOW_PERIOD_MS = 3600; +// One full pulse; `flow-glow-pulse` in styles.css runs 0.9s each way. +const GLOW_PERIOD_MS = 1800; // The floor stays well above zero so any single frame, a screenshot included, shows the mark. const GLOW_MIN = 0.35; const GLOW_MAX = 0.75; @@ -15,7 +15,7 @@ export const GLOW_STEADY = (GLOW_MIN + GLOW_MAX) / 2; /** Edge glows run the length of the line, so they draw weaker than node glows. */ export const EDGE_GLOW_SCALE = 0.6; /** How far a steady unexplained glow leans from its kind color toward red. */ -export const UNEXPLAINED_STEADY_MIX = 0.75; +export const UNEXPLAINED_STEADY_MIX = 0.85; // The shape is filled this far off-canvas and its shadow thrown back into view, so only the blur lands. const SHADOW_THROW = 10_000; /** Shared by styles.ts and the glow loop, so a glow dims with its node. */ @@ -162,7 +162,7 @@ export function createChangeGlow(cy: cytoscape.Core, container: HTMLElement, col function frame(now: number): void { const t = (now - startedAt) / GLOW_PERIOD_MS; const pulse = GLOW_MIN + (GLOW_MAX - GLOW_MIN) * (1 - Math.cos(2 * Math.PI * t)) / 2; - const redShare = (1 - Math.cos(Math.PI * (t - 0.5))) / 2; + const redShare = Math.min(1, 1.6 * (1 - Math.cos(Math.PI * (t - 0.5))) / 2); const palette = colors(); cy.batch(() => { edges.forEach((edge) => { diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 8b839e1..0836adc 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -6,7 +6,7 @@ import { parseModels } from '../model/parse'; import { pickModel } from './resolve-model'; // Static on purpose: lazy-importing this module, which server.ts imports statically, // makes the compiled binary throw `__promiseAll is not defined` at startup. -import { approve } from '../model/change-store'; +import { approve, readReview } from '../model/change-store'; import { VERSION } from './version'; import type { RouterFile } from '../router/build'; @@ -359,7 +359,11 @@ const exportCmd = defineCommand({ } const { generateApp } = await import('../generators/app'); - const html = await generateApp(model, flowModel, bundle, { themeMode: mode }); + const review = await readReview(dir, model).catch((err: unknown) => { + process.stderr.write(`ignatius: could not build the change review: ${err instanceof Error ? err.message : String(err)}\n`); + return null; + }); + const html = await generateApp(model, flowModel, bundle, { themeMode: mode, review }); await Bun.write(outputPath, html); console.log(`Wrote export to ${outputPath}`); diff --git a/src/generators/app.ts b/src/generators/app.ts index 80c1c15..cdf15f7 100644 --- a/src/generators/app.ts +++ b/src/generators/app.ts @@ -6,6 +6,7 @@ * - window.__IGNATIUS_MODE__ = "static" * - window.__MODEL__ (entity model — ERD graph + dictionary) * - window.__FLOW_MODEL__ (flow diagrams array — may be empty/undefined) + * - window.__CHANGE_REVIEW__ (change review against the approved baseline — only when one exists) * - window.__LAYOUT_KEY__ (ERD position-restore fingerprint) * - window.__FLOW_LAYOUT_KEYS__ (per-diagram fingerprint map — may be undefined) * - window.__FLOW_CLUSTERS__ (model-root clusters/ registry — may be undefined) @@ -24,6 +25,7 @@ import type { Model } from '../model/parse'; import type { FlowModel } from '../flows/flow-parse'; +import type { ChangeReview } from '../model/change-review'; import { layoutFingerprint } from '../model/layout-fingerprint'; import { buildFlowLayoutKeys } from '../flows/flow-fingerprint'; import type { BundleContent } from './embedded-bundle'; @@ -32,16 +34,17 @@ import { loadBundleFromDir } from './embedded-bundle'; export type GenerateAppOpts = { /** dark | light (default: dark) */ themeMode?: 'dark' | 'light'; + /** Change review to bake in so the static page shows what changed; null or omitted when no baseline exists. */ + review?: ChangeReview | null; }; /** - * Escape </script> sequences so the HTML parser cannot prematurely close an - * enclosing <script> tag. The backslash before / is invisible to the JS runtime - * (treated as a plain /), but breaks the browser's end-tag detection. - * WHY: flow body HTML is rendered markdown and can contain </script> in code fences. + * Make a JSON payload inert inside an inline <script>: every `<` becomes `\u003c`, which the + * JS runtime reads as `<` but the HTML parser never sees as `</script` or `<!--<script`. + * WHY: flow body HTML is rendered markdown, and change-note titles are agent-written. */ function escapeScriptClose(s: string): string { - return s.replace(/<\/script/gi, () => '<\\/script'); + return s.replace(/</g, () => '\\u003c'); } /** @@ -93,7 +96,7 @@ export async function generateApp( const layoutKey = layoutFingerprint(model); // Build injection script with the full union of globals. - // Order: mode → model → flows → layout keys → theme → surface (seed). + // Order: mode → model → flows → change review → layout keys → theme → surface (seed). let injection = `<script>` + `window.__IGNATIUS_MODE__ = "static"; ` + `window.__MODEL__ = ${escapeScriptClose(JSON.stringify(model))}; `; @@ -107,6 +110,7 @@ export async function generateApp( } injection += + (opts.review ? `window.__CHANGE_REVIEW__ = ${escapeScriptClose(JSON.stringify(opts.review))}; ` : '') + `window.__LAYOUT_KEY__ = ${escapeScriptClose(JSON.stringify(layoutKey))}; ` + `window.__THEME_MODE__ = ${escapeScriptClose(JSON.stringify(themeMode))}; ` + `</script>`; diff --git a/src/model/change-store.ts b/src/model/change-store.ts index 3a6bdac..c08bf67 100644 --- a/src/model/change-store.ts +++ b/src/model/change-store.ts @@ -111,9 +111,20 @@ async function loadNotes(modelDir: string): Promise<ReturnType<typeof parseChang export async function buildReview(modelDir: string, model?: Model): Promise<ChangeReview> { const current = await currentSnapshot(modelDir, model); const baseline = await ensureBaseline(modelDir, current); + return reviewAgainst(modelDir, baseline, current); +} + +async function reviewAgainst(modelDir: string, baseline: ModelSnapshot, current: ModelSnapshot): Promise<ChangeReview> { return crossCheck(diffSnapshots(baseline, current), await loadNotes(modelDir), baseline.model); } +/** The review against the stored baseline, never writing one; null when none is stored or it is unreadable, which serve would replace. */ +export async function readReview(modelDir: string, model?: Model): Promise<ChangeReview | null> { + const baseline = await readBaseline(modelDir); + if (!baseline) return null; + return reviewAgainst(modelDir, baseline, await currentSnapshot(modelDir, model)); +} + /** Accept the current parse as the baseline and delete every change note. */ export async function approve(modelDir: string): Promise<{ items: number; notes: number }> { const current = await currentSnapshot(modelDir); diff --git a/test/checks/test-change-review-dict-flow.ts b/test/checks/test-change-review-dict-flow.ts index 69d1ba7..873c1e0 100644 --- a/test/checks/test-change-review-dict-flow.ts +++ b/test/checks/test-change-review-dict-flow.ts @@ -268,10 +268,9 @@ try { animations.forEach(a => a.play()); return steps; }); - assert(cycle.some(s => s.kind >= 0.8 && s.red <= 0.2) && cycle.some(s => s.red >= 0.8 && s.kind <= 0.2), + assert(cycle.some(s => s.kind >= 0.8 && s.red <= 0.55) && cycle.some(s => s.red >= 0.9 && s.kind <= 0.2), 'SC9: an unexplained glow swaps between its kind color and red', JSON.stringify(cycle)); - const brightest = cycle.map(s => Math.max(s.kind, s.red)); - assert(Math.max(...brightest) >= 0.8 && Math.min(...brightest) <= 0.4, 'SC9: an unexplained glow dips between its colour swaps, matching the graph', JSON.stringify(brightest)); + assert(Math.min(...cycle.map(s => s.red)) >= 0.45, 'SC9: the red layer of an unexplained glow never falls below half strength', JSON.stringify(cycle)); const calm = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' }); await calm.route('**/events', route => route.abort()); diff --git a/test/checks/test-change-review-flow-index.ts b/test/checks/test-change-review-flow-index.ts index e7a1541..507991c 100644 --- a/test/checks/test-change-review-flow-index.ts +++ b/test/checks/test-change-review-flow-index.ts @@ -182,7 +182,7 @@ try { const pulse = await indexRow(page, 'Process Customer Return').evaluate(el => getComputedStyle(el, '::before').animationName); assert(pulse === 'flow-glow-pulse', 'an explained row glow pulses like the canvas glow', pulse); const swap = await indexRow(page, 'Record Sales Order').evaluate(el => [getComputedStyle(el, '::before').animationName, getComputedStyle(el, '::after').animationName]); - assert(swap.every(name => name === 'flow-glow-swap'), 'an unexplained row glow swaps between its kind colour and red', JSON.stringify(swap)); + assert(swap[0] === 'flow-glow-red' && swap[1] === 'flow-glow-swap', 'an unexplained row glow swaps between its kind colour and red', JSON.stringify(swap)); await indexRow(page, 'Record Sales Order').hover(); const summary = await page.locator('[data-ignatius="flow-index-change"]').innerText().catch(() => ''); @@ -200,7 +200,7 @@ try { const billingDot = await menuDot(menuRow('Billing')); assert(billingDot.count === 1 && billingDot.change === 'added' && billingDot.unexplained === 'true' && billingDot.glow !== 'none', 'a level menu row for an added flow carries one glowing dot marked added and unexplained', JSON.stringify(billingDot)); - assert(billingDot.animations.length === 2 && billingDot.animations.every(name => name === 'flow-glow-swap'), 'an unexplained menu dot swaps between its kind colour and red', JSON.stringify(billingDot)); + assert(billingDot.animations.length === 2 && billingDot.animations.includes('flow-glow-red') && billingDot.animations.includes('flow-glow-swap'), 'an unexplained menu dot swaps between its kind colour and red', JSON.stringify(billingDot)); const orderToCashDot = await menuDot(menuRow('Order To Cash')); assert(orderToCashDot.count === 1 && orderToCashDot.change === null && orderToCashDot.severity === 'unexplained' && orderToCashDot.animations[0] === 'none', 'a level menu row containing an unexplained change carries one static red dot', JSON.stringify(orderToCashDot)); diff --git a/test/checks/test-change-review-graph.ts b/test/checks/test-change-review-graph.ts index 18660e8..0208b82 100644 --- a/test/checks/test-change-review-graph.ts +++ b/test/checks/test-change-review-graph.ts @@ -279,7 +279,7 @@ try { assert(glowLayer.present && glowLayer.behind, 'SC7: node glows draw on a canvas behind cytoscape', JSON.stringify(glowLayer)); assert(glowLayer.pointerEvents === 'none', 'SC7: the glow canvas takes no pointer events', JSON.stringify(glowLayer)); - const glowFrames = await glowSamples(page, 3); + const glowFrames = await glowSamples(page, 7); const [glow] = glowFrames; assert(glow.personOutline === 0 && glow.personUnderlay === 0, 'SC7: a changed node draws no outline or underlay frame', JSON.stringify(glow)); assert(glow.person !== null && glow.person.alpha > 0 && glow.person.color === CHANGE_COLORS[glow.light ? 'light' : 'dark'].changed, 'SC7: the changed node glows amber', JSON.stringify(glow)); diff --git a/test/checks/test-export-change-review.ts b/test/checks/test-export-change-review.ts new file mode 100644 index 0000000..7047837 --- /dev/null +++ b/test/checks/test-export-change-review.ts @@ -0,0 +1,69 @@ +/** Export bakes in an existing baseline's change review and never creates a baseline, so exporting cannot bless unreviewed edits. */ + +import { chromium } from 'playwright'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { assert } from '../assert'; +import { approve } from '../../src/model/change-store'; + +const ROOT = resolve(import.meta.dir, '../..'); +const FIXTURE = join(ROOT, 'models/key-inherited'); + +if (!(await Bun.file(join(ROOT, 'dist/static/index.js')).exists())) { + console.log('SKIP: dist/static/index.js not built'); + process.exit(0); +} + +const work = mkdtempSync(join(tmpdir(), 'ignatius-export-review-')); +const dir = join(work, 'model'); +const leftovers = new Set([join(FIXTURE, '.ignatius'), join(FIXTURE, '.gitignore')]); +cpSync(FIXTURE, dir, { recursive: true, filter: source => !leftovers.has(source) }); + +async function exportHtml(name: string): Promise<{ path: string; html: string; stderr: string }> { + const path = join(work, name); + const proc = Bun.spawn(['bun', join(ROOT, 'src/cli/cli.ts'), 'export', dir, '-o', path], { stdout: 'pipe', stderr: 'pipe' }); + const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); + assert(code === 0, `FAIL: ${name} export exits 0 (code ${code})`, stderr.slice(0, 400)); + return { path, html: existsSync(path) ? readFileSync(path, 'utf8') : '', stderr }; +} + +const injection = (html: string) => html.slice(0, html.indexOf('<script type="module">')); + +const browser = await chromium.launch(); +try { + const unreviewed = await exportHtml('no-baseline.html'); + assert(!injection(unreviewed.html).includes('__CHANGE_REVIEW__'), 'FAIL: a model with no baseline exports no change review'); + assert(!existsSync(join(dir, '.ignatius')) && !existsSync(join(dir, '.gitignore')), 'FAIL: export writes neither .ignatius/ nor a .gitignore'); + + await approve(dir); + const person = join(dir, 'data/identity/Person.md'); + writeFileSync(person, readFileSync(person, 'utf8').replace(' birthdate:\n', ' nickname:\n type: text\n desc: "Added."\n birthdate:\n')); + mkdirSync(join(dir, '.ignatius/changes'), { recursive: true }); + writeFileSync(join(dir, '.ignatius/changes/hostile.md'), '---\ntitle: "<!--<script> </script><script>alert(1)</script>"\nentities:\n Person: [nickname]\n---\n\nWhy.\n'); + + const reviewed = await exportHtml('with-baseline.html'); + const match = /window\.__CHANGE_REVIEW__ = (\{.*?\}); window\./s.exec(injection(reviewed.html)); + assert(match !== null, 'FAIL: an approved model exports its change review'); + const review = JSON.parse(match?.[1] ?? '{}'); + assert( + review.items?.some((item: { scope: string; entity?: string }) => item.scope === 'column' && item.entity === 'Person'), + `FAIL: the exported review carries the added Person column ${JSON.stringify(review.items)}`, + ); + + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const pageErrors: string[] = []; + page.on('pageerror', err => pageErrors.push(err.message)); + await page.goto(`file://${reviewed.path}#view=graph`); + await page.waitForSelector('.changes-panel', { timeout: 15_000 }); + assert(pageErrors.length === 0, 'FAIL: a note title carrying script markup leaves the exported page running', pageErrors.join('; ')); + assert((await page.locator('.changes-panel-approve').count()) === 0, 'FAIL: a static export shows no Approve button'); + await page.waitForFunction(() => Object.keys(window.__IGNATIUS_GLOW__ ?? {}).length > 0, undefined, { timeout: 15_000 }); + const glow = await page.evaluate(() => window.__IGNATIUS_GLOW__ ?? {}); + assert('Person' in glow, `FAIL: the changed Person node glows in the export ${JSON.stringify(Object.keys(glow))}`); + + console.log(process.exitCode ? '\nexport-change-review: failures above.' : '\nAll export-change-review checks passed.'); +} finally { + await browser.close(); + rmSync(work, { recursive: true, force: true }); +}