feat(diagrams): compare two Mermaid files as a picture - #20
Conversation
Branch created so the spec has the one the plan's header points at; no implementation yet — its 16 steps are all outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model and diff layers of the visual-diff spec — the risky part, and pure, so
it is unit-tested before any UI exists.
modelFrom() wraps mermaid's own parser rather than duplicating the grammar:
getDiagramFromText + db.getData() returns the same {nodes, edges} shape for
flowchart, state, class and ER, runs in jsdom with no rendering, and returns null
for a type with no shared model rather than coercing one.
The probe that confirmed this also found something the plan did not anticipate:
an ER node's id carries its parse position (entity-CUSTOMER-0), so inserting an
entity above renumbers every one below and the whole diagram reads as
rewritten — the same class of problem the plan already flagged for domId. The
counter is stripped so the name is the identity, with a test that parses the
same entity at two positions.
diffDiagrams() keys on that semantic id and reports added/removed/changed, with
a relabelled node keeping what it was. Renames are paired only when a label
picks out exactly one removed and one added node: two nodes called "Task" make
the pairing a guess, and a wrong guess reads worse than the plain truth.
Mermaid's parser config moved to utils/mermaid.js so the renderer and the model
extractor share one definition — securityLevel 'strict' cannot drift between
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…us colours
The union source carries BOTH revisions so one layout holds them — two
independent renders drift, and a reader cannot tell drift from change. Status
rides on a `:::class`, never a classDef: mermaid compiles a classDef to an
inline `style="fill:… !important"`, a hardcoded colour no theme can re-tint.
Labels come from the compared FILES, so they are attacker-controlled text being
written back into Mermaid syntax (rule 6). Structural characters are stripped
rather than escaped — no legitimate label needs them, and stripping cannot be
half-right the way an escape table can. Four negative tests emit a hostile label
and re-parse the result: a classDef, an %%{init}%% directive and an injected
edge all fail to alter the graph. One assertion was wrong first time and is now
right: the WORD may survive as label text (mangling a label that mentions
classDef would be wrong) — what must not survive is a classDef statement.
focusDiff keeps the changes plus a ring of context. The first implementation
mutated the keep-set mid-pass, so a single hop cascaded the length of a chain
and "radius" meant nothing; each hop now expands from the set as it stood when
that hop began.
The three status tokens are measured, not asserted. --dg-del mixes toward
--text because nord's --danger-border is 2.24:1 on its card; nord also needs a
--dg-chg override because its sage and gold sit OKLab 0.081 apart, under the
0.10 floor, so added and changed would read alike.
check-theme-depth gained a fourth ratchet for exactly this, and it earned its
place immediately: it caught contrast --dg-chg at 2.74:1, which hand arithmetic
had scored as passing. All 14 now clear both floors — worst contrast 3.05
(nord), closest pair ΔE 0.102 (sepia).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pushed red: the pairwise ΔE loop nested four deep and focusDiff scored 12. Both split into named helpers rather than the caps being raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The viewer, the toggle and the wiring. Both sides parse to a graph, the graphs diff, and one union source carrying both revisions renders once — a single dagre layout, so an unchanged node cannot drift and be mistaken for a change. The toggle is the existing Structure checkbox renaming itself to Diagram, not a second control. comparableKind gained a 'diagram' branch, split into semanticKind so it stayed one question rather than growing past the complexity cap. Three bugs found by running it, none of which the unit tests could see: - Widening the toolbar's v-if exposed a tooltip that called structuredFormat.toUpperCase() — null for two .mmd files, so opening a diagram pair crashed the renderer into the error dialog. Each view now explains what it gives rather than naming a format it may not have. - The e2e first drove a synthetic cli:command, which loads nothing: main vouches for a path with allowCliPath before file:read will serve it. It spawns the real CLI now, which is the only thing that proves the round trip. - The toggle never appeared at all until the v-if was widened — step 9 of the plan, which I had skipped. Seeded a .mmd pair so the view can be opened by hand on the host; the change in it is deliberately the kind a text diff reads badly. npm run check: green. Docker e2e: 3 passed. check:themes: all 14 clear both floors, worst contrast 3.05 (nord), closest pair ΔE 0.102 (sepia). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Changes requested
The layering is right — model, diff, union and focus are pure and tested before any UI exists, and the theme ratchet is the strongest part of this change: it caught a failure hand arithmetic had scored as passing, which is exactly the job. One real bug.
🔴 1 · build() is an unguarded async race
DiagramDiffViewer.build() is async and awaits two modelFrom calls, then writes source, counts, hidden and rows. The watcher fires on left.content, right.content, focused, radius and diffRevision — several of which can change in quick succession (a re-diff, a focus toggle while a parse is in flight, an edit).
Nothing sequences them. A slower earlier run can resolve after a newer one and overwrite the newer state, leaving the picture showing one revision's diff while the register and counts show another's. Worse, it is intermittent and load-dependent — the failure mode that gets dismissed as "it looked fine when I tried it".
This repo already learned this: MermaidDiagram.vue carries renderSeq with the comment "Only the newest render may touch the DOM (a fast edit can outrace an old one)". The same guard belongs here, and the fact that the component this one embeds already has it makes the omission harder to defend, not easier.
Fix: a monotonic seq captured at entry; discard the result if it is no longer current before assigning.
🟡 Notes
:key="i"on the register rows. Index keys make Vue reuse the wrong row when the list reorders — and it will, since node and edge statuses change between renders.${status}:${id ?? start+end}is stable.- A full re-parse per
diffRevision. Fine for the diagrams anyone hand-writes; worth remembering if a large generated diagram ever lands here. - Step 16 outstanding is the right call — a mis-seeded screenshot yields a plausible wrong picture, and that is worth a human's eye.
✅ Verified
- Rule 6 — the hostile-label tests are the right shape: emit, then re-parse, and assert the graph is unchanged. Stripping structural characters rather than escaping them is the more defensible choice and the reasoning is recorded.
- Rule 8 — no
v-html/innerHTMLin either new component; the SVG goes through the existingDOMParser+importNodepath, andsecurityLevel: 'strict'is now shared rather than duplicated. - Themes — the ratchet measures both floors on all 14 and the margins are honest (3.05 and 0.102, both tight). Overrides are per-theme with a one-line why, not a global rule that breaks three to fix two.
- Structure — components are 89 and 29 lines;
comparableKindwas split rather than the cap raised.
Review finding 1. build() awaits two model parses before assigning source, counts, hidden and rows, and the watcher fires on five things — several of which change in quick succession. Nothing sequenced them, so a slower earlier run could resolve after a newer one and leave the picture showing one revision while the register and counts showed another: intermittent, load-dependent, and the kind that gets dismissed as "it looked fine". MermaidDiagram — the component this one embeds — already carries exactly this guard for exactly this reason. A monotonic seq captured at entry now discards a stale run before it assigns. Also the register's index keys, which make Vue reuse the wrong row when a status changes and the list reorders. Keyed on status plus identity instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Resolved — approving
1 · the async race — buildSeq captured at entry, checked after the two awaits, stale run discarded before it assigns. It matches the guard MermaidDiagram already carries, which is the right precedent to follow rather than invent a second pattern.
Register keys — now status:identity rather than the index.
npm run check green; Docker e2e 3 passed after the change.
Merging notes
- Step 16 (the screenshot) is still open and should stay open until someone looks at the frame. Approving the code is not approving an unseen capture.
- The three status tokens are now under the
check-theme-depthratchet, so a future theme cannot reintroduce the matrix collision silently. Margins are tight by design — 3.05 and ΔE 0.102 — which means a palette change that nudges either will fail the build rather than degrade quietly. That is the intent; worth knowing before someone reads a future failure as the guard being fussy.
Approved.
…he diagram
The viewer was built from the plan's prose rather than the mockup it links, and
it showed. Rebuilt against the proposal:
- The change register is a right-hand RAIL, not a footer list — it is read
alongside the picture, with Changes and Edges grouped and a status stripe down
each row.
- Legend chips carry a glyph (+ − ± ·) as well as a colour, including
"unchanged", with the diagram type on the right.
- The status band counts in words — "Nodes 2 added · 0 changed · 1 removed".
- Nodes are fill-tinted, not stroke-only: 16% of the status colour over --bg for
added and removed, --bg-elevated for changed, a hairline for unchanged. That
is the redundancy a stroke alone does not give a colour-blind reader.
!important is load-bearing here rather than lazy: mermaid injects
`#<svgId> .node rect { fill; stroke }` INTO the svg, and an id selector beats any
class chain written from outside. Without it only stroke-dasharray survived —
the diagram rendered in mermaid's default lavender while the tests passed.
Edges now carry status too, via mermaid's edge-id syntax (`A e0@--> B` plus a
`class e0 added` STATEMENT — unlike classDef it emits no inline style, so the
colour stays ours to theme).
Split view now splits the diagram, as the proposal's segmented control does: two
laid-out revisions with before/after titles, and the drift note that is the whole
argument for the union view. Focus on changes moved to the toolbar beside Split
view and Diagram, where its siblings are.
Seeded a ~35-node service map, because five nodes prove nothing about a view
whose job is hiding what did not change: focus hides 25 there.
Verified by looking, not only by asserting — light and dark, union and split,
small and large.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-approving at aa989bd
The viewer now matches the linked design proposal, which the first cut did not — it had been built from the plan's prose. Checked against the mockup point by point: register as a right-hand rail with Changes/Edges groups and status stripes, legend chips carrying a glyph as well as a colour, word-counted status band, fill-tinted nodes.
The finding worth recording is that the first cut passed every test while rendering in mermaid's default lavender. Mermaid injects #<svgId> .node rect { fill; stroke } into the SVG, and an id selector beats any class chain written from outside — so only stroke-dasharray, which mermaid does not set, survived. Nothing in the suite could see it, because no assertion looked at a colour. !important here is the fix, not a shortcut.
Split view now splits the diagram rather than doing nothing, which is the right home for it — the toggle already means "two panes", and the drift note it surfaces is the argument for the union view stated where it is felt. Focus on changes sits with its siblings in the toolbar.
The ~35-node seed matters more than it looks: focus mode hides 25 nodes there, and on the five-node example it had nothing to do.
npm run check green; Docker e2e 3 passed, now asserting both modes.
Still outstanding
Step 16, the committed screenshot. Unchanged from before: it needs a human eye, and approving code is not approving an unseen capture.
Approved.
Four things found by using it on a diagram worth diffing. Pan and zoom reuse composables/useZoomPan — the same one MermaidViewerDialog drives, rather than a second gesture layer. A 35-node service map is unreadable at fit-width, which is the only size mermaid renders it at. Edge status went through a `class eN removed` statement, which mermaid PARSES AND THEN IGNORES: the rendered path carried only `edge-pattern-solid flowchart-link`, so no edge was ever coloured. Status now goes through mermaid's own link syntax — dotted for removed, thick for added — and the classes it emits for those (edge-pattern-dotted, edge-thickness-thick) are what the stylesheet colours. Reliable because this view generates every edge in the source. Side-by-side panes share one zoom layer; transforming each independently made them overlap the moment either was scaled. Both start at the top so the first rank lands at the same height on each side, and the drift note now says what is actually true — aligned at the first node, free below it. Two self-inflicted regressions on the way, both caught by looking rather than by a test: wrapping the diagram in a transformed div gave it auto height, and MermaidDiagram is height:100%, so the canvas went blank; then the same wrapper as a flex child shrank to content and width:100% resolved against zero. An alignment assertion is deliberately NOT added: I could not make it fail with the fix reverted, and a test that has never failed guards nothing. Comparing pasted diagrams already worked — comparePasted fills left/right, which is what canCompareDiagram reads. Pinned with a test so it stays true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… as noise The drift note coloured its TEXT with --dg-chg. That token is floored at 3:1 as a stroke; as body text it scores 2.14 on light and fails the 4.5:1 text floor on ten of the fourteen themes — the same trap the standards already record for accent-coloured labels. Measured across all 14 rather than eyeballed. The amber is a left rule now and the text is --text, which clears everywhere. The change rail was noise: every row bold so none stood out, "new"/"removed" repeating what the + and − already said, 52px section heads, raw ids where the nodes had labels, and <button> rows offering a press that did nothing — one of which was wearing a stuck focus ring in the reported screenshot. Rebuilt as a reading rail: one line per change, plain name with the status carried by a coloured mark, removed rows dimmed because they are history rather than the new state, counts in the heads, and list items instead of buttons so nothing pretends to be interactive. Also gives the rendered svg the width its viewBox asks for (utils/svgNaturalWidth, capped so a broken diagram cannot demand a gigapixel canvas). Honest note: this did NOT fix readability on the seeded service map, because mermaid's own viewBox for it is already compact — pan and zoom remain the answer there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults in one strip, both measured across all fourteen themes rather than eyeballed. The head LABEL was --text-dim on the elevated band: 2.82 on sepia, 2.92 on nord, under the 4.5:1 text floor on nine of the fourteen — and it is 9px uppercase, which needs more contrast than body text, not less. It takes --text now, which clears from 6.30 (nord) to 14.74 (beacon). The count beside it stays dim, being the secondary half. The head BAND was fenced on one side only. --bg-elevated sits ~1.2-1.4:1 from the rail by design — that is what the role is for — so a single bottom border left it reading as a slightly different stripe rather than a band. Ruled on both sides now, the same way the sidebar's own section heads are, which is what the design proposal does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bands blended because every surface in the viewer was the same role. On all but the light theme --bg-raised IS --bg-panel, so the card, the rail and the bands sat on one colour and only the elevated band's ~1.26 shifted off it. Measured every surface pair across the fourteen themes before touching anything: --bg/--bg-panel is 1.05-1.24, --bg-panel/--bg-elevated 1.22-1.39, --bg-hover/--bg-panel 1.10-1.17. No token in this system gives a strong background break — that is the depth contract working as designed, and the only strong signal it offers is --border (1.24 on bloom to 16.87 on contrast). So the split is by ROLE rather than by tone: the stage takes --bg as the surface being read, and the strips around it take --bg-panel as chrome. The drift band is fenced top and bottom like every other band in the app, and the sticky rail head carries --shadow-1, elevation being the one cue the surface roles cannot supply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rface role It wore --bg-elevated, which is a chrome role — so a notice looked like another band and dissolved into the legend directly above it. A warning should be coloured by what it means. 30% of --dg-chg over --bg-panel, which re-tints per theme. The number is chosen for HUE, not luminance: even a 40% mix tops out at a 1.47 luminance ratio against the panel because amber sits close to several of these grounds, while the amber wash itself is obvious. --text stays at 5.49 on the worst theme. The copy was also stale — it still claimed unchanged nodes sit at different heights, which stopped being true when the panes were aligned at the first node. It now says what is actually the case: aligned there, free below it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the hole three shipped bugs went through. check:themes audited the tokens against the surface ROLES, which says nothing about a component pairing a token with a ground of its own choosing — so --dg-chg as body text (2.14 on light), --text-dim on an elevated band (2.82 on sepia), and a chrome role behind a semantic notice all passed a green build. Only same-rule pairs are checked, because that is the subset CSS can answer on its own: when one rule sets BOTH color and background, the ground is not a guess. 201 rules qualify; 87 are skipped and the count is printed, so the limit of the check is visible rather than implied. 168 pairs were already below the floor. Failing on all of them would have meant either a cleanup nobody asked for or a floor quietly set to whatever passed, so they go in a committed baseline that only turns one way: a new pair must clear 4.5 outright, and a baselined one may improve but never worsen. Both halves proved by re-introducing the real bug (11 themes fail, worst 2.14 — the figure I had measured by hand) and by degrading a baselined pair (caught as WORSE). Writing it surfaced the same defect in both guards: neither stripped comments, so prose containing a colon parsed as a declaration and swallowed the real one after it — which is why the drift rule was silently skipped on the first run. check-style-tokens had the mirror image, reporting a hex mentioned in a comment as a hardcoded colour. Both strip comments now; the style guard keeps token-exempt markers, since blanking those would withdraw an exemption someone deliberately wrote. Stage ground softened to a 40% mix of the chrome tone: --bg alone is pure white on light and contrast, which is a lot of glare behind a diagram. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I had been reporting "only the screenshot is left" while tracking the step list and not re-reading the plan's own Docs-impact table. Three rows marked yes were untouched. The empty state now offers Mermaid, which needed a `mermaid` key in fileFilters.js first — the renderer names a format and never supplies extensions of its own (rule 6), so the tile could not exist without main knowing the key. Until now nothing told a reader the app compares .mmd at all. roadmap.md gains a Diagrams track: what is built, and the two things that are not. The board goes back to four cards on its original grid — I had re-laid it to three when the tab track shipped — with the rose lane for diagrams. Rendered and looked at rather than trusted to arithmetic. Validation recorded as fact: check green at 1914 passed, Docker e2e 10 passed including a neighbouring spec because this branch changed shared mermaid config, and the seed round trip verified against a sandbox SEED_USER_DATA rather than the real library, which has live data in it — 46 entries in, 0 left after --clean. Token usage stays "not measured": no baseline was recorded when this branch started, and inventing one would be worse than the gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-approving at 6c426b7 — spec closed out
Since the last approval this branch closed the loose ends and gained a guard worth more than the feature.
The guard. check:themes now audits every rule that declares both color and background — 201 of them, with 87 skipped and counted so the limit is visible. It exists because three bugs shipped through a green build, all the same mistake: a token used outside the job it is floored for. Introduced with a 168-entry ratchet baseline rather than a floor set to whatever passed, and proved in both directions — the real bug re-introduced fails 11 themes at 2.14, and degrading a baselined pair fails as WORSE.
Writing it exposed that neither guard stripped comments, so prose containing a colon parsed as a declaration and swallowed the real one after it. The first run silently skipped the very rule it was written for. That was caught by testing the guard against the bug instead of trusting a green result — the right instinct, and one the earlier passes on this branch lacked.
Loose ends. Three Docs-impact rows marked yes had been missed while tracking the step list: the Mermaid tile in the empty state (which needed a mermaid key in fileFilters.js first, since the renderer only ever names a format), the roadmap's Diagrams track, and the board re-laid to four cards.
Validation is recorded as fact: 1914 passed, Docker e2e 10 including a neighbouring spec because this branch changed shared mermaid config, and the seed round trip run against a sandbox SEED_USER_DATA rather than the maintainer's live library — 46 entries in, 0 after clean.
What is deliberately not done
- Step 16, the screenshot. The one artifact no assertion can validate. Approving code is not approving an unseen capture.
- Readable at rest. Mermaid's svg has no intrinsic width, so a large map fits the pane and is unreadable until zoomed.
svgNaturalWidthis correct and capped but did not move that case. On the roadmap as thenowitem, not hidden as a TODO. - Token usage. No baseline was recorded when the branch started; "not measured" beats a number nobody can reproduce.
Approved.
Two corrections, one of them mine. `shipped` means ready for a HUMAN to review — every step ticked, every Docs-impact yes done, every Validation line answered with a fact, the PR open and the agent review resolved. It is not about merging: merging is the human's call, and a spec that waited for it would sit in in-progress describing work that is finished. specs/README.md now spells out all four values in a table, and says that a point which genuinely cannot be done is stated on its own line with the reason rather than left silent. Step 16 was not deferrable under that definition, so it is done: the recapture script grows a diagram-diff shot that writes a throwaway .mmd pair, turns on the Diagram view, switches to the union layout — one diagram carrying both revisions being the thing worth showing — and cleans up after itself. The frame is committed with its README alt, and the gallery and empty-state alt both mention Mermaid now. Every Scope "In" item was then checked against the tree rather than against the step list, which is how three Docs-impact rows went missing earlier. All present; 39 unit tests across the five pure modules green. Token usage stays unmeasured with the reason on the line: no baseline was recorded when the branch started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Spec shipped — ready for human review
All 16 steps ticked, every Docs-impact "yes" done, every Validation line answered with a fact. Nothing outstanding.
The last step is done, not deferred. make screenshots SHOTS="diagram-diff" now has a shot in the pipeline: it writes a throwaway .mmd pair, turns the Diagram view on, switches to the union layout (one diagram carrying both revisions being the thing worth showing) and cleans up after itself. The frame is committed with its README alt, and both the gallery and the empty-state alt mention Mermaid.
Confirmed against the tree, not the step list — which is how three Docs-impact rows went missing earlier: diagramModel · diagramDiff · diagramUnion · diagramFocus · svgNaturalWidth · the two components · three --dg-* tokens with the nord and contrast overrides · the fourth ratchet · the widened toggle · the 'diagram' branch at App.vue:157 · pan/zoom via the shared useZoomPan · two seeded pairs · the e2e · the screenshot. 39 unit tests across the five pure modules, green.
npm run check 0 — 1914 passed. Docker e2e 3 passed.
Two things left open on purpose
Both are on the roadmap's new Diagrams track rather than TODOs in code:
- Readable at rest — mermaid gives its svg no intrinsic width, so a large map fits the pane and is unreadable until zoomed.
svgNaturalWidthis correct and capped but did not move that case. - Click a change to pan to it — the rail is read-only today.
One thing that cannot be done
Token usage. No Token baseline was recorded when this branch started and token-usage.mjs needs one, so the header reads "not measured" rather than a number nobody could reproduce. Stated on the line rather than left silent.
Approved.
Same collision as the snippet branch: #18 appended a session round-trip test to the end of diffStore.test.js, where this branch appended the diagram-comparison suites. All three are kept; they test unrelated things. check green at 1946 passed after the merge, Docker e2e 3 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what my slice-by-slice local runs never could: adding the Mermaid tile took the supported-format row from six buttons to seven, and ui-affordances.spec pinned the number. I had run the diagram spec repeatedly and the whole suite not once. Naming them is the better assertion anyway — a bare count says nothing about WHICH tile went missing, and its failure reads "6 became 7", which describes the symptom rather than the change. Whole suite run locally after the fix: 303 passed, 2 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-approving — conflict resolved and a CI failure fixed
Two things since the last approval.
The merge. #18 appended a session round-trip suite to the end of diffStore.test.js, where this branch appended the diagram suites. All three kept; no source file conflicted.
A real CI failure, caught by CI and not by me. ui-affordances.spec pinned the supported-format row at six buttons, and the Mermaid tile made it seven. Worth naming plainly: the diagram spec was run locally many times and the whole suite not once, so a green local slice was mistaken for a green suite. CI is what found it.
The assertion is now by NAME rather than count — a bare number says nothing about which tile went missing, and its failure read "6 became 7", describing the symptom instead of the change. Whole suite re-run locally afterwards: 303 passed, 2 skipped.
Approved, subject to CI on this head going green — the previous run failed on exactly the assertion this commit fixes.
#20 landed the diagram spec, whose suites append to the same end of diffStore.test.js that dropSnippets does — the third time this branch has conflicted there, and every time the same collision: two branches appending, not disagreeing. All four suites kept: dropSnippets, the two diagram ones, and the session round trip. check green at 1974; the WHOLE e2e suite run before pushing this time — 307 passed, 2 skipped — because last round a partial local run let a broken assertion through to CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec:
specs/2026-08-02-mermaid-visual-diff/plan.md(15/16 steps).Two
.mmdfiles compare as diagrams instead of as text — where an inserted stage re-indents every following line, a re-pointed edge is a one-character diff that changes the topology, and a node renamed at its id is two unrelated lines.Approach
Both sides parse through mermaid's own parser (
getDiagramFromText+db.getData()), which returns the same{nodes, edges}shape for flowchart, state, class and ER, runs in jsdom with no rendering, and keeps the whole model layer a pure unit. Hand-writing a parser would duplicate the grammar and drift on every release.The graphs diff, and one union source carrying both revisions renders once. That is the point: two independent renders lay out separately, so an unchanged node moves and the reader cannot tell drift from change.
Status rides on a
:::class, never aclassDef— mermaid compiles a classDef to an inlinestyle="fill:… !important", a hardcoded colour no theme can re-tint.What the build found that the plan assumed
entity-CUSTOMER-0), so inserting an entity above renumbers every one below and the whole diagram reads as rewritten — the same trap the plan flagged fordomId, one level deeper. The counter is stripped; there's a test that parses the same entity at two positions.--bg-raised: only nord fails on contrast (2.24), and nord also fails the ΔE floor (sage vs gold, 0.081).contrastneeded the--dg-chgoverride after all — caught by the new ratchet, which hand arithmetic had scored as passing.Security
classDef, a%%{init}%%directive and an injected edge all fail to alter the graph.MermaidDiagram, which usesDOMParser+importNode;securityLevel: 'strict'is now shared fromutils/mermaid.jsso the renderer and the extractor cannot disagree about it.Themes
check-theme-depth.mjsgained a fourth ratchet: each status colour ≥ 3:1 on the viewer's card, and each pair ≥ 0.10 OKLab apart — because contrast alone does not stop two statuses reading as one. On matrix,--accentis--success-text, so an accent-tinted "changed" would score fine and be invisible.All 14 clear both floors: worst contrast 3.05 (nord
--dg-del), closest pair ΔE 0.102 (sepia add/chg).Verification
npm run checkgreen;check:themesgreen across 14.diagram-diff.spec.mjs, 3 passed: one stitched SVG, focus hiding the untouched part with a count, and toggling off returning to Monaco.Outstanding — step 16
make screenshots SHOTS="diagram-diff"and the READMEalt. The frame needs a seeded run in the container and a human look before it lands: a mis-seeded capture yields a plausible wrong picture.Step 12 (pan/zoom via
useZoomPan) was folded into the stage's own scroll rather than added — a second gesture layer is a change worth making deliberately.🤖 Generated with Claude Code