@plannotator/ui 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HANDOFF.md CHANGED
@@ -717,14 +717,15 @@ Behavior is pinned by `../core/html-anchor.test.ts` (the wire fingerprint of a c
717
717
 
718
718
  ## Mermaid 12 (0.40.0)
719
719
 
720
- `@plannotator/ui` 0.40.0 moves the diagram runtime from `mermaid` `^11.17.2` to an exact `mermaid` `12.0.0` and, in the same release, stops registering it eagerly in Plannotator's own plan editor. The theme mapping from 0.39.x (see "Theme-aware Mermaid diagrams") is unchanged and was re-swept on 12: all 52 palettes in `packages/ui/themes` in both modes (104 combinations, 15 diagrams each), 17,160 text-on-fill pairs and 9,880 line-on-canvas pairs measured from the rendered SVG in Chromium, **0 failures** against the 4.5:1 / 3:1 rule and 0 render errors. This section is the contract a host needs to adopt 0.40.0; the publish order is at the end.
720
+ `@plannotator/ui` 0.40.0 moves the diagram runtime from `mermaid` `^11.17.2` to an exact `mermaid` `12.0.0` and, in the same release, stops registering it eagerly in Plannotator's own plan editor. The theme mapping from 0.39.x (see "Theme-aware Mermaid diagrams") is unchanged and was re-swept on 12: all 52 palettes in `packages/ui/themes` in every mode they define (78 combinations — 26 palette halves do not exist, 22 palettes being dark-only and 4 light-only — 15 diagrams each), 12,870 text-on-fill pairs and 7,410 line-on-canvas pairs measured from the rendered SVG in Chromium, **0 failures** against the 4.5:1 / 3:1 rule and 0 render errors. This section is the contract a host needs to adopt 0.40.0; the publish order is at the end.
721
721
 
722
722
  **What Mermaid 12 changes, and what we took.** We take 12's defaults rather than pinning the 11 ones (owner ruling). Concretely:
723
723
 
724
- - **ELK is the default layout** for flowchart (`graph`, `flowchart`), state, class, ER and requirement diagrams: orthogonal right-angle edge routing, tighter node boxes with more label wrapping, different subgraph packing, and a different rendered `viewBox` for the same source. ELK is now part of `mermaid` itself (`elkjs` is a dependency; the separate `@mermaid-js/layout-elk` package is gone) and is loaded by Mermaid's own internal `import()` on the first ELK layout, so in a chunked host build it is a separate `elk-*.js` chunk (1,435 KB, 438 KB gzip) whether or not you import `mermaid-eager`. `flowchart-elk` as a diagram id still parses and renders (`aria-roledescription="flowchart-elk"`). The `defaultRenderer` option under `flowchart` / `class` / `state` config is removed upstream; use the top-level `layout` option if you need dagre back (`mermaid.initialize({ ..., layout: 'dagre' })` on the runtime after ours; `MERMAID_CONFIG` does not set `layout`).
724
+ - **ELK is the effective default layout** for flowchart (`graph`, `flowchart`), state, class, ER and requirement diagrams — through Mermaid 12's per-diagram defaults, while the top-level `config.layout` still reads `dagre` (so do not infer the renderer from that key): orthogonal right-angle edge routing, tighter node boxes with more label wrapping, different subgraph packing, and a different rendered `viewBox` for the same source. ELK is now part of `mermaid` itself (`elkjs` is a dependency; the separate `@mermaid-js/layout-elk` package is gone) and is loaded by Mermaid's own internal `import()` on the first ELK layout, so in a chunked host build it is a separate `elk-*.js` chunk (1,435 KB, 438 KB gzip) whether or not you import `mermaid-eager`. `flowchart-elk` as a diagram id still parses and renders (`aria-roledescription="flowchart-elk"`). The `defaultRenderer` option under `flowchart` / `class` / `state` config is removed upstream; use the top-level `layout` option if you need dagre back (`mermaid.initialize({ ..., layout: 'dagre' })` on the runtime after ours; `MERMAID_CONFIG` does not set `layout`).
725
725
  - **Per-diagram default theme/look** (`redux-color` theme and the `neo` look for flowchart, class, state, ER, requirement, sequence, use case, swimlane, Venn and agentflow) does not reach a Plannotator-themed document: `applyMermaidTheme` passes an explicit base theme (`dark` / `default`) and a complete `themeVariables` set, and `MERMAID_CONFIG` passes `theme: 'dark'`, so the rendered look is the classic one in the 0.39 sweep and in the 0.40 sweep alike. A host that renders with no theme tokens keeps `MERMAID_CONFIG` (still `theme: 'dark'`, still `securityLevel: 'strict'`, pinned by `components/MermaidBlock.test.ts`).
726
726
  - **Legacy diagram ids** `flowchart`, `class`, `state` are gone from `detectType`; `flowchart-v2`, `classDiagram`, `stateDiagram` are what 11 already returned for the same sources, and every `aria-roledescription` in the sweep is identical 11 → 12.
727
727
  - **Removed public exports** (`clearLayoutRenderState`, `createCommonLayoutRenderer`, `defaultMeasureLayout`, `paintLayoutData`, the `CommonLayout*` types) are internal layout helpers that nothing in `@plannotator/ui` used or re-exported.
728
+ - **`lodash-es` override does not travel.** This repo pins `lodash-es` to `4.18.1` through a root `overrides` entry (the Mermaid 12 dependency tree pulls an older range); npm does not apply a dependency's `overrides`, so a consumer adds its own root `"overrides": { "lodash-es": "4.18.1" }` (pnpm: `pnpm.overrides`) to get the same tree.
728
729
  - **Browser floor: Safari 17.4+ and ES2024.** Mermaid 12 is built to that target ("Mermaid is now built to target Safari 17.4+ and ES2024"; Node 22.12+ for anything that imports it server-side, e.g. a test harness). A host that must render diagrams on an older Safari stays on ui 0.39.x. Nothing else in `@plannotator/ui` moved its floor.
729
730
 
730
731
  **SVG ids are byte-identical 11 → 12** (measured from the real rendered SVG in the plan editor across 15 diagrams in 7 families, all 12 ids per diagram matched). `MermaidBlock` renders with `mermaid.render("mermaid-" + block.id, source)`, so every id below is prefixed by that render id (`{renderId}`), and a host that anchors on these keeps working:
@@ -736,7 +737,7 @@ Behavior is pinned by `../core/html-anchor.test.ts` (the wire fingerprint of a c
736
737
  | | subgraph | `<g id="{renderId}-{subgraphId}" class="cluster">` under `g.clusters` |
737
738
  | | markers | `<marker id="{renderId}_flowchart-v2-{pointEnd\|pointStart\|circleEnd\|circleStart\|crossEnd\|crossStart}[-margin]">` |
738
739
  | state (`stateDiagram-v2`) | state | `{renderId}-state-{stateId}-{n}` (`.node.default.statediagram-state`); pseudo-states `{renderId}-state-{scope}_start-{n}` / `_end-{n}`; composite cluster `{renderId}-state-{stateId}-{n}` (`.statediagram-cluster`) |
739
- | | transition | `<path id="{renderId}-edge{n}" class="transition">` (note `edge{n}`, not `L_a_b_0`) |
740
+ | | transition | `<path id="{renderId}-edge{n}" class="transition">` (note `edge{n}`, not `L_a_b_0`); markers `{renderId}_stateDiagram-barbEnd` and, new under 12, `{renderId}_stateDiagram-barbEnd-margin` |
740
741
  | class | class box | `{renderId}-classId-{ClassName}-{n}` |
741
742
  | | relation | `<path id="{renderId}-id_{From}_{To}_{n}" class="relation">`; markers `{renderId}_classDiagram-{kind}[-margin]` |
742
743
  | ER | entity | `{renderId}-entity-{ENTITY}-{n}` |
@@ -758,15 +759,55 @@ Three class-level deltas, none of which breaks an id- or class-based selector: `
758
759
  - `applyMermaidTheme(mermaid, key)` is keyed on the **runtime object** as well as the `(palette, mode)` key, so a lazily loaded runtime is themed on its first render exactly like an eagerly registered one, and a host that swaps runtimes gets a fresh `initialize` (pinned by `components/MermaidBlock.theme.test.tsx`).
759
760
  - Single-file builds gain nothing from the lazy import: `inlineDynamicImports` inlines the `import('mermaid')` target (and ELK) into the one HTML file, and the import resolves from the bundle. Plannotator's own hook bundle is 21.74 MB → 23.50 MB (+1.76 MB, +8.1%; gzip 6.66 MB → 7.19 MB); that delta is Mermaid 12's own size, not the loading strategy, and the review bundle is unchanged (17.43 MB, byte-identical to main) because it never carried Mermaid. On the chunked share portal the entry chunk goes 4,688 KB → 4,044 KB (-645 KB, -13.7%) with `mermaid.core` moving to its own chunk.
760
761
 
761
- **Theming contract, restated for 12.** `MermaidBlock` calls `applyMermaidTheme(runtime, mermaidThemeKey(colorTheme, mode))` before every render. `readThemeTokens()` reads `--background`, `--foreground`, `--card`, `--card-foreground`, `--popover`, `--border`, `--muted`, `--muted-foreground`, `--primary`, `--primary-foreground`, `--secondary`, `--accent`, `--destructive`, `--success`, `--warning` and `--font-sans` off the document element (resolving `color-mix()` / `var()` chains through a probe element); `buildMermaidThemeVariables(tokens, mode)` turns them into the base theme (`dark` / `default`) plus a complete `themeVariables` set for every family (node fills ← `card`, text ← `foreground` / `card-foreground`, borders ← `border`, edges and arrowheads ← `muted-foreground`, clusters ← `muted`, twelve categorical fills seeded from `primary`, `accent`, `success`, `warning`, `destructive`, `secondary`; every colour opaque hex, every text-on-fill pair guarded to 4.5:1 and every line 3:1 against every surface it can cross), and `mermaid.initialize(buildMermaidConfig(spec))` runs once per key change. **Without tokens** (no `ThemeProvider`, no `theme.css`, or the tokens kept off the document element) `readThemeTokens` returns `undefined`, `buildMermaidThemeVariables` returns `null`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with (12's own `dark` base theme with the slate `themeVariables`, ELK layout). None of the variable names changed between 11 and 12; the 0.40 sweep (104 combinations, 17,160 text pairs, 9,880 line pairs, 0 failures) is the proof that the mapping holds under ELK's re-laid-out geometry.
762
+ **Theming contract, restated for 12.** `MermaidBlock` calls `applyMermaidTheme(runtime, mermaidThemeKey(colorTheme, mode))` before every render. `readThemeTokens()` reads `--background`, `--foreground`, `--card`, `--card-foreground`, `--popover`, `--border`, `--muted`, `--muted-foreground`, `--primary`, `--primary-foreground`, `--secondary`, `--accent`, `--destructive`, `--success`, `--warning` and `--font-sans` off the document element (resolving `color-mix()` / `var()` chains through a probe element); `buildMermaidThemeVariables(tokens, mode)` turns them into the base theme (`dark` / `default`) plus a complete `themeVariables` set for every family (node fills ← `card`, text ← `foreground` / `card-foreground`, borders ← `border`, edges and arrowheads ← `muted-foreground`, clusters ← `muted`, twelve categorical fills seeded from `primary`, `accent`, `success`, `warning`, `destructive`, `secondary`; every colour opaque hex, every text-on-fill pair guarded to 4.5:1 and every line 3:1 against every surface it can cross), and `mermaid.initialize(buildMermaidConfig(spec))` runs once per key change. **Without tokens** (no `ThemeProvider`, no `theme.css`, or the tokens kept off the document element) `readThemeTokens` returns `undefined`, `buildMermaidThemeVariables` returns `null`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with (12's own `dark` base theme with the slate `themeVariables`, ELK layout). None of the variable names changed between 11 and 12; the 0.40 sweep (78 combinations, 12,870 text pairs, 7,410 line pairs, 0 failures) is the proof that the mapping holds under ELK's re-laid-out geometry.
762
763
 
763
764
  **Publish order.** `@plannotator/ui` 0.40.0 pins `@plannotator/core` `0.25.3` exactly, so **publish `core` 0.25.3 first, then `ui` 0.40.0** (`npm publish` in `packages/core`, then in `packages/ui`; both by hand from `main` after merge — CI never publishes these packages). Nothing under `packages/core` changed for Mermaid 12 itself. Core 0.25.3 also carries #1549 (`parseHtmlElementContext`, `MAX_ELEMENT_CONTEXT_BYTES`, `MAX_PAGE_URL_LENGTH` on `@plannotator/core/html-anchor`, and the element-context round trip; see "Element context through the host seam"), which is why the ui pin moves: a ui 0.40.0 on a published core 0.25.2 would fail to compile in a consumer.
764
765
 
765
766
  ---
766
767
 
768
+ ## Diagram engine (0.41.0)
769
+
770
+ `@plannotator/ui` 0.41.0 makes the Workspaces diagram viewer THE diagram engine of the package (owner ruling: "the new engine, not an option"). `MermaidBlock` and `GraphvizBlock` keep fence parsing, `diagramLanguages.ts` and the lazy-retry contract, and render through ONE renderer slot and ONE canvas: their own viewBox math, `applyView` and per-block zoom controls are gone, and the popout is the same `DiagramViewer` at full size over the document (the `TablePopout` chrome). A host that installs 0.41.0 gets the viewer, its overlay and Source pane, the anchor codecs and the runtime slots as supported surface; a host that already renders the copies Workspaces carried re-pins onto these exports and deletes the copies. Requires `@plannotator/core` 0.25.4 (the `diagram-anchor` subpath), so **publish core 0.25.4 first, then ui 0.41.0**.
771
+
772
+ **New exports.**
773
+
774
+ - `@plannotator/ui/components/diagram` (barrel; the components also resolve as `components/diagram/<Name>`): `DiagramViewer` (+ `DiagramViewerProps`), `DiagramPopout`, `DiagramCanvas` (+ `DiagramCanvasHandle`, `DiagramEscapeOutcome`, `svgContentSize`, `KEY_PAN_PX`; props `pickTarget`, `className`), `DiagramOverlay`, `DiagramComposer`, `DiagramSourcePane`, `useDiagramComments` (+ `DiagramComment`, `DiagramCreateComment`, `DiagramComposerDraft`, `DiagramHover`, `ResolvedDiagramComment`), `useDiagramRender` (+ `DiagramRenderState`), `useDiagramSourceDraft` (+ `DiagramSourceDraft`, `SaveResult`, `PREVIEW_DEBOUNCE_MS`), `useDiagramViewport` (+ `Viewport`, `ContentSize`, `ZOOM_MIN`, `ZOOM_MAX`, `ZOOM_STEP`, `WHEEL_ZOOM_SENSITIVITY`, `DRAG_THRESHOLD_PX`, `TOUCH_DRAG_THRESHOLD_PX`, `FIT_PADDING_PX`). Also `components/diagram/anchorClaims`: `DiagramAnchorClaims`, `DiagramAnchorClaimsContext`.
775
+ - `@plannotator/ui/utils/diagram-render`: `renderDiagram(kind, renderId, source, theme)`, `diagramFinder(kind)`, `sanitizeDiagramSvg`, `parseDiagramSvg`, `scrubDiagramSvg`, `scopeDiagramCss`, `widenEdgeHitAreas`, `diagramHitSource`, `DIAGRAM_HIT_ATTR`, `DIAGRAM_HIT_LAYER_ATTR`, `EDGE_HIT_STROKE_WIDTH`, `themeGraphvizSvg`, types `DiagramTheme`, `DiagramRenderResult`, `DiagramRenderError`, `DiagramKind`; test hook `__setDiagramSvgParserForTests`.
776
+ - `@plannotator/ui/utils/graphviz`: the Graphviz runtime slot, the same shape as `utils/mermaid` — `loadGraphvizRuntime`, `setGraphvizRuntime`, `getGraphvizRuntime`, `getGraphvizRuntimeSource`, `getGraphvizRetryDelayMs`, `__setGraphvizRuntimeLoaderForTests`, types `GraphvizRuntime`, `GraphvizRuntimeSource`, `GraphvizRuntimeLoader`. `@viz-js/viz` is pinned exactly at `3.30.0`; the only runtime spelling of the package is the slot's `import('@viz-js/viz')`.
777
+ - `@plannotator/ui/utils/diagram-anchor`: the Mermaid finder over a rendered svg — `DiagramFinder` (the seam: `targetSelector`, `targetFromElement`, `findTarget`, `sourceLine`), `MERMAID_FINDER`, `DIAGRAM_TARGET_SELECTOR`, `diagramFamilyOf`, `nodeIdsOf`, `splitEdgeStem`, `targetFromElement`, `findDiagramTarget`; it re-exports everything from `@plannotator/core/diagram-anchor` so a host imports one module.
778
+ - `@plannotator/ui/utils/diagram-anchor-graphviz`: `GRAPHVIZ_FINDER`, `GRAPHVIZ_TARGET_SELECTOR`, `graphvizTargetFromElement`, `graphvizFindTarget`, `graphvizSourceLine`.
779
+ - `@plannotator/ui/utils/diagram-projection`: `projectElement(el, hostRect)` (+ `ScreenRect`).
780
+ - `@plannotator/core/diagram-anchor` (core 0.25.4): types `DiagramFamily`, `DiagramTargetKind`, `DiagramTarget`, `DiagramAnchor`, `DiagramKind`; `parseDiagramAnchor`, `parseDiagramTarget`, `parseDiagramAdditionalTargets`, `buildDiagramAnchorValue`, `buildDiagramAnchor` (the opaque-blob shape a host that stores every anchor kind in one JSON column writes), `sameTarget`, `diagramTargetText`, `diagramTargetName`, `diagramAnchorLocationLine`, `lineMentions`, `diagramSourceLine`, `diagramWholeSourceLines`, `diagramFirstSourceLine`, `DIAGRAM_ANCHOR_KEY`, `DIAGRAM_ADDITIONAL_TARGETS_KEY`, `MAX_DIAGRAM_ANCHOR_STRING_LENGTH`.
781
+ - `Annotation.diagramAnchor?: DiagramAnchor` on `@plannotator/ui/types` (also re-exported there as a type), and additive `annotations`, `selectedAnnotationId`, `onSelectAnnotation`, `onAddAnnotation`, `readOnly`, `onRestoreReport` props on `MermaidBlock` / `GraphvizBlock` (`DiagramBlockProps`; `Viewer` passes its own).
782
+
783
+ **Removed.** `components/mermaidSvg` (`normalizeMermaidSvgMarkup`) is gone: it never sanitized anything (it baked `max-width: none`, `preserveAspectRatio` and `height="100%"` into the root tag for the old innerHTML mount), and the canvas now sizes the mounted node itself. A host that imported it drops the import. `GraphvizBlock` no longer exports `__setVizLoaderForTests` as its own function; the name is kept as an alias of `__setGraphvizRuntimeLoaderForTests`.
784
+
785
+ **The adapter (`DiagramViewer` props).** `kind: 'mermaid' | 'graphviz'`, `source: string`, `theme: { colorTheme, mode: 'dark' | 'light' }` (the pair `useTheme()` resolves; a host without `ThemeProvider` passes any palette id with the mode it renders in — with no tokens on the document the Mermaid entry keeps the static `MERMAID_CONFIG`), `comments: DiagramComment[]` (`{ id, anchor: DiagramAnchor, additionalTargets?, text, author?, resolved? }`; array order is the badge numbering), `onCreateComment?(anchor, text, additionalTargets)` (absent: clicks open nothing; `anchor.sourceLine` already carries `sourceLineOffset`), `onSave?(source) => Promise<SaveResult>` with `SaveResult = { status: 'ok' } | { status: 'stale', currentSource }` (absent: no Source pane; a thrown error is the save error shown in the pane; `stale` shows the Reload strip, holds Save and keeps the draft, and Reload adopts `currentSource` as the baseline), `readOnlySource?` (pane shown, no Save), `sourceOpen?` (the host owns the toggle; `DiagramPopout` renders one), `selectedCommentId?` / `onSelectComment?`, `onUnanchoredChange?(ids)` (once per membership change after every render, the `HtmlViewer` contract), `onResolutionChange?(map)` (every comment's verdict, whenever one changes or the list does), `canvasClassName?` (the canvas is `touch-action: pan-y` so a finger can scroll the page past an inline diagram; a viewer that owns the screen passes `touch-none`, as `DiagramPopout` does), `onDismiss?` (Escape with nothing left to close), `renderId?` (two viewers over one document need two), `sourceLineOffset?` (0 when the document IS the diagram; a fence's opening line for a fence, so `sourceLine` names document lines), `maxAdditionalTargets?` (default 0: a comment covers one part; pass 16 to keep shift-click multi-select — Workspaces' `MAX_PERSISTED_ADDITIONAL_TARGETS`), `commentingDisabledReason?` (the composer shows the reason and a Close instead of a textarea — what `CommentingPolicy.reason` was), `retryToken?`, `onRenderState?`, `renderFallback?` (what to show while there is no svg yet), `autoFocus?`, `className?`. Nothing else reaches the viewer: identity, storage, the comments rail and the save transport are the host's.
786
+
787
+ **The anchor shape** (`DiagramAnchor`; Workspaces' `diagram` value plus two additive members within `v: 1`, the `sequence` family and the `diagram` kind): `{ v: 1, family: 'flowchart' | 'state' | 'class' | 'er' | 'requirement' | 'sequence' | 'other' | 'graphviz', kind: 'node' | 'edge' | 'cluster' | 'diagram', id?, from?, to?, label, sourceLine: [first, last] | null }` — the diagram's own id for the part (never the rendered element id with its counter, never geometry), the label at write time, and 1-based DOCUMENT lines. Restore order: id, label, source line (the pane's gutter mark), unanchored but listed. `parseDiagramAnchor` is the one fail-closed validator (strings capped at 400, a bad `sourceLine` drops to null while the target survives); the external-annotations POST (both runtimes), the feedback archive and the export all run it. Share links drop the anchor exactly like `htmlAnchor` (pinned by `sharing.multiTarget.test.ts`).
788
+
789
+ **Codec families.** Flowchart, state, class, ER and requirement parts are addressed by Mermaid's own ids (table in "Mermaid 12"). **Sequence** diagrams carry classes, not ids, so the ids are the codec's: an actor is a `node` whose id is the actor's `name` attribute (`rect.actor`, `text.actor`, `line.actor-line`; the top and bottom boxes of one actor are the same target, and the element is the top box); a message is an `edge` `msg-<n>` by document order (`.messageLine0/1`, and its `text.messageText` resolves to the same message; `from` / `to` come from the line's `data-from` / `data-to` when the renderer wrote them; the label is the message text); a note is a `node` `note-<n>` (`rect.note` + `text.noteText`); a loop / alt / opt frame is a `cluster` `frame-<n>` (the group that holds its `line.loopLine`s, label from `text.labelText` + `text.loopText`). `sourceLine` for an ordinal is the n-th statement of its kind in the text (`diagramSourceLine`). Because an ordinal moves when a statement is inserted above it, sequence restore checks the label too: the part at the ordinal when its label still matches, else the ONE part that carries the stored label, else the part at the ordinal (its text was edited in place). Everywhere, the label fallback (restore step 2) applies only while the label names exactly one part; with duplicates it is skipped and the comment falls through to the source line. gitGraph and pie carry neither ids nor stable classes: a comment there is a whole-diagram comment.
790
+
791
+ **Comments that name no diagram block.** A comment composed in a block carries that block's `blockId`. One posted through `POST /api/external-annotations` carries `blockId: "external"`, and one whose fence was deleted carries a dead id. `Viewer` provides `DiagramAnchorClaimsContext` (`components/diagram/anchorClaims`, a `DiagramAnchorClaims` over the document's diagram block ids in order); every diagram block tries such a comment against its own render (`onResolutionChange`, the per-comment verdict map the viewer reports whenever a verdict or the list changes) and reports to the coordinator; the FIRST block in document order whose finder resolves it shows it, and when every block has answered and none did, the first block reports it unanchored (a document with no diagram at all: `Viewer` reports it). A block rendered without the context coordinates with itself only. A Graphviz-family anchor is only ever tried against dot fences.
792
+
793
+ **The runtime slots.** Mermaid: `utils/mermaid` as before (`loadMermaidRuntime`, `setMermaidRuntime`; the eager entry unchanged), with `applyMermaidTheme(runtime, mermaidThemeKey(colorTheme, mode))` still called per (palette, mode) before every render — `MermaidBlock.theme.test.tsx` and the `securityLevel: 'strict'` pin are unchanged. Graphviz: `utils/graphviz`, the same shape, filled lazily on the first dot fence or by a host through `setGraphvizRuntime(await import('@viz-js/viz').then((m) => m.instance()), 'host')`. Both are loaded with ONE automatic re-attempt after the slot's retry delay; a load failure resolves the render to `{ ok: false, runtimeUnavailable: true }`, which is the one failure the block's Retry (a shared epoch per engine, so one Retry re-attempts every failed sibling) can change.
794
+
795
+ **The sanitizer, and the delta.** `sanitizeDiagramSvg` = `parseDiagramSvg` (DOMPurify under the svg, svg-filter and html profiles with `foreignobject` added back the way Mermaid's own pass adds it, `RETURN_DOM_FRAGMENT`, the root adopted into the page document) + `scrubDiagramSvg` (in place on the tree about to be mounted: `script`/`iframe`/`object`/`embed`/`link`/`meta` removed, every `<style>` reduced by `scopeDiagramCss(css, rootId)` to the rules scoped under the svg's own root id plus `@keyframes` — `@import` and every other statement at-rule dropped, any rule that fetches dropped (`url(` that is not a `#fragment`, `image-set(`, `image(`, `src(`, `cross-fade(`, `paint(`, `element(`; CSS escapes are decoded first, so `u\72l(` is caught), any rule with a selector NOT starting at `#<rootId>` dropped (it could restyle the page around the diagram), `@media` / `@supports` / `@container` / `@layer` filtered recursively, `@font-face` and the rest dropped; a style element left empty is removed. Mermaid scopes every rule it emits under the render id, so all of its rules survive: pinned over six captured families, and in Chromium the computed node, label and edge styles equal main's on three palettes with identical rule counts, every `<a>` loses `href`/`xlink:href` so a `click A "https://…"` binding cannot turn a pinpoint click into a navigation, every `on*` attribute removed, `href`/`xlink:href`/`src` kept only for `#fragment` or `http(s)` values). No markup string ever reaches the app DOM: the canvas mounts the node with `replaceChildren`. Delta against what Plannotator had: nothing to merge in the security direction (the old `normalizeMermaidSvgMarkup` and the old Graphviz regex rewrite did no sanitizing); the one case ours covered that the moved code did not — a Graphviz root whose size is only `width="206pt" height="188pt"` with no `viewBox` — is now handled in `svgContentSize`, which accepts `pt`/`px`-suffixed lengths as the fallback. Also consolidated: the old Graphviz stroke color `var(--muted-foreground)` becomes `var(--foreground)` (the moved `themeGraphvizSvg`), so dot fences and Workspaces' `.dot` documents draw the same line. **Test note:** happy-dom cannot host DOMPurify (its `DOMParser` lands the fragment in a foreign realm and mislabels svg namespaces; in-place mode reads `nodeName` through a cached `Node.prototype` getter happy-dom overrides on `Element`), so DOM tests parse through an inert XML/`<template>` parse via `__setDiagramSvgParserForTests` (`test-setup/diagramSvg.ts`) while `scrubDiagramSvg` still runs on every test render; the DOMPurify parse is proven in Chromium.
796
+
797
+ **Interaction (owner feedback, twice: watching the first runs, then testing the build by hand).** Click-to-select, drag-to-pan: a press that travels under the drag threshold is a click and opens the composer (so a slightly moving click still selects); one that travels further is a pan and never opens it. The threshold is pointer-type aware: `DRAG_THRESHOLD_PX` (4 px) for a mouse or pen, `TOUCH_DRAG_THRESHOLD_PX` (10 px) for a finger. **Hover targeting was removed at the owner's request** — it read as messy and fought the pan hand — so nothing highlights on a plain mouse-over; the one pre-click affordance left is the ring and label chip under the pointer while the platform modifier is held (Cmd on macOS, Ctrl elsewhere; `isModKeyHeld`), and it disarms on the modifier's release, on any other key while it is held, and on window blur, like the code review's token cards. Canvas keys (`+` `-` `0`, arrows) ignore events carrying Meta, Ctrl or Alt, so `Mod+0`, `Mod+-` and `Alt+Arrow` stay the browser's. The wheel ignores `|deltaY| < 0.1`.
798
+
799
+ **The hit layer and the priority rule.** Edges were nearly impossible to click. Measured cause (headless Chromium, `elementsFromPoint` at 20 points along every edge of a flowchart with nested subgraphs, a state, a class and an ER diagram): the ONLY thing painted over an edge is that edge's **own label** (`g.edgeLabel > foreignObject > … > p`), which covers 5–40% of every labelled edge, centred on its midpoint — exactly where a person clicks an edge; unlabelled edges were reachable at 20 of 20 points. Mermaid 12 + ELK emits ONE `g.root` even with nested subgraphs, and clusters are painted before the edge paths, so nested roots and cluster rects cover nothing. The fix is structural anyway, so it also holds for a renderer that does nest: `widenEdgeHitAreas(svg)` (exported from `utils/diagram-render`, with `DIAGRAM_HIT_ATTR`, `DIAGRAM_HIT_LAYER_ATTR`, `EDGE_HIT_STROKE_WIDTH`, `diagramHitSource`) collects one invisible hit path per edge into ONE `<g data-diagram-hit-layer>` appended LAST in the svg root. Each hit path is a bare element of the edge's tag carrying only its geometry (`d` / `x1 y1 x2 y2` / `points`), a `transform` that re-creates the ancestors' placement (every ancestor's `transform` attribute joined outermost first, plus the edge's own: an svg transform list composes left to right, so this needs no layout and runs on the detached node), `data-diagram-hit="<index>"`, `stroke: transparent`, `fill: none`, `stroke-width: 14`, `pointer-events: stroke`, the width, stroke, dash pattern and markers also set `!important` inline. It keeps NO `id`, `class`, marker, inline style or `data-*` of the edge, so a host's `[data-id="L_A_B_0"]` still matches exactly what it matched before. `diagramHitSource(hitEl)` (a WeakMap) maps one back to its visible edge. Covered: Mermaid `g.edgePaths > path`, `path.flowchart-link`, `path.transition`, `path.relation`, `path.relationshipLine`, the sequence `.messageLine0/1`; Graphviz `g.edge > path`. Because the layer sits over the nodes too, `DiagramCanvas` never trusts the topmost element: it takes `document.elementsFromPoint` at the pointer, maps hit paths to their edges, reduces each element to its addressable ancestor, and hands the candidates (topmost first) to `pickTarget`, which the viewer implements as **node, then edge, then cluster** (`useDiagramComments.pickTarget`). An edge LABEL is a target too and resolves to its edge (`g.edgeLabel` → the edge whose id its `data-id` names); the composer and the ring always land on the part's ONE element (`finder.findTarget` canonicalizes). Numbered badges and rings for existing comments are unchanged.
800
+
801
+ **A click never does nothing: the `diagram` kind.** A click that resolves no part opens the composer on the WHOLE diagram (with a draft already open it closes the draft instead): `{ kind: 'diagram', family, label: <the diagram's first source line>, sourceLine: <the source's full range, offset into the document> }`, no `id`. This covers gitGraph, pie, and any family the codec does not address. Its ring is the svg's content bounds and its badge sits top-left; it is never unanchored while the diagram renders (`findTarget` returns the svg root). The export line reads `Diagram (<family>), lines a–b`.
802
+
803
+ **Migration for a host that carried the copies.** `useDiagramRender(kind, documentId, source, theme, { retryToken })` now takes the `{ colorTheme, mode }` theme and reports `error.runtimeUnavailable`; `useDiagramAnnotations` becomes the host's projection of its rows onto `comments` plus its mutation behind `onCreateComment` (the viewer half is `useDiagramComments`); `useDiagramDraft`'s `preview`/`dirty`/`stale`/`reload` semantics live in `useDiagramSourceDraft` behind `onSave` (the PATCH, `If-Match`, the query cache and the fence slice stay host-side; answer `stale` on a 412); `DiagramComposer` takes `disabledReason`/`error` instead of a `CommentingPolicy`; the canvas's `onEscape` returns `'consumed' | 'pass'` so a popout can walk the Escape ladder; arrow keys pan (`KEY_PAN_PX`, Shift ×5) in addition to `+` `-` `0`. Icons come from `lucide-react` (already a dependency).
804
+
805
+ ---
806
+
767
807
  ## Publishing & versioning
768
808
 
769
- - **The current pair is `@plannotator/ui` `0.40.0` on `@plannotator/core` `0.25.3`. Publish `core` 0.25.3 first, then `ui` 0.40.0** (both by hand from `main` after merge; CI never publishes these packages). Three things ship in 0.40.0: (1) **Mermaid 12.0.0**, pinned exactly (was `^11.17.2`): ELK layout by default for flowchart/state/class/ER/requirement, Safari 17.4+ / ES2024 floor, SVG ids byte-identical to 11 but `g.edgePaths` children now in declaration order, and the plan editor no longer imports `utils/mermaid-eager` (the lazy path is the default for everyone; hosts that want startup registration import the eager entry themselves) — see "Mermaid 12 (0.40.0)"; (2) **theme-aware Mermaid diagrams**: New additive exports `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `isDarkBackground`, `MERMAID_THEME_TOKEN_NAMES`) and `utils/cssColor` (parser + OKLab/contrast toolkit). `MermaidBlock` now calls `useTheme()` and `applyMermaidTheme` before each render; `MERMAID_CONFIG`, `loadMermaidRuntime`, `mermaid-eager` and `securityLevel: 'strict'` are unchanged. A host whose document carries no theme tokens renders diagrams byte-identically to 0.39.0; a host that mounts `ThemeProvider` with `theme.css` gets diagrams in its palette and mode with no configuration. No new peer dependencies; core unchanged. See "Theme-aware Mermaid diagrams (0.40.0)".; (3) **element context through the host seam (#1521, #1549), which is what moves `core` to 0.25.3:** `@plannotator/core/html-anchor` gains `parseHtmlElementContext`, `MAX_ELEMENT_CONTEXT_BYTES` and `MAX_PAGE_URL_LENGTH`; `PersistedHtmlAnchor.elementContext?` and `HtmlAnnotationTarget.context?` now round-trip through `buildPersistedHtmlAnchor` and `projectHostThreads`. **Core changes here, so bump and publish `core` first** and update UI's exact core dependency before packing ui — a ui build that imports these from an older published core fails to compile in a consumer, the 0.38.0 failure mode. `@plannotator/ui/components/html-viewer` re-exports the validator, so 0.39.0's import site is unchanged, and rows without context stay byte-identical on the wire. `utils/parser` gains `includeOutline` on `elementContextExportBlock` / `exportAnnotationEntry`, and `exportAnnotationEntry`'s `includeRoute` now defaults to true per field. See "Element context through the host seam".
809
+ - **The current pair is `@plannotator/ui` `0.41.0` on `@plannotator/core` `0.25.4`. Publish `core` 0.25.4 first, then `ui` 0.41.0** (both by hand from `main` after merge; CI never publishes these packages). 0.41.0 is the diagram engine (see "Diagram engine (0.41.0)"): one renderer slot and one canvas behind `MermaidBlock` / `GraphvizBlock`, the `components/diagram` surface, the Graphviz runtime slot with `@viz-js/viz` pinned `3.30.0`, and `Annotation.diagramAnchor`; core 0.25.4 adds the `diagram-anchor` subpath ui imports, so a ui 0.41.0 on a published core 0.25.3 would fail to compile in a consumer.
810
+ - The previous pair was `@plannotator/ui` `0.40.0` on `@plannotator/core` `0.25.3` (publish order the same). Three things shipped in 0.40.0: (1) **Mermaid 12.0.0**, pinned exactly (was `^11.17.2`): ELK layout by default for flowchart/state/class/ER/requirement, Safari 17.4+ / ES2024 floor, SVG ids byte-identical to 11 but `g.edgePaths` children now in declaration order, and the plan editor no longer imports `utils/mermaid-eager` (the lazy path is the default for everyone; hosts that want startup registration import the eager entry themselves) — see "Mermaid 12 (0.40.0)"; (2) **theme-aware Mermaid diagrams**: New additive exports `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `isDarkBackground`, `MERMAID_THEME_TOKEN_NAMES`) and `utils/cssColor` (parser + OKLab/contrast toolkit). `MermaidBlock` now calls `useTheme()` and `applyMermaidTheme` before each render; `MERMAID_CONFIG`, `loadMermaidRuntime`, `mermaid-eager` and `securityLevel: 'strict'` are unchanged. A host whose document carries no theme tokens renders diagrams byte-identically to 0.39.0; a host that mounts `ThemeProvider` with `theme.css` gets diagrams in its palette and mode with no configuration. No new peer dependencies; core unchanged. See "Theme-aware Mermaid diagrams (0.40.0)".; (3) **element context through the host seam (#1521, #1549), which is what moves `core` to 0.25.3:** `@plannotator/core/html-anchor` gains `parseHtmlElementContext`, `MAX_ELEMENT_CONTEXT_BYTES` and `MAX_PAGE_URL_LENGTH`; `PersistedHtmlAnchor.elementContext?` and `HtmlAnnotationTarget.context?` now round-trip through `buildPersistedHtmlAnchor` and `projectHostThreads`. **Core changes here, so bump and publish `core` first** and update UI's exact core dependency before packing ui — a ui build that imports these from an older published core fails to compile in a consumer, the 0.38.0 failure mode. `@plannotator/ui/components/html-viewer` re-exports the validator, so 0.39.0's import site is unchanged, and rows without context stay byte-identical on the wire. `utils/parser` gains `includeOutline` on `elementContextExportBlock` / `exportAnnotationEntry`, and `exportAnnotationEntry`'s `includeRoute` now defaults to true per field. See "Element context through the host seam".
770
811
  - The previous pair was `@plannotator/ui` `0.39.0` on `@plannotator/core` `0.25.2` (core unchanged; nothing under `packages/core` moved). UI 0.39.0 adds **element context** to raw-HTML and live-app pinpoint annotations (#1517, #1520): a new optional `Annotation.elementContext` (`HtmlElementContext` in `@plannotator/ui/types`) and `HtmlAnnotationTarget.context`, captured by the bridge at click time (tag, id, author classes, ancestor `path`, `role`, accessible `name`, an allowlisted `attrs` set with href/src scrubbed of query and fragment, rendered `text`, an adaptive collapsed HTML `outline`, child count, viewport `rect`, nearest `landmark` and `heading`, a `component` hint, and in live-app sessions `page`), hard-capped at 2 KiB serialized per primary and 1 KiB per extra target, and re-validated at the parent trust boundary by the new `parseHtmlElementContext` export of `@plannotator/ui/components/html-viewer`. New helpers on `@plannotator/ui/utils/parser`: `elementContextExportBlock(ann, { includeRoute })` (the fenced skeleton plus selector/path/role/name/attrs/text/box/near lines the full export now prints under a context-bearing comment) and `exportAnnotationEntry(ann, { includeRoute })` (one annotation as a standalone feedback entry, a pure helper for hosts; `AnnotationPanel`'s card chrome is unchanged from 0.38.2). The field is purely descriptive: `HtmlElementAnchor` and restore are untouched, no `BRIDGE_PROTOCOL_VERSION` bump, share links drop it like anchors, annotations without it export byte-identically, and the repaint path posts only anchors to the bridge. **Host persistence gap in 0.39.0 itself, closed in the next publish (#1521, #1549)**: as shipped, 0.39.0's `@plannotator/core/html-anchor` (`buildPersistedHtmlAnchor`, `projectHostThreads`) does not carry `elementContext`, so a host pinned to 0.39.0 that persists through those helpers drops it on save and must persist and project the field itself. The next publish carries it end to end — see "Element context through the host seam". Peer ranges are unchanged from 0.38.2: `react` / `react-dom` `^19.2.3`, `tailwindcss` as before, and `@codemirror/state ^6.7.2` beside `@codemirror/view ^6.43.10`. Decision-control change in the same window (#1516): the header primary reads `Send Feedback` / `Post Comments` with no inline count (`DecisionPrimary.count` removed; internal, not host-supported surface).
771
812
  - Before that, `@plannotator/ui` `0.38.2` on `@plannotator/core` `0.25.2`. UI 0.38.2 keeps the type word in a titled alert's accessible name through a visually hidden `sr-only` span before the title instead of an `aria-label` on the title row (naming a generic `div` is prohibited by ARIA and WebKit drops it, so VoiceOver on Safari read only the bold title in 0.38.1), and loosens the React peer back to `^19.2.3` (0.38.1 declared `^19.2.8` only because the dependency batch moved it; nothing in the package needs a newer API). **Do not consume ui 0.38.0**: it imports `@plannotator/core/token-hover` (the hover-card trigger settings, #1462) but pins core 0.25.1, which never exported that subpath, so it fails to compile in any consumer; 0.38.1 is the same UI pinning core 0.25.2, which publishes `./token-hover`, the rotated `guide-viewer-manifest` pin, and the `config-types` hover fields (core 0.25.2 is the first core publish since 0.25.1 even though those changes landed over several releases; the package smoke now diffs the UI's core imports against the registry so an unpublished core subpath fails preflight instead of the consumer). UI 0.38.1 also aligns `@codemirror/state` to `^6.7.2` beside `@codemirror/view ^6.43.10`, so a consumer can no longer resolve two state copies. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `<!-- icon: name -->` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly.
772
813
  - Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2, and ui 0.40.0 on core 0.25.3 (lockstep, `html-anchor` element context). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export.
package/README.md CHANGED
@@ -53,7 +53,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
53
53
 
54
54
  ### Lazy renderers and the eager entries (`utils/math`, `utils/generateIdentity`, `utils/mermaid`; 0.32.0)
55
55
 
56
- The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
56
+ The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz has its own slot since 0.41.0 (`utils/graphviz`, the same shape as Mermaid's: lazy `import('@viz-js/viz')` on the first dot fence, or `setGraphvizRuntime` from the host) and the block shows the source fence under a status until the SVG lands. Mermaid, Graphviz, KaTeX and the dictionary sit behind synchronous slots:
57
57
 
58
58
  - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
59
59
  - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')` and the block shows the source fence under a "Rendering diagram" status until the SVG lands; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. **Since 0.40.0 (Mermaid 12.0.0, ELK layout by default, Safari 17.4+) this lazy path is Plannotator's own**: the plan editor no longer imports the eager entry, so a document with no diagram never downloads the runtime in a chunked build. `import "@plannotator/ui/utils/mermaid-eager";` in your entry registers and initializes the runtime at startup instead, if you would rather it never fail separately from the app. Mermaid 12 lays flowchart, state, class, ER and requirement diagrams out with ELK (orthogonal edges, different packing); every generated SVG `id` keeps its 11.x shape, but children of `g.edgePaths` are now in declaration order, so select edges by id (`{renderId}-L_{from}_{to}_{n}`, `{renderId}-edge{n}`), never by DOM position. Details in HANDOFF.md "Mermaid 12 (0.40.0)". **Diagrams follow the colour theme.** Before every render `MermaidBlock` calls `applyMermaidTheme` (`utils/mermaidTheme`), which reads the theme tokens off the document (`--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens and `--font-sans`; `readThemeTokens`), derives a complete `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; every text-on-fill pair guarded to WCAG 4.5:1 and every line 3:1, rule in the module doc) and runs the global `mermaid.initialize` once per `(palette, mode)` key, re-rendering mounted diagrams when the key changes. The key comes from `useTheme()`, so a host that mounts `ThemeProvider` and ships `theme.css` gets diagrams in its palette with nothing to configure. **Fallback contract:** with no tokens on the document (no `ThemeProvider`, no `theme.css`) `readThemeTokens` returns `undefined`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with, so such a host renders byte-identically to 0.39.0. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` pinned); the new exports are additive. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
@@ -216,6 +216,10 @@ exactly. The standalone `StickyHeaderLane` remains supported for Plannotator's
216
216
  hidden-at-rest ghost lane, but its always-visible mode is an overlay and is not
217
217
  the in-flow host integration.
218
218
 
219
+ ### Diagram engine (`components/diagram`; 0.41.0)
220
+
221
+ One renderer slot and one canvas render every Mermaid and Graphviz diagram — the fences in `Viewer` (`MermaidBlock`, `GraphvizBlock`), their popout, and whatever a host renders itself. `DiagramViewer` from `@plannotator/ui/components/diagram` takes `kind`, `source`, `theme` (`{ colorTheme, mode }`), `comments: DiagramComment[]` and `onCreateComment(anchor, text, additionalTargets)`; pass `onSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>` to get the Source pane (left of the canvas on desktop, under it on the phone; `readOnlySource` shows it without Save), and `sourceOpen` to toggle it. Zoom, pan and fit are the canvas's (wheel, drag, `+` `-` `0`, arrow keys); a click opens the composer at the part beneath while a drag pans (4 px threshold, 10 px for a finger), nothing highlights on a plain mouse-over (the ring under the pointer needs the platform modifier held), every edge carries an invisible 14 px hit path in one top layer and a click is resolved by priority over everything under the pointer (node, then edge, then cluster; an edge label is its edge), a click on no part comments on the whole diagram (kind `diagram`), sequence diagrams are addressable (actors, messages, notes, frames), a saved comment paints a ring and a numbered badge from the array order, and every comment re-resolves against each render through the engine's finder (id, then label, then unanchored — reported through `onUnanchoredChange`). The anchor is `DiagramAnchor` from `@plannotator/core/diagram-anchor` (`{ v: 1, family, kind, id | from + to, label, sourceLine }`, document lines), and Plannotator stores it as `Annotation.diagramAnchor`. Runtime slots: `utils/mermaid` (as before) and `utils/graphviz` (new, same shape, `@viz-js/viz` pinned `3.30.0`), each lazy on first use or filled by the host. `DiagramPopout` is the full-size viewer in the `PopoutDialog` chrome. See HANDOFF.md § "Diagram engine (0.41.0)" for every export, the adapter, the sanitizer and the migration notes.
222
+
219
223
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
220
224
 
221
225
  The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
@@ -251,7 +255,7 @@ npm install @plannotator/ui @plannotator/core
251
255
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
252
256
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
253
257
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
254
- - Currently `@plannotator/ui` 0.39.0 depends exactly on `@plannotator/core` 0.25.2. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
258
+ - Currently `@plannotator/ui` 0.41.0 depends exactly on `@plannotator/core` 0.25.4. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
255
259
 
256
260
  ## The one rule
257
261
 
@@ -0,0 +1,376 @@
1
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { diagramTargetText, type DiagramKind } from '@plannotator/core/diagram-anchor';
3
+ import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
4
+ import { AnnotationType, type Annotation, type Block } from '../types';
5
+ import type { DiagramTheme } from '../utils/diagram-render';
6
+ import { getIdentity } from '../utils/identity';
7
+ import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
8
+ import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
9
+ import { svgContentSize } from './diagram/DiagramCanvas';
10
+ import { DiagramPopout } from './diagram/DiagramPopout';
11
+ import { DiagramViewer } from './diagram/DiagramViewer';
12
+ import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
13
+ import type { DiagramRenderState } from './diagram/useDiagramRender';
14
+ import { useTheme } from './ThemeProvider';
15
+
16
+ /**
17
+ * A diagram fence in the document: the fence's language picks the engine
18
+ * (`MermaidBlock`, `GraphvizBlock`), and everything else is one code path
19
+ * through the renderer slot and `DiagramViewer` — the canvas with zoom, pan
20
+ * and fit, the comment overlay, and the same viewer at full size in the
21
+ * popout. What this block owns is the document side: the source fence under
22
+ * a status until the first render lands (never the error panel as a
23
+ * placeholder), the error panel with the source and a Retry for a failed
24
+ * engine load, the "Show source" toggle, and the bridge between a comment
25
+ * composed on a part and an `Annotation` on the document (`diagramAnchor`
26
+ * plus the fence's document lines), so it lists in the rail beside text
27
+ * comments, exports, drafts and restores after a reload.
28
+ */
29
+
30
+ /** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */
31
+ const RETRY_EPOCHS: Record<DiagramKind, ReturnType<typeof createRuntimeRetryEpoch>> = {
32
+ mermaid: createRuntimeRetryEpoch(),
33
+ graphviz: createRuntimeRetryEpoch(),
34
+ };
35
+
36
+ const LABELS: Record<DiagramKind, string> = { mermaid: 'Mermaid', graphviz: 'Graphviz' };
37
+
38
+ /** The inline box height from the diagram's aspect at a nominal width, so
39
+ * a wide flowchart is not letterboxed in a tall box and a tall state
40
+ * diagram is not squeezed into a short one; clamped so neither extreme
41
+ * takes the page. The canvas fits the diagram inside whatever it gets. */
42
+ const NOMINAL_WIDTH_PX = 800;
43
+ const MIN_HEIGHT_PX = 16 * 16;
44
+ const MAX_HEIGHT_PX = 36 * 16;
45
+
46
+ function inlineHeight(state: DiagramRenderState | null): string {
47
+ const size = state?.svgNode ? svgContentSize(state.svgNode) : null;
48
+ if (size === null) return 'min(65vh, 24rem)';
49
+ const px = Math.round(size.height * (NOMINAL_WIDTH_PX / size.width)) + 48;
50
+ return `min(65vh, ${Math.min(MAX_HEIGHT_PX, Math.max(MIN_HEIGHT_PX, px))}px)`;
51
+ }
52
+
53
+ function newAnnotationId(): string {
54
+ const c = globalThis.crypto;
55
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
56
+ return `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
57
+ }
58
+
59
+ export interface DiagramBlockProps {
60
+ block: Block;
61
+ /** The document's annotations; the block keeps the ones anchored on this
62
+ * fence (`diagramAnchor` + its `blockId`). */
63
+ annotations?: readonly Annotation[];
64
+ selectedAnnotationId?: string | null;
65
+ onSelectAnnotation?: (id: string | null) => void;
66
+ /** A comment composed on a part becomes an annotation on the document. */
67
+ onAddAnnotation?: (annotation: Annotation) => void;
68
+ readOnly?: boolean;
69
+ /** The block's restore verdict after every render: its own comments as
70
+ * `attempted`, the ones whose part is gone as `unanchored`, so the host's
71
+ * panel shows the same "Unanchored" chip a text comment gets. */
72
+ onRestoreReport?: (report: AnnotationRestoreReport) => void;
73
+ }
74
+
75
+ const NO_ANNOTATIONS: readonly Annotation[] = [];
76
+
77
+ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> = ({
78
+ kind,
79
+ block,
80
+ annotations = NO_ANNOTATIONS,
81
+ selectedAnnotationId = null,
82
+ onSelectAnnotation,
83
+ onAddAnnotation,
84
+ readOnly = false,
85
+ onRestoreReport,
86
+ }) => {
87
+ const label = LABELS[kind];
88
+ const rootRef = useRef<HTMLDivElement | null>(null);
89
+ const [showSource, setShowSource] = useState(false);
90
+ const [isExpanded, setIsExpanded] = useState(false);
91
+ const [retryToken, setRetryToken] = useState(0);
92
+ const [renderState, setRenderState] = useState<DiagramRenderState | null>(null);
93
+
94
+ // The (palette, mode) the diagram must follow: the same resolution the
95
+ // code fences use (see useFenceTheme). Outside a ThemeProvider the default
96
+ // context yields the Plannotator dark pair, and with no theme tokens on the
97
+ // document the renderer keeps the static config, so a host without the
98
+ // provider renders exactly as before. A key change re-runs the render,
99
+ // which is what re-themes an already rendered diagram.
100
+ const { colorTheme, resolvedMode } = useTheme();
101
+ const theme = useMemo<DiagramTheme>(
102
+ () => ({ colorTheme, mode: resolvedMode === 'light' ? 'light' : 'dark' }),
103
+ [colorTheme, resolvedMode],
104
+ );
105
+
106
+ // A sibling's Retry re-attempts this block too, but only while its own
107
+ // failure was the shared engine import; a healthy block or a diagram
108
+ // syntax error is left alone.
109
+ const runtimeUnavailableRef = useRef(false);
110
+ runtimeUnavailableRef.current = renderState?.error?.runtimeUnavailable ?? false;
111
+ useEffect(
112
+ () =>
113
+ RETRY_EPOCHS[kind].subscribe(() => {
114
+ if (!runtimeUnavailableRef.current) return;
115
+ setRetryToken((token) => token + 1);
116
+ }),
117
+ [kind],
118
+ );
119
+
120
+ useEffect(() => {
121
+ setIsExpanded(false);
122
+ }, [block.content]);
123
+
124
+ // Comments that name no diagram block of the document (an external POST
125
+ // carries `blockId: "external"`; a deleted fence leaves a dead id) belong
126
+ // to whichever diagram resolves their anchor first: see anchorClaims.
127
+ const sharedClaims = useContext(DiagramAnchorClaimsContext);
128
+ const ownClaims = useMemo(() => new DiagramAnchorClaims([block.id]), [block.id]);
129
+ const claims = sharedClaims ?? ownClaims;
130
+ const claimsVersion = useSyncExternalStore(claims.subscribe, claims.getVersion, claims.getVersion);
131
+
132
+ const ownAnnotations = useMemo(
133
+ () => annotations.filter((ann) => ann.diagramAnchor !== undefined && ann.blockId === block.id),
134
+ [annotations, block.id],
135
+ );
136
+ const unownedAnnotations = useMemo(
137
+ () =>
138
+ annotations.filter(
139
+ (ann) =>
140
+ ann.diagramAnchor !== undefined &&
141
+ ann.blockId !== block.id &&
142
+ !claims.blockIds.includes(ann.blockId) &&
143
+ // A Graphviz anchor names a DOT part; it is never a Mermaid one.
144
+ (ann.diagramAnchor.family === 'graphviz') === (kind === 'graphviz'),
145
+ ),
146
+ [annotations, block.id, claims, kind],
147
+ );
148
+
149
+ // The comments on this fence, in document order (the badge numbers): its
150
+ // own, then the unowned ones it shows or is still trying.
151
+ const comments = useMemo<readonly DiagramComment[]>(() => {
152
+ void claimsVersion;
153
+ const tried = unownedAnnotations.filter((ann) => {
154
+ const owner = claims.owner(ann.id);
155
+ return owner === undefined || owner === block.id;
156
+ });
157
+ return [...ownAnnotations, ...tried].map((ann) => ({
158
+ id: ann.id,
159
+ anchor: ann.diagramAnchor!,
160
+ text: ann.text ?? '',
161
+ author: ann.author,
162
+ }));
163
+ }, [block.id, claims, claimsVersion, ownAnnotations, unownedAnnotations]);
164
+
165
+ const selectedCommentId = useMemo(
166
+ () => (selectedAnnotationId !== null && comments.some((c) => c.id === selectedAnnotationId) ? selectedAnnotationId : null),
167
+ [comments, selectedAnnotationId],
168
+ );
169
+ useEffect(() => {
170
+ if (selectedCommentId === null) return;
171
+ rootRef.current?.scrollIntoView?.({ block: 'nearest' });
172
+ }, [selectedCommentId]);
173
+
174
+ const handleCreate = useMemo<DiagramCreateComment | undefined>(() => {
175
+ if (readOnly || onAddAnnotation === undefined) return undefined;
176
+ return (anchor, text) => {
177
+ onAddAnnotation({
178
+ id: newAnnotationId(),
179
+ blockId: block.id,
180
+ startOffset: 0,
181
+ endOffset: 0,
182
+ type: AnnotationType.COMMENT,
183
+ text,
184
+ originalText: diagramTargetText(anchor),
185
+ createdA: Date.now(),
186
+ author: getIdentity(),
187
+ diagramAnchor: anchor,
188
+ });
189
+ };
190
+ }, [block.id, onAddAnnotation, readOnly]);
191
+
192
+ const [resolution, setResolution] = useState<ReadonlyMap<string, boolean> | null>(null);
193
+ const svgReady = renderState?.svgNode != null;
194
+ const renderFailed = renderState !== null && renderState.error !== null && !svgReady;
195
+
196
+ // This block's verdicts for the unowned comments. A diagram that failed
197
+ // to render resolves nothing, and must say so or the verdict stays
198
+ // pending forever.
199
+ useEffect(() => {
200
+ if (unownedAnnotations.length === 0) return;
201
+ const results = new Map<string, boolean>();
202
+ for (const ann of unownedAnnotations) {
203
+ if (renderFailed) results.set(ann.id, false);
204
+ else if (resolution?.has(ann.id)) results.set(ann.id, resolution.get(ann.id) === true);
205
+ }
206
+ if (results.size > 0) claims.report(block.id, results);
207
+ }, [block.id, claims, renderFailed, resolution, unownedAnnotations]);
208
+
209
+ // The restore verdict the panel's "Unanchored" chip runs on: this block's
210
+ // own comments, the unowned ones it shows, and (from the first diagram
211
+ // block only, so it is said once) the unowned ones nobody resolved.
212
+ useEffect(() => {
213
+ if (onRestoreReport === undefined || (resolution === null && !renderFailed)) return;
214
+ const attempted: string[] = [];
215
+ const unanchored: string[] = [];
216
+ for (const ann of ownAnnotations) {
217
+ attempted.push(ann.id);
218
+ if (renderFailed || resolution?.get(ann.id) === false) unanchored.push(ann.id);
219
+ }
220
+ for (const ann of unownedAnnotations) {
221
+ const owner = claims.owner(ann.id);
222
+ if (owner === block.id) attempted.push(ann.id);
223
+ else if (owner === null && claims.blockIds[0] === block.id) {
224
+ attempted.push(ann.id);
225
+ unanchored.push(ann.id);
226
+ }
227
+ }
228
+ if (attempted.length > 0) onRestoreReport({ attempted, unanchored });
229
+ }, [block.id, claims, claimsVersion, onRestoreReport, ownAnnotations, renderFailed, resolution, unownedAnnotations]);
230
+
231
+ const renderFallback = useCallback(
232
+ (state: DiagramRenderState) => {
233
+ if (state.error !== null) {
234
+ return (
235
+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 overflow-hidden">
236
+ <div className="px-3 py-2 bg-destructive/10 border-b border-destructive/20 flex items-center gap-2">
237
+ <svg className="w-4 h-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
238
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
239
+ </svg>
240
+ <span className="text-xs text-destructive font-medium">{label} Error</span>
241
+ {state.error.runtimeUnavailable && (
242
+ <button
243
+ type="button"
244
+ onClick={() => RETRY_EPOCHS[kind].bump()}
245
+ className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
246
+ title="Retry loading the diagram renderer"
247
+ >
248
+ Retry
249
+ </button>
250
+ )}
251
+ </div>
252
+ <pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{state.error.message}</pre>
253
+ <pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
254
+ <code>{block.content}</code>
255
+ </pre>
256
+ </div>
257
+ );
258
+ }
259
+ // First render still in flight (the engine import on the lazy path,
260
+ // then the render itself): the source stays readable under a quiet
261
+ // status line. A re-render for a theme change keeps the previous SVG,
262
+ // so this shows only before the first diagram lands.
263
+ return (
264
+ <>
265
+ <div
266
+ role="status"
267
+ aria-live="polite"
268
+ data-diagram-pending=""
269
+ {...(kind === 'mermaid' ? { 'data-mermaid-pending': '' } : {})}
270
+ className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground"
271
+ >
272
+ <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/70" aria-hidden="true" />
273
+ Rendering diagram…
274
+ </div>
275
+ <InlineSource block={block} kind={kind} />
276
+ </>
277
+ );
278
+ },
279
+ [block, kind, label],
280
+ );
281
+
282
+ const viewerProps = {
283
+ kind,
284
+ source: block.content,
285
+ theme,
286
+ comments,
287
+ onCreateComment: handleCreate,
288
+ selectedCommentId,
289
+ onSelectComment: onSelectAnnotation,
290
+ sourceLineOffset: block.startLine,
291
+ retryToken,
292
+ };
293
+
294
+ return (
295
+ <>
296
+ {/* `annotation-exclude`: the text highlighter never enters a diagram,
297
+ so a text restore (a reply that lost its anchor, a quote that also
298
+ appears in a node label) can never wrap a <mark> inside the svg. */}
299
+ <div ref={rootRef} className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
300
+ {svgReady && !showSource && (
301
+ <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
302
+ <button
303
+ type="button"
304
+ onClick={() => setShowSource(true)}
305
+ className="p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
306
+ title="Show source"
307
+ aria-label="Show source"
308
+ >
309
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
310
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
311
+ </svg>
312
+ </button>
313
+ <button
314
+ type="button"
315
+ onClick={() => setIsExpanded(true)}
316
+ className="p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
317
+ title="Expand diagram"
318
+ aria-label="Expand diagram"
319
+ data-diagram-expand=""
320
+ >
321
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
322
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
323
+ </svg>
324
+ </button>
325
+ </div>
326
+ )}
327
+ {showSource ? (
328
+ <div className="relative">
329
+ <button
330
+ type="button"
331
+ onClick={() => setShowSource(false)}
332
+ className="absolute top-2 right-2 z-10 p-1.5 rounded-md bg-muted/85 hover:bg-muted text-muted-foreground hover:text-foreground"
333
+ title="Show diagram"
334
+ aria-label="Show diagram"
335
+ >
336
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
337
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
338
+ </svg>
339
+ </button>
340
+ <InlineSource block={block} kind={kind} />
341
+ </div>
342
+ ) : (
343
+ <div
344
+ data-diagram-inline=""
345
+ className={svgReady ? 'rounded-xl bg-muted/30 border border-border/30 overflow-hidden' : undefined}
346
+ style={svgReady ? { height: inlineHeight(renderState) } : undefined}
347
+ >
348
+ <DiagramViewer
349
+ {...viewerProps}
350
+ renderId={`${kind}-${block.id}`}
351
+ onResolutionChange={setResolution}
352
+ onRenderState={setRenderState}
353
+ renderFallback={renderFallback}
354
+ />
355
+ </div>
356
+ )}
357
+ </div>
358
+ {isExpanded && svgReady && typeof document !== 'undefined' && (
359
+ <DiagramPopout
360
+ {...viewerProps}
361
+ open
362
+ onClose={() => setIsExpanded(false)}
363
+ title={`${label} diagram`}
364
+ renderId={`${kind}-${block.id}-popout`}
365
+ dataAttributes={{ 'data-block-id': block.id }}
366
+ />
367
+ )}
368
+ </>
369
+ );
370
+ };
371
+
372
+ const InlineSource: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
373
+ <pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
374
+ <code className={`pn-code font-mono language-${block.language?.trim().split(/\s+/, 1)[0] ?? kind}`}>{block.content}</code>
375
+ </pre>
376
+ );