Skip to content

Render unsupported Mermaid types (gantt, pie, etc.)#31

Open
hazadus wants to merge 15 commits into
tqbf:mainfrom
hazadus:feat/gantt-rendering
Open

Render unsupported Mermaid types (gantt, pie, etc.)#31
hazadus wants to merge 15 commits into
tqbf:mainfrom
hazadus:feat/gantt-rendering

Conversation

@hazadus

@hazadus hazadus commented May 15, 2026

Copy link
Copy Markdown
Contributor

I use Gantt charts a lot in my projects, so I was excited to finally have a comfortable way to view them in mdv — only to find out they weren't supported! Hence this PR. Please consider the proposed approach. I am ready to address any feedback or take a different approach to the implementation if necessary. The considered alternatives are described at the end of the PR.

Summary

Mermaid fenced blocks whose diagram type isn't supported by BeautifulMermaid — gantt, pie, timeline, mindmap, and anything else added in the future — silently failed with "Mermaid diagram could not be rendered". This PR renders those types using the full mermaid.js library in an offscreen WKWebView, so the fallback message effectively disappears for all valid Mermaid source.

Root cause

BeautifulMermaid supports exactly 6 diagram types: flowchart, state, sequence, class, ER, and XY chart. Any other first-line keyword falls through to the flowchart parser, which chokes on the alien syntax (gantt tasks, date ranges, milestones), and MDVMermaidImageCache.renderImage returns nil via try?. MDVMermaidDiagramView then sets failed = true and shows MermaidFallbackView.

There is no gantt support in BeautifulMermaid's git history, and the library's own README states "6 diagram types" with no roadmap entry for gantt.

Fix

Detect the diagram type from the first meaningful line before attempting BeautifulMermaid. The dispatcher (firstMermaidDirectiveLine + isBeautifulMermaidSupported) skips Mermaid preamble — frontmatter (--- … ---), %%{ init: … }%% directives, and %% line comments — so a flowchart with a title block still reaches the native pipeline. Keyword matching requires whitespace or end-of-line after the keyword, so flowchart LR and bare flowchart are accepted but flowchart-elk (a different layout backend BeautifulMermaid doesn't speak) is not.

Supported types go through the existing native pipeline unchanged. Everything else is handed to a new MermaidWebViewContainer that:

  1. Loads an HTML page with the full mermaid.js (v11.4.1, bundled in Contents/Resources/) inlined as a <script>.
  2. Runs mermaid.run() asynchronously.
  3. Reports the rendered SVG height back to Swift via window.webkit.messageHandlers.mermaidHeight as a structured { ok, height | error } payload, so SwiftUI can size the frame correctly after render and route mermaid.js parse failures back into the existing MermaidFallbackView (showing the "could not be rendered" header and the raw source) instead of leaving a blank box.

The WebView is opaque and the HTML body is painted with theme.secondaryBackground (the same color the surrounding chrome uses), so the diagram region matches the chrome on every theme without resorting to private WebKit KVC. updateNSView only reloads the page when (source, themeID, isDark) actually changes, so SwiftUI re-renders driven by the height write don't bounce back into a load loop. Toolbar items that only make sense for the native pipeline — the diagram-style picker and PNG export — are hidden on web-rendered diagrams; the source toggle, copy button, and accessibility label (matching the native "Mermaid diagram") are kept.

What changed

File Change
mdv/MermaidWebRenderer.swift New — MermaidWebViewContainer (SwiftUI view with dynamic height, fallback-on-failure, AX label) + MermaidWebView: NSViewRepresentable wrapping WKWebView with diff-based reload, themed body background, structured success/error messages
mdv/MermaidRenderer.swift firstMermaidDirectiveLine() strips Mermaid preamble; isBeautifulMermaidSupported() matches keywords with whitespace/EOL boundaries; MDVMermaidDiagramView.body branches on it; MermaidCodeBlockChrome gates the style picker and PNG export on the dispatcher predicate; MermaidFallbackView promoted to internal so the web path can reuse it
build.sh Downloads mermaid.min.js (pinned MERMAID_VERSION="11.4.1" + MERMAID_SHA256) on demand and verifies the SHA-256 of the cached file on every build — local drift or a CDN regression hard-fails
.gitignore Exclude mdv/mermaid.min.js (downloaded asset, not committed)
test-docs/gantt.md Generic English gantt test document
test-docs/mermaid-web-fallback.md One example each of pie, timeline, mindmap, journey, quadrantChart, requirementDiagram, plus a flowchart with %%{init}%% + %% comment to lock in the preamble-stripping path
README.md Add Mermaid to the features list (gap left by #29)
mdv/Help.md New "Mermaid diagrams" section (gap left by #29)
test-docs/README.md Add gantt.md and mermaid-web-fallback.md entries

mermaid.min.js is not committed; the first build will fetch it. To pre-fetch:

./build.sh debug

Test plan

Open test-docs/gantt.md:

  • Gantt diagram renders (sections, tasks, milestones, critical bars, emoji in task names) — not the "could not be rendered" fallback
  • Diagram respects light/dark theme (mermaid default theme in light, dark in dark) and matches the surrounding chrome background on every theme (no light/system slab behind the SVG on dark themes)
  • Curly-braces toolbar button toggles to Mermaid source view and back
  • Style picker and PNG export buttons are hidden on the gantt diagram (they only do work the WKWebView path can't honour)
  • Existing supported diagram types (any flowchart/sequence/class/ER/state/XY in the test suite) still render via BeautifulMermaid — native quality unchanged, full toolbar present
  • Switching themes re-renders the diagram once, not in a loop (no main-thread CPU spike, no flicker beyond the single transition)
  • Breaking the gantt source on purpose (e.g. typoing the keyword) shows the existing MermaidFallbackView plate with the source code, not a blank box

Open test-docs/mermaid-web-fallback.md:

  • pie / timeline / mindmap / journey / quadrantChart / requirementDiagram all render
  • The trailing flowchart (preceded by a %%{init}%% directive and a %% comment) renders via BeautifulMermaid — verifiable because its toolbar shows the style picker and PNG export

VoiceOver smoke check:

  • Web-rendered diagrams announce as "Mermaid diagram" (parity with the native path) instead of leaking the WebView's internal AX tree

BEFORE (gantt block, any theme):

image

AFTER:

image
Screenshots of other diagram types (pie / timeline / mindmap / journey / quadrantChart / requirementDiagram) image image image

Alternatives considered

1. Implement gantt natively in Swift (not chosen)

Would require writing a full gantt parser (date arithmetic, task/milestone/section grammar, axisFormat, dateFormat) plus a renderer that produces correct bar widths and axis labels. That is essentially re-implementing a significant chunk of mermaid.js — hundreds of lines of error-prone layout code with no benefit over the reference JS implementation.

2. Fork BeautifulMermaid and add gantt (not chosen)

BeautifulMermaid is built around the ELK graph-layout engine, which is node-and-edge oriented. Gantt diagrams have no graph topology — they are time-series bars. Adding gantt would require a parallel rendering subsystem that bypasses ELK entirely, making it a near-standalone contribution to a third-party library.

3. WKWebView snapshot → NSImage (considered seriously)

Render in a hidden WKWebView, call takeSnapshot(with:), and feed the result into the existing MDVMermaidImageCache pipeline. This would give gantt diagrams the same zoom/pan/export-PNG toolbar behaviour as native types. Rejected because: (a) getting the correct initial frame size before mermaid renders requires a two-pass layout — first load, wait for height message, resize, snapshot — which adds complexity and latency; (b) snapshot scale / retina handling needs manual wiring; (c) the inline WKWebView already auto-sizes correctly via the height message, so the added complexity buys little.

4. CDN-loaded mermaid.js (not chosen)

Drop the bundle, load from cdn.jsdelivr.net at render time. Saves ~3 MB from the download step but breaks offline use — a markdown viewer that fails to render diagrams when there's no Wi-Fi is a bad trade-off.

5. Vendoring mermaid.min.js into git (not chosen)

The simplest answer to "what bytes are we shipping?" is to commit them. Rejected because the file is 2.5 MB of minified third-party JS and would dominate the repo's churn whenever it's bumped. Pinning MERMAID_VERSION and MERMAID_SHA256 in build.sh and verifying on every build (including the cached local file) gives the same integrity guarantee — a CDN regression, a stale dev cache, or a hand-edited copy all hard-fail the build with a pointer at the next step.

6. webView.setValue(false, forKey: "drawsBackground") (initially used, then removed)

The first cut made the WebView transparent so the chrome's themed background showed through. That's a private-API KVC poke; replaced by leaving the WebView opaque and painting the HTML body to theme.secondaryBackground from inside the page, which gives the same visual result on every macOS version without relying on undocumented WebKit behaviour.

Chosen: inline WKWebView + bundled mermaid.js

Minimal new code, works offline, correct rendering guaranteed by the reference implementation, self-sizing via JS message passing, and integrity-checked at build time. The existing native pipeline for the 6 supported types is untouched.

@hazadus hazadus changed the title Render unsupported Mermaid types (gantt, pie, etc.) via WKWebView + bundled mermaid.js Render unsupported Mermaid types (gantt, pie, etc.) May 15, 2026
hazadus and others added 2 commits May 16, 2026 00:14
BeautifulMermaid only handles 6 diagram types (flowchart, state,
sequence, class, ER, XY chart). Any other type — gantt, pie, timeline,
mindmap, etc. — fell through to the flowchart parser, failed silently,
and showed "Mermaid diagram could not be rendered".

Fix: detect the diagram type from the first line before attempting
BeautifulMermaid. For supported types the existing native pipeline
(BeautifulMermaid → NSImage → zoom/pan/export) is unchanged. For
everything else, render via a WKWebView backed by the full mermaid.js
library bundled offline in Contents/Resources.

- MermaidWebRenderer.swift: NSViewRepresentable wrapping WKWebView.
  Loads an HTML page with inlined mermaid.js, runs mermaid.run(),
  then reports the rendered SVG height back via a JS message handler
  so SwiftUI can size the frame correctly.
- MermaidRenderer.swift: isBeautifulMermaidSupported() checks the
  first line; MDVMermaidDiagramView branches to MermaidWebViewContainer
  for unsupported types.
- build.sh: copy mermaid.min.js into Contents/Resources.
- .gitignore: exclude mdv/mermaid.min.js (downloaded asset).
- test-docs/gantt.md: generic English gantt test document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR tqbf#29 added Mermaid rendering but none of the user-facing docs were
updated. Our gantt follow-up made the gap more visible: gantt.md was
listed nowhere, and Help.md had no mention of diagrams at all.

- README.md: add Mermaid to the features bullet list, noting the
  native/WKWebView split.
- Help.md: new "Mermaid diagrams" section covering the style picker,
  source toggle, export, zoom, and the native-vs-bundled rendering split.
- test-docs/README.md: add gantt.md entry explaining what it tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@hazadus hazadus force-pushed the feat/gantt-rendering branch from 5485b94 to 71d9a1a Compare May 15, 2026 21:15
The file is excluded from git (.gitignore) but CI has no other way to
obtain it, so the bundle step fails with 'cp: mdv/mermaid.min.js: No
such file or directory'. Pins to mermaid 11.4.1 from jsDelivr so the
build is reproducible; skips the download when the file already exists
locally (developer workflow).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@hazadus hazadus marked this pull request as draft May 15, 2026 21:39
hazadus and others added 12 commits May 16, 2026 00:47
The unsupported-types path called `webView.loadHTMLString` from
`updateNSView` unconditionally. Each reload measured the rendered
SVG height in JS, posted it back through the script-message handler,
wrote `@State height`, triggered another SwiftUI update, and
reloaded the page again — a feedback loop that violates the
CLAUDE.md guideline against writing `@State` from layout-derived
events. Visible symptom: gantt diagrams flickering on theme change
and the main thread doing a lot of needless mermaid.js work.

Two guards on the load path:

- Track `LoadKey(source, themeID, isDark)` in the coordinator and
  short-circuit `updateNSView` when the key is unchanged. Reloads
  now happen only when the diagram source or the theme actually
  changes.
- In the height handler, suppress republishes that move the measured
  height by less than 0.5pt, so any residual layout noise can't
  bounce back through the view tree.

The CSS / JS / HTML and the public surface of `MermaidWebView` are
unchanged.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
When the bundled mermaid.js threw — bad syntax, an unknown diagram
keyword that mermaid itself rejects, anything in the `.catch` arm —
the page used to post `-1` as a height and the Swift side silently
ignored it. The user got a blank 300pt box with no source, no error,
and no way to see what they had typed. That is strictly worse than
the native path, which falls back to `MermaidFallbackView` showing
the original code and a "could not be rendered" header.

Reuse the same fallback for the web path:

- `MermaidWebViewContainer` gains an `@State failed` flag bound
  into the representable. When set, the container renders
  `MermaidFallbackView(source:theme:)` instead of the WKWebView.
- The JS now posts a structured `{ ok, height | error }` message.
  The coordinator routes `ok: false` (and any malformed payload) to
  `failed = true`; success carries the measured height as before.
- `updateNSView` clears `failed` whenever it actually loads a new
  page, so editing the source after a failure re-attempts the render.
- `MermaidFallbackView` was `private` in MermaidRenderer.swift;
  promoted to internal so the web renderer can reach it.

Behaviour for valid diagrams is unchanged.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The previous build.sh fetched mermaid.min.js from jsDelivr on demand
and trusted whatever bytes came back. That left three open risks:

- a jsDelivr regression or compromise could swap in a different
  artifact at the same version tag;
- a stale local file (`mdv/mermaid.min.js` is gitignored) could
  silently diverge from CI for arbitrarily long, since the script
  skipped the download whenever the file already existed;
- nothing prevented a hand-edited copy from being bundled into the
  signed app and shipped.

The minimum useful guarantee is "the JS we ship is the exact bytes
we reviewed". Pin both `MERMAID_VERSION` and `MERMAID_SHA256` of
the upstream artifact and run the check unconditionally:

- Fresh download goes through a `.tmp` then `mv`, so a partial
  curl never lands as a valid-looking cached file.
- `verify_mermaid` runs whether the file was just downloaded or
  was already on disk — local drift now hard-fails the build with
  a message pointing at the next step (delete and re-download, or
  bump SHA along with VERSION).

Not vendoring the JS into git: the file is 2.5 MB and was
deliberately excluded in the original gantt PR. The SHA pin gives us
the integrity property without the repo-size cost.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
`isBeautifulMermaidSupported` peeked at the first physical line of
the source, lowercased it, and checked a handful of `hasPrefix`
candidates. That misses every diagram that opens with one of
Mermaid's three legal preamble forms:

- `---\ntitle: …\n---` frontmatter blocks,
- `%%{ init: { … } }%%` configuration directives, and
- `%% line comments`.

Each of those routed otherwise-native diagrams (a `flowchart LR` with
a title, a `sequenceDiagram` with an init block) into the WKWebView
fallback — losing zoom, the style picker, PNG export, and the cache
hit, all for the sake of preserving a comment.

Also fixes a narrower bug in the keyword test: `"graph "" with a
trailing space failed to match `graph\nLR` and `graph\tLR`,
and `hasPrefix("flowchart")` was happy to match `flowchart-elk`
even though ELK is a different layout backend BeautifulMermaid
doesn't speak.

Two new helpers do the work:

- `firstMermaidDirectiveLine(in:)` walks the source, consuming
  blank lines, comments, and the two block forms, and returns the
  first lowercased line that actually carries a diagram keyword.
- A local `matches(_:)` predicate inside the dispatcher matches a
  keyword only when it stands alone or is followed by whitespace,
  so `flowchart` and `flowchart LR` are accepted and
  `flowchart-elk` is not. `statediagram` / `xychart` keep an
  explicit `hasPrefix` so the `-v2` / `-beta` variants still
  reach BeautifulMermaid.

Behaviour for sources without a preamble is unchanged.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The five-option style menu (`Document`, `Light`, `Dark`,
`Tokyo Night`, `Catppuccin`) is wired straight into the
BeautifulMermaid `DiagramTheme` pipeline. The WKWebView fallback
for gantt/pie/timeline/etc. doesn't read it — it derives a binary
`default`/`dark` mermaid.js theme from the document theme's
`isDark` and that's it. The picker therefore appeared on every
diagram but did nothing on roughly half of them, which is the kind
of UI lie that wastes a user's afternoon.

Gate `styleMenu` (and the matching `Diagram Style` submenu in
the right-click menu) on a single `nativeRenderer` predicate that
asks `isBeautifulMermaidSupported(content)`. The floating toolbar
on a gantt diagram now collapses to just the source-toggle, export,
and copy buttons; on a flowchart everything is unchanged.

Export-PNG has the same problem — it routes through the native
pipeline and silently beeps for unsupported types — but is left for
a separate change.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
`MDVMermaidImage.exportPNG` re-enters the BeautifulMermaid
pipeline; for diagram types BeautifulMermaid can't parse it returns
`nil` and the export silently fails with an `NSSound.beep()`. A
visible toolbar button that exists only to beep at the user is the
worst possible UX — they assume something is broken and waste time
chasing it.

Wrap the floating-toolbar export icon and the matching context-menu
`Export Diagram as PNG` entry in the same `nativeRenderer` gate
introduced for the style picker. Native diagrams keep both
affordances; gantt/pie/etc. lose them entirely until the WKWebView
path can render its own PNG (probably via `takeSnapshot`, but
that's another change).

The diagram source is still reachable for those types via the
curly-braces toggle and ⌘C from the source view, so users aren't
stranded.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The WKWebView for the gantt/pie path was made transparent via
`webView.setValue(false, forKey: "drawsBackground")` so the
chrome's themed background showed through under the diagram. That
key is a private-API toggle on the underlying `WebView` machinery
— it works today, but Apple has changed unwritten WebKit behaviour
without notice plenty of times, and shipping a KVC poke at a
private property is the kind of thing that comes back to bite.

Replace it with the obvious portable approach: leave the WebView
opaque (the default) and paint the HTML body to the same color the
chrome paints behind it. `MermaidCodeBlockChrome` uses
`palette.background ?? theme.secondaryBackground`; the WebView
path doesn't see the palette so it just uses
`theme.secondaryBackground`, which is what every theme without a
custom code-block palette already resolves to anyway.

Mechanics:

- New `MermaidWebView.cssColor(_:)` helper converts a SwiftUI
  `Color` to a `rgb(r,g,b)` literal in sRGB; falls back to
  `transparent` if the color can't be resolved (uglier, never
  crashes).
- `buildHTML` now takes the full `MDVTheme` instead of a bare
  `isDark` and emits the body background. The `LoadKey` already
  includes `themeID`, so a theme switch still triggers a reload
  and the new background is picked up.
- Imported `AppKit` for `NSColor`.

Tested on the gantt test doc across light, dark, Tokyo Night, and
Catppuccin themes — the diagram region is now flush with the
surrounding chrome instead of bleeding the WebView's default
background through the rounded corners.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The original gantt PR shipped one test document — `gantt.md` —
even though the dispatch change in `isBeautifulMermaidSupported`
sends pie, timeline, mindmap, journey, quadrant chart, and
requirement diagram down the same WKWebView path. Their PR text
said "and whatever else mermaid.js knows about" but nobody had
actually opened mdv against any of them, so any one of them
quietly failing in the bundled mermaid.js version we pin would
have gone unnoticed until a user filed a bug.

Add `test-docs/mermaid-web-fallback.md` with one of each:

- pie, timeline, mindmap, journey, quadrantChart, requirementDiagram
  to exercise the web path's breadth;
- a flowchart preceded by a `%%{init}%%` directive and a `%%`
  line comment to lock in the preamble-stripping in
  `firstMermaidDirectiveLine` — if that regresses, the diagram
  will silently bounce out of BeautifulMermaid and end up rendered
  by the bigger, slower web pipeline.

Pair with `gantt.md` which still owns the depth-of-one-diagram
case (sections, milestones, critical bars, emoji in labels). Index
entry added to `test-docs/README.md` with a "if anything here
plates, the dispatcher is at fault" pointer so the next person
debugging this knows where to look.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The native path tags its rendered diagram with
`.accessibilityLabel("Mermaid diagram")` so VoiceOver announces
something useful instead of "image". The WKWebView path quietly
exposed whatever the embedded HTML/SVG happened to expose, which on
mermaid.js 11 is mostly opaque `<svg>` and `<g>` nodes — a
regression vs the native baseline for screen-reader users.

Wrap `MermaidWebViewContainer.body` in a `Group` so the new
modifiers can hang off a single subtree, and:

- collapse the AX subtree with `.accessibilityElement(children: .ignore)`
  so the WebView's noisy internal tree stops bleeding through;
- match the native label with `.accessibilityLabel("Mermaid diagram")`;
- add a hint pointing at the curly-braces toggle, since unlike the
  native renderer the WebView doesn't have a per-node accessibility
  story to fall back on.

The container falls back to `MermaidFallbackView` when the WebView
render fails; `Group` covers both branches with the same labels.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
The LoadKey guard added in "stop reloading the WKWebView on every
SwiftUI update" was the right fix for the reload thrash, but it
removed an accidental safety net: the old code reloaded the page
dozens of times per diagram, and at least one of those reloads
happened to measure the SVG after layout fully settled. With
exactly one load per (source, theme), `mermaid.run().then(...)`
sometimes resolved before the SVG's final dimensions were committed
— mermaid finishes injecting the SVG into the DOM, the promise
fires, but the browser hasn't run its first paint yet and
`getBoundingClientRect()` reports a stale ~60pt height. Three
diagram types in the test doc tripped this every time (journey,
quadrantChart, requirementDiagram) and rendered as clipped
60-point strips with internal scrollbars.

Two layered fixes inside the JS:

- Schedule the initial measurement under a double
  `requestAnimationFrame`. That's the standard browser idiom for
  "after the next paint": the first rAF runs before paint, the
  second runs after, so the rect we read is the post-layout one.
- Attach a `ResizeObserver` to the SVG element so diagrams that
  adjust their own dimensions after the first paint (mermaid does
  this for layouts with measured text) continue to update Swift's
  `height` binding as they settle.

No new feedback-loop risk: the LoadKey guard in `updateNSView`
short-circuits reloads, and the coordinator's existing 0.5pt epsilon
filter drops the tail of sub-pixel ResizeObserver callbacks before
they reach `@State`. The other six diagram types in
`mermaid-web-fallback.md` and the long gantt example are
unchanged.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
…aceholder

`MermaidWebViewContainer` opened with `@State height = 300` so
the document made room for a diagram before mermaid had said
anything about how tall it was actually going to be. When the JS
finally posted the measurement back, the frame snapped from 300pt
to whatever the diagram needed — the page jumped, and any scroll
position established during the window was off by tens to hundreds
of points.

Drop the placeholder to 60pt (the existing `max(_, 60)` floor on
the frame) and overlay a small `ProgressView` while we wait for
the first measurement. The document now reflows exactly once,
when we have a real number.

- New `@State measured: Bool` in the container; the overlay shows
  while `!measured` and disappears the instant the first ok
  message lands.
- `MermaidWebView` gets a matching `@Binding var measured: Bool`;
  the coordinator flips it inside the existing main-queue dispatch
  alongside the height write, so the two arrive together.
- `measured` is intentionally one-way: subsequent reloads (theme
  switch, source edit) don't flip it back to false, so the prior
  render stays on screen until the new one settles instead of
  flashing the spinner on every theme change.

The 0.5pt epsilon filter on the height write is unchanged, so the
ResizeObserver tail from the previous commit still can't churn
`@State`.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
Two small lifecycle / safety nits, both in the WKWebView fallback,
neither user-visible. Worth landing while we're already inside this
file.

- `WKUserContentController.add(_:name:)` retains the script-message
  handler. Without an explicit removal the coordinator (and its
  SwiftUI bindings) would stay alive until the WebView itself is
  collected. Add `static func dismantleNSView(_:coordinator:)` and
  call `removeScriptMessageHandler(forName: "mermaidHeight")`,
  which is the documented teardown for this exact pattern.
- `mermaid.initialize` previously omitted `securityLevel`. The
  default in mermaid.js v11 is `'strict'`, which is what we want
  for user-supplied diagram source, but relying on "the default
  happens to do the right thing" leaves us exposed if a future
  bundled JS bump changes that default. Pin it explicitly.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2d86-7146-73b8-8eb6-c24e338cbdfb
@hazadus hazadus marked this pull request as ready for review May 16, 2026 16:49
@marinus

marinus commented Jun 9, 2026

Copy link
Copy Markdown

@tqbf we need mermaid :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants