@plannotator/ui 0.39.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 +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
package/HANDOFF.md
CHANGED
|
@@ -212,13 +212,14 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
|
|
|
212
212
|
| `components/html-viewer` (`HtmlViewer`, `projectHostThreads`, `buildPersistedHtmlAnchor`) | The raw-HTML annotation viewer: overlay-projected placed markers, pinpoint anchors, multi-target comments. Props + validated bridge protocol; no backend of its own. See "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" and "HTML annotation parity seams". *(Blessed in 0.29.0.)* |
|
|
213
213
|
| `components/HtmlSurfaceControls` | The eye / refresh / pen header controls for an HTML surface, with per-string `labels` overrides. Presentation only. See "HTML annotation parity seams". |
|
|
214
214
|
| `hooks/useHtmlRefresh` | Re-fetch a rendered HTML document through a host-supplied `fetchSnapshot`, remount the viewer on a reload generation, acknowledge the restore report once. See "HTML annotation parity seams". |
|
|
215
|
-
| `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A). Pure: React plus `utils/platform`; no backend. |
|
|
215
|
+
| `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A toggles annotate mode, Mod+Shift+X shows/hides the tools). Pure: React plus `utils/platform`; no backend. |
|
|
216
216
|
| `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
|
|
217
217
|
| `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
|
|
218
218
|
| `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
|
|
219
219
|
| `utils/mermaid-math-slot` | Alias target only: what a host redirects Mermaid's own `katex` import to, so `$$` labels in diagrams typeset through the math slot and the host build carries one KaTeX chunk. Never import it yourself. See "Lazy renderers and eager entries", item 2. |
|
|
220
220
|
| `utils/identity-tater` | Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with `identityProvider` should not. |
|
|
221
|
-
| `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration.
|
|
221
|
+
| `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration. Omit `utils/mermaid-eager` for the lazy path with retry, which is what Plannotator itself does since 0.40.0 (Mermaid 12); import it to register the runtime in your entry chunk at startup. See "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)". |
|
|
222
|
+
| `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`) and `utils/cssColor` | Theme-aware diagram configuration: the pure token-to-`themeVariables` mapping with its contrast guard, the document token reader, and the cached per-`(palette, mode)` `initialize` step `MermaidBlock` runs before each render. Additive; with no theme tokens on the document the static `MERMAID_CONFIG` stays in force. See "Theme-aware Mermaid diagrams (0.40.0)". |
|
|
222
223
|
|
|
223
224
|
**AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code.
|
|
224
225
|
|
|
@@ -456,11 +457,11 @@ Behavior is pinned by `components/MarkdownEditor.embedPicker.test.ts`, the suppo
|
|
|
456
457
|
|
|
457
458
|
## Lazy renderers and eager entries (0.32.0)
|
|
458
459
|
|
|
459
|
-
Four modules that used to ride every document read for a host that bundles by route now load on demand: the Mermaid runtime, the Graphviz engine, KaTeX, and the username dictionary. Plannotator's own apps register KaTeX and the dictionary eagerly in both the plan editor and the review editor, and the plan editor also
|
|
460
|
+
Four modules that used to ride every document read for a host that bundles by route now load on demand: the Mermaid runtime, the Graphviz engine, KaTeX, and the username dictionary. Plannotator's own apps register KaTeX and the dictionary eagerly in both the plan editor and the review editor, and through 0.39.0 the plan editor also registered the Mermaid runtime eagerly (the review editor never renders a Mermaid block and deliberately does not), so every surface rendered exactly as before; the single-file builds were unchanged in size and first paint and the portal entry chunk kept Mermaid as on main (the built-HTML markers and the A/B proof live in `tests/entry-assets.test.ts` and the PR that shipped this). **Since 0.40.0 the plan editor takes the lazy Mermaid path too** — see "Mermaid 12 (0.40.0)" for why and for the host contract; the paragraph below describes the slot, which is unchanged.
|
|
460
461
|
|
|
461
462
|
1. **Graphviz: no seam, nothing to do.** `GraphvizBlock` imports `@viz-js/viz` inside its render effect. It already showed the source fence until the SVG landed, so the only change for a chunking host is that the first dot fence on a page fetches the engine. A failed import is dropped from the memo and re-attempted once with a fresh `import()` after a short delay; a persistently failing chunk surfaces as the existing error panel with the source, plus a Retry button that issues another fresh attempt (a diagram syntax error shows the panel exactly as before, without Retry). Hosts that aliased the specifier to a lazy shim can delete the shim.
|
|
462
463
|
|
|
463
|
-
**Mermaid: a runtime slot, filled eagerly by Plannotator.** `utils/mermaid` holds the slot (`getMermaidRuntime`, `setMermaidRuntime`, `getMermaidRuntimeSource`) and the one code path `MermaidBlock` uses, `loadMermaidRuntime()`: it resolves at once from a filled slot and otherwise imports `mermaid` lazily, initialized once with `MERMAID_CONFIG` (`securityLevel: 'strict'` pinned by test), with the same drop-on-rejection, one automatic re-attempt and Retry button as Graphviz. `utils/mermaid-eager` imports the runtime statically, initializes it at module evaluation (where the old module-scope `initialize` ran) and fills the slot; `packages/editor/App.tsx`
|
|
464
|
+
**Mermaid: a runtime slot, filled eagerly by Plannotator.** `utils/mermaid` holds the slot (`getMermaidRuntime`, `setMermaidRuntime`, `getMermaidRuntimeSource`) and the one code path `MermaidBlock` uses, `loadMermaidRuntime()`: it resolves at once from a filled slot and otherwise imports `mermaid` lazily, initialized once with `MERMAID_CONFIG` (`securityLevel: 'strict'` pinned by test), with the same drop-on-rejection, one automatic re-attempt and Retry button as Graphviz. `utils/mermaid-eager` imports the runtime statically, initializes it at module evaluation (where the old module-scope `initialize` ran) and fills the slot; through 0.39.0 `packages/editor/App.tsx` imported it by policy, so Plannotator's plan surfaces kept Mermaid in their entry chunk and it could never fail separately from the app. Since 0.40.0 neither Plannotator app imports it (Mermaid 12's runtime is too large to ride every plan read; `tests/entry-assets.test.ts` now asserts the eager marker is ABSENT from both bundles). A host that wants startup registration adds `import '@plannotator/ui/utils/mermaid-eager'`; a host that omits it gets the lazy path, exactly like Plannotator.
|
|
464
465
|
|
|
465
466
|
**Retry, honestly.** An in-page retry cannot recover a chunk whose first fetch failed: browsers record a failed module fetch in the module map for the page lifetime, so a fresh `import()` of the same URL rejects without a request, and package code cannot re-import under a new URL because Rollup minifies the chunk's export names. The retry therefore 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. The panel with the source is always shown, never a blank.
|
|
466
467
|
|
|
@@ -485,7 +486,7 @@ Four modules that used to ride every document read for a host that bundles by ro
|
|
|
485
486
|
|
|
486
487
|
With the alias the same consumer build emits zero chunks carrying the KaTeX body out of the package and the entry's only `import()` in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries import `math-eager`, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through `inlineDynamicImports` as before (`tests/entry-assets.test.ts` pins the split: `utils/math` has no `import('katex')` site, `utils/math-default-loader` has the only one).
|
|
487
488
|
|
|
488
|
-
**Mermaid's own KaTeX, and the last shared chunk (0.34.0, from 0.33.0 adoption feedback).** The alias above is not the whole story once a page can render a Mermaid diagram. The Mermaid runtime (11.15.0
|
|
489
|
+
**Mermaid's own KaTeX, and the last shared chunk (0.34.0, from 0.33.0 adoption feedback).** The alias above is not the whole story once a page can render a Mermaid diagram. The Mermaid runtime (11.15.0 when this was written; 12.0.0 since 0.40.0, same import) typesets `$$...$$` labels through its own `import("katex")`, inside `renderKatexUnsanitized`, and it offers nothing to turn that off: `legacyMathML` / `forceLegacyMathML` only choose the output mode, the guard around the import is the `@mermaid-js/tiny` build marker, and there is no hook to hand it a renderer. The import only *runs* for a label that matches Mermaid's `$$` test, but it is *emitted* regardless, so a host that registered a loader and aliased the default still built a `katex-*.js` chunk, and because that chunk then had two dynamic importers (the host's loader module and the Mermaid runtime) Rollup kept it separate from the host's loader chunk: a math document fetched two files, the 57-byte loader chunk plus the shared 261 KB KaTeX chunk. Measured on the scratch Vite 6 consumer of this checkout (loader registered, default aliased, a document with inline math, a display block and a flowchart with a `$$` label): one chunk carried the KaTeX body before, `katex-*.js`, imported by `mermaid.core-*.js` and by the host's loader chunk; 367 JS chunks in all.
|
|
489
490
|
|
|
490
491
|
The fix is a bundler-facing redirect to a package module, `utils/mermaid-math-slot`, whose default export has the one method Mermaid calls (`renderToString`) and delegates to whatever fills the math slot, with Mermaid's own options (`throwOnError: true`, `displayMode: true`, the MathML `output` mode) passed through untouched, so a KaTeX renderer produces exactly the markup Mermaid produced from its direct import. Redirect the `katex` specifier for importers inside the `mermaid` package ONLY; a plain `resolve.alias` on `katex` would also rewrite your own loader's import and break math everywhere:
|
|
491
492
|
|
|
@@ -504,7 +505,7 @@ Four modules that used to ride every document read for a host that bundles by ro
|
|
|
504
505
|
// plugins: [mermaidKatexToSlot, react(), ...]
|
|
505
506
|
```
|
|
506
507
|
|
|
507
|
-
Two details of that snippet are layout-proofing. The importer test is `node_modules/mermaid/` anywhere in the path, not a pattern for one install layout: a hoisted install puts the runtime at `node_modules/mermaid/`, Bun's isolated layout at `node_modules/.bun/mermaid@
|
|
508
|
+
Two details of that snippet are layout-proofing. The importer test is `node_modules/mermaid/` anywhere in the path, not a pattern for one install layout: a hoisted install puts the runtime at `node_modules/mermaid/`, Bun's isolated layout at `node_modules/.bun/mermaid@12.0.0/node_modules/mermaid/`, and pnpm's at `node_modules/.pnpm/mermaid@12.0.0/node_modules/mermaid/`; every one of them ends in that segment, and the trailing separator keeps `mermaid-something` packages out. The slot module is resolved from the host's own config file (`configFile`), not from the Mermaid importer: resolving from the importer walks up from Mermaid's location, which finds `@plannotator/ui` on hoisted and Bun-isolated installs but not under pnpm's strict `node_modules`, where the package is only visible from the host root. Resolving from the config file is the same lookup the host's own imports use; passing an absolute path to the file (`path.resolve(...)` of the installed `utils/mermaid-math-slot.ts`) works too.
|
|
508
509
|
|
|
509
510
|
With the redirect the same consumer build emits one chunk carrying the KaTeX body, the host's own loader chunk (`host-katex-*.js`, 261 KB, reached only by the entry's `import()`), `mermaid.core-*.js` has no KaTeX import left, and the chunk count drops to 366: one KaTeX chunk, owned by the host, one file fetched. The slot must be filled by the time Mermaid asks, so `MermaidBlock` awaits `loadMathRenderer()` before rendering a diagram whose source carries a `$$` label (`hasMermaidMath`, Mermaid's own regex); on a filled slot that resolves at once, on the lazy path it runs your loader, and if that load fails the label throws a message naming the cause (`MERMAID_MATH_SLOT_EMPTY_MESSAGE`) which the block's error panel shows with the source. Do not import the module yourself; it exists to be resolved to. Plannotator does not redirect: its Mermaid keeps its direct KaTeX, inlined by the single-file builds with everything else, and the pre-render wait is a resolved promise there because `math-eager` filled the slot at startup. No test in the repo renders a real Mermaid diagram with a math label (Mermaid does not render under happy-dom), and nothing in Plannotator's own documents exercises `$$` labels; the bridge is pinned by `utils/mermaid-math-slot.test.ts` (delegation with Mermaid's exact options, KaTeX parity, the empty-slot error, the label regex) and the pre-render warm by the "Mermaid math labels warm the math slot" cases in `components/DiagramBlock.lazyRetry.test.tsx`.
|
|
510
511
|
|
|
@@ -654,7 +655,7 @@ Nine additive seams so a host can run the raw-HTML annotation surface with the s
|
|
|
654
655
|
|
|
655
656
|
3. **`hooks/useHtmlRefresh({ enabled?, documentKey?, fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`** returns `{ canRefresh, isRefreshing, reloadGeneration, refresh, reportAnnotationRestore }`. `fetchSnapshot(documentKey)` resolves `{ status: 'ok', rawHtml } | { status: 'missing' } | { status: 'unavailable' }`; a rejection counts as `unavailable`. Key the viewer on `reloadGeneration` and wire its `onUnanchoredChange` to `reportAnnotationRestore`. The hook owns the guards: a fetch superseded by a newer refresh or by a `documentKey` change never applies, and the restore acknowledgement fires once per reload generation with the viewer's first report for the remounted document, which by item 2 is the bridge's post-restore set, the empty set included, so a host clears its chip when a previous orphan re-anchors. Notifications are the host's, through `onResult`.
|
|
656
657
|
|
|
657
|
-
4. **`components/HtmlSurfaceControls({ armed, onToggleArmed?, toolsHidden?, onToggleTools?, canRefresh?, onRefresh?, isRefreshing?, compact?, labels? })`**: the eye, the refresh and the pen with the exact markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`, `data-html-annotate-toggle`), `aria-pressed` on the pen and the eye, `aria-disabled` on an in-flight refresh (focus is kept), and the pen's pixel-stable border. Each control renders only when its handler is passed; `compact` renders nothing. `labels` overrides any string per key (`annotateTitle`, `interactTitle`, `annotateLabel`, `interactLabel`, `hideTools`, `showTools`, `refresh`, `refreshing`, `refreshTitle`, `refreshingTitle`); the defaults are Plannotator's pen and eye strings, the refresh default is the neutral "Refresh document", and no pen `aria-label` is emitted unless a label is passed. Pin the armed state to `annotateModeActive` and pass `onAnnotateModeExit` / `onAnnotateModeToggle` to the viewer so Esc and Mod+Shift+
|
|
658
|
+
4. **`components/HtmlSurfaceControls({ armed, onToggleArmed?, toolsHidden?, onToggleTools?, canRefresh?, onRefresh?, isRefreshing?, compact?, labels? })`**: the eye, the refresh and the pen with the exact markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`, `data-html-annotate-toggle`), `aria-pressed` on the pen and the eye, `aria-disabled` on an in-flight refresh (focus is kept), and the pen's pixel-stable border. Descriptions ride the package's `Tooltip` (hover AND focus-visible) instead of a native `title`, with the control's shortcut under it as keycaps; `shortcuts` (`{ annotate?, tools?, refresh? }`, normalized bindings, `null` for none) overrides them and defaults to the `html-annotate` scope. Each control renders only when its handler is passed; `compact` renders nothing. `labels` overrides any string per key (`annotateTitle`, `interactTitle`, `annotateLabel`, `interactLabel`, `hideTools`, `showTools`, `refresh`, `refreshing`, `refreshTitle`, `refreshingTitle`); the defaults are Plannotator's pen and eye strings, the refresh default is the neutral "Refresh document", and no pen `aria-label` is emitted unless a label is passed. Pin the armed state to `annotateModeActive` and pass `onAnnotateModeExit` / `onAnnotateModeToggle` / `onToolsToggle` to the viewer so Esc, Mod+Shift+A and Mod+Shift+X work with focus inside the frame.
|
|
658
659
|
|
|
659
660
|
5. **`AnnotationPanel` `unanchoredIds?: ReadonlySet<string>`** renders a small "Unanchored" chip (`data-annotation-unanchored`) on matching cards. Absent, the DOM is unchanged (pinned by comparing the markup against an explicit empty set).
|
|
660
661
|
|
|
@@ -676,11 +677,140 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
|
|
|
676
677
|
|
|
677
678
|
---
|
|
678
679
|
|
|
680
|
+
## Theme-aware Mermaid diagrams (0.40.0)
|
|
681
|
+
|
|
682
|
+
Mermaid diagrams used to render from one static config in every palette and both modes: `MERMAID_CONFIG` pinned Mermaid's `dark` base theme plus a slate `themeVariables` palette, so a diagram was blue-on-slate under GitHub Light and Catppuccin alike. Diagrams now follow the active colour theme and mode the way code fences already do (`resolveFenceTheme` / `useFenceTheme`), through ONE dynamic mapping rather than per-palette themes.
|
|
683
|
+
|
|
684
|
+
**How it works.** `utils/mermaidTheme` has three layers. `readThemeTokens(el?)` reads the theme custom properties off the document element (`--background`, `--foreground`, `--card`, `--card-foreground`, `--popover`, `--border`, `--muted`, `--muted-foreground`, `--primary`, `--primary-foreground`, `--secondary`, `--accent`, `--destructive`, `--success`, `--warning`, `--font-sans`) via `getComputedStyle`, resolving anything the pure parser cannot read as written (`color-mix()`, a `var()` chain) through a throwaway probe element, and returns `undefined` when neither `--background` nor `--foreground` resolves. `buildMermaidThemeVariables(tokens, mode)` is pure: it parses the tokens (hex, `rgb()`, `hsl()`, `oklch()`, `oklab()`, `lab()`, `lch()`, `color()`; alpha composited over the background, output always opaque hex because Mermaid's colour library does not read `oklch()`), fills any missing optional token from the two required ones, and returns `{ theme, themeVariables }` — base theme `dark` when the resolved mode is dark, `default` when light, with a complete override for every documented family: general, flowchart, sequence (`actor*`, `signal*`, `note*`, `activation*`, `labelBox*`, `sequenceNumberColor`), state, class, ER (`attributeBackgroundColor*`, `rowOdd/Even`), requirement, gitGraph (`git0..7`, `gitInv*`, `gitBranchLabel*`, `commitLabel*`, `tagLabel*`), gantt, pie (`pie1..12`, `pieOpacity: 1`), the `cScale*` scale behind mindmap/timeline, journey (`fillType0..7`), quadrant, venn, architecture, C4, plus the nested `xyChart`, `packet`, `radar`, `wardley`, `cynefin` objects. `applyMermaidTheme(mermaid, key, root?)` is the runtime step `MermaidBlock` runs before every render: `mermaid.initialize` is global, so it runs only when the `(palette, mode)` key (`mermaidThemeKey(colorTheme, mode)`, from `useTheme()`) or the runtime object changed since the last apply; a key change also re-runs the block's render effect, which is what re-themes an already rendered diagram (the fence re-highlight pattern).
|
|
685
|
+
|
|
686
|
+
**Token → variable mapping** (the canvas is `muted` at 30% over `card`, matching the block container's `bg-muted/30`; the same mix over `background` is also checked as a surface, since the container can sit on either): `background`/`labelBackground`/`edgeLabelBackground`/`commitLabelBackground`/`relationLabelBackground`/`altSectionBkgColor` ← canvas; node/actor/state/entity/requirement/person fills (`primaryColor`, `mainBkg`, `nodeBkg`, `actorBkg`, `stateBkg`, `requirementBackground`, `tagLabelBackground`, `attributeBackgroundColorOdd`, `rowOdd`) ← `card`; their text (`primaryTextColor`, `nodeTextColor`, `actorTextColor`, `stateLabelColor`, `classText`, `requirementTextColor`, `tagLabelColor`) ← `card-foreground`; borders (`primaryBorderColor`, `nodeBorder`, `border1`, `actorBorder`, `activationBorderColor`, `compositeBorder`, `taskBorderColor`, `gridColor`, `pieOuterStrokeColor`, `archGroupBorderColor`, `quadrant*BorderStrokeFill`) ← `border`; edges and arrowheads (`lineColor`, `arrowheadColor`, `defaultLinkColor`, `signalColor`, `actorLineColor`, `labelBoxBorderColor`, `transitionColor`, `relationColor`, `archEdge*`, `innerEndBackground`, `specialStateColor`) ← `muted-foreground`; page text (`textColor`, `titleColor`, `labelColor`, `signalTextColor`, `transitionLabelColor`, `commitLabelColor`, `pieTitleTextColor`, `pieLegendTextColor`, `taskTextOutsideColor`, quadrant/xyChart/wardley text) ← `foreground`; cluster/subgraph, composite state, label box, activation and gantt section fills (`clusterBkg`, `secondaryColor`, `secondBkg`, `compositeBackground`, `labelBoxBkgColor`, `activationBkgColor`, `sectionBkgColor`, `doneTaskBkgColor`) ← `muted`; `tertiaryColor` ← `popover`; notes ← `card` tinted 18% toward `warning` with a `warning` border; error fills ← `card` tinted toward `destructive`; accents (`activeTaskBorderColor`, `vertLineColor`, `quadrantPointFill`, `taskTextClickableColor`) ← `primary`; `todayLineColor`/`critBorderColor` ← `destructive`. The twelve categorical fills (`cScale0..11`, `pie1..12`, `git0..7`, `fillType0..7`, `venn1..8`, `taskBkgColor`, `activeTaskBkgColor`, `xyChart.plotColorPalette`) are seeded from the palette's own accent tokens in the order `primary`, `accent`, `success`, `warning`, `destructive`, `secondary` (greys skipped, hues closer than 18° merged) and completed with hue rotations of the first seed, all normalized to one OKLCH lightness per page polarity (0.74 on a dark page, 0.50 on a light one; chroma clamped to 0.06..0.15) so one ink — the `background` token — reads on all of them (`cScaleLabel*`, `gitBranchLabel*`, `gitInv*`, `pieSectionTextColor`). `fontFamily` ← `--font-sans` when present. `darkMode` follows the mode.
|
|
687
|
+
|
|
688
|
+
**Contrast rule.** Every text-on-fill pair the mapping produces must reach WCAG 4.5:1 and every line-on-canvas pair 3:1 (`ensureContrast`; the mapping adds 0.1 of headroom because a browser composites the container tint in its own space). Page-level text and lines are guarded against every surface they can cross rather than one: the canvas (the block's `bg-muted/30` tint over the document card, which is where Plannotator's article puts it, and over the bare page for a host that mounts the block there), node fills (`card`), cluster and composite-state fills (`muted`), popovers and ER rows — the first sweep found edges at 2.4–2.9:1 and cluster titles at 4.0:1 in 20 palettes precisely because they had been guarded against the page background alone. A pair that falls short is repaired by moving the text or line colour toward the mode's `foreground` token by the smallest OKLab step that satisfies the ratio (hue is kept where possible); when `foreground` cannot reach the ratio on that fill, the `background` token is used as the ink; when neither token can, pure black or white is the last resort (a mid-luminance fill such as the line colour under a sequence number); ratios are measured on the 8-bit colour Mermaid receives. Categorical fills are additionally pushed in lightness until the `background` ink reaches 4.5:1 on each. Structural strokes (node, cluster, actor borders) are guaranteed 1.5:1 against the canvas, nudged toward `muted-foreground`, so a palette with a near-invisible `border` still draws outlines. Page polarity (which lightness the fills are normalized to) is decided from the measured luminance of the `background` token, not from the mode label, so a dark-only palette rendered under a light label still gets fills its ink can carry; the label only picks the Mermaid base theme. `packages/ui/utils/mermaidTheme.test.ts` sweeps every palette in `packages/ui/themes` in both modes against these pairs, so a new palette cannot regress the guard.
|
|
689
|
+
|
|
690
|
+
**Fallback contract for hosts.** Nothing changes for a host that does not use the tokens: with no `--background`/`--foreground` on the document (no `ThemeProvider`, no `theme.css`), `readThemeTokens` returns `undefined`, `buildMermaidThemeVariables` returns `null`, `buildMermaidConfig(null)` is `MERMAID_CONFIG` itself, and `applyMermaidTheme` records the key without calling `initialize` at all, so the runtime keeps the static config the loader or the eager entry initialized it with and renders byte-identically to 0.39.0 (pinned by `utils/mermaidTheme.test.ts` and `components/MermaidBlock.theme.test.tsx`). Outside a `ThemeProvider`, `useTheme()` yields the default context (Plannotator dark), which only names the key. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` still pinned by `components/MermaidBlock.test.ts`; `flowchart.htmlLabels` and `curve` are carried into the dynamic config unchanged) and `loadMermaidRuntime` / the eager entry are untouched: the runtime is still initialized once at registration, and the theme apply is a second, cached `initialize` on top. A host that ships its own tokens under the same names gets themed diagrams for free; a host that wants the old slate look in a themed document can keep the tokens off the diagram's ancestors, since `readThemeTokens` reads the document element by default. New exports are additive; the only behaviour change is for documents that carry the tokens, where diagrams now follow them.
|
|
691
|
+
|
|
692
|
+
**Known limits.** `GraphvizBlock` already maps its output to `var(--foreground)` / `var(--muted-foreground)` / `var(--muted)` and needs nothing. Mermaid hardcodes a `#000000` stroke on the sequence `crosshead` marker (lost messages, `-x`), which no theme variable reaches; it stays as in every Mermaid theme. The Mermaid 12 upgrade shipped in the same 0.40.0 publish and reuses this mapping unchanged; the re-sweep on 12 is in "Mermaid 12 (0.40.0)".
|
|
693
|
+
|
|
694
|
+
## Element context through the host seam (0.40.0)
|
|
695
|
+
|
|
696
|
+
`@plannotator/ui` 0.39.0 captured **element context** on raw-HTML and live-app pinpoints but deliberately left `@plannotator/core/html-anchor` untouched, so a host persisting through `buildPersistedHtmlAnchor` and reading through `projectHostThreads` dropped the field on save and never got it back on projection. That gap (#1521) is closed: the field now survives the whole host round trip.
|
|
697
|
+
|
|
698
|
+
**The validator lives in core.** `parseHtmlElementContext` is exported by **`@plannotator/core/html-anchor`**, beside `parseHtmlElementAnchor`, along with `MAX_ELEMENT_CONTEXT_BYTES` (2048) and `MAX_PAGE_URL_LENGTH` (2048). `@plannotator/ui/components/html-viewer` imports it and **re-exports it unchanged**, so the 0.39.0 import site keeps working and no host code has to move; what is gone is the hand-mirrored copy that used to live in `components/html-viewer/useHtmlAnnotation.ts` (the rule since core 0.25.0: no mirror validators). Behavior is byte-faithful to 0.39.0 — the same scalar bounds, the same `CONTEXT_ATTR_ALLOWLIST` with href/src scrubbed of query and fragment, the same `outline → text → attrs → classes → path → heading → landmark → component` shed order inside the 2 KiB serialized bound, and the same fail-closed `undefined` for anything that is not a record or carries no `tag`. A host that calls the validator directly gets identical output for identical input.
|
|
699
|
+
|
|
700
|
+
**What a host passes.** `buildPersistedHtmlAnchor(source, options)` now accepts `elementContext?: HtmlElementContext | null` on `source`, and each entry of `source.htmlAdditionalTargets` accepts `context?`. Both go through the same fail-closed parser the read path uses, so nothing is persisted that would be refused on read back. `HostThread` (the `projectHostThreads` input) accepts `elementContext?` the same way.
|
|
701
|
+
|
|
702
|
+
**What a host gets back.** `PersistedHtmlAnchor` gains `elementContext?` and `HtmlAnnotationTarget` gains `context?`. **Key order is fixed and matters:** `elementContext` serializes strictly AFTER `htmlAdditionalTargets`, and a target's `context` after its `anchor` (so a kept target's key order is `text, label, anchor, context`). A row that carries no context serializes byte-identically to what 0.39.0 wrote — the key is absent, not `undefined` — so a wire fingerprint over stored anchors does not move when you adopt this. `projectHostThreads` returns `elementContext` on the projection and `context` on each projected target, so a host's panel and its per-row Copy (which calls `exportAnnotationEntry`) see the field. Paint never reads it: the repaint path still posts only anchors to the bridge.
|
|
703
|
+
|
|
704
|
+
**The 16 KiB budget is unchanged, and contexts are the first thing it sheds.** `DEFAULT_HTML_ANCHOR_MAX_BYTES` stays 16384: one primary context at 2 KiB plus 16 extras at 1 KiB each already exceeds it, and raising the default is not the answer (the 48 KiB figure that appeared in the design write-up was never shipped). Under `maxBytes` the stages are now: the quote down to its 400-char floor → **per-target contexts, from the end** → **the primary context** → targets from the end → the rest of the quote. Shedding context before targets is deliberate: a context is descriptive and re-derivable on the next click, while a dropped target loses a marker the reviewer placed. Context shedding is silent — `droppedTargets`, `capDroppedTargets` and `sizeDroppedTargets` count targets only and keep their exact 0.39.0 meanings, so a host notice that distinguishes a product-cap drop from a size drop is unaffected.
|
|
705
|
+
|
|
706
|
+
**The field stays descriptive.** Restore never reads it, `HtmlElementAnchor` and the restore path are untouched, there is no `BRIDGE_PROTOCOL_VERSION` bump, and share links still drop it exactly as they drop anchors.
|
|
707
|
+
|
|
708
|
+
### Export without the outline
|
|
709
|
+
|
|
710
|
+
`elementContextExportBlock(ann, opts)` and `exportAnnotationEntry(ann, opts)` on `@plannotator/ui/utils/parser` take a new **`includeOutline?: boolean`, default `true`**. Pass `false` to print the identity lines (`selector`, `path`, `role · name · component`, `attrs`, `text`, `route`, `box`, `near`) WITHOUT the fenced HTML skeleton: in a model turn the 600-char outline is the expensive part per annotation, and the identity lines are what an agent greps. Defaulting to true keeps every existing caller's bytes unchanged.
|
|
711
|
+
|
|
712
|
+
**Default change to know about before you upgrade:** `exportAnnotationEntry`'s **`includeRoute` now defaults to `true` per field**, not only when `opts` is omitted. In 0.39.0 the default lived on the parameter (`opts = { includeRoute: true }`), so passing any explicit options object replaced it and `exportAnnotationEntry(ann, {})` silently dropped the live-app `route` line. A host that was passing an options object in order to suppress the route must now pass `includeRoute: false` explicitly. Callers that pass nothing, or that already pass `{ includeRoute: true }` or `{ includeRoute: false }`, are unaffected. `elementContextExportBlock`'s own default is unchanged (route off unless asked, because the grouped export prints a `## Page:` heading above the entries).
|
|
713
|
+
|
|
714
|
+
Behavior is pinned by `../core/html-anchor.test.ts` (the wire fingerprint of a context-less row, the shed order, and the projection) and the element-context cases in `utils/parser.test.ts`.
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## Mermaid 12 (0.40.0)
|
|
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 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
|
+
|
|
722
|
+
**What Mermaid 12 changes, and what we took.** We take 12's defaults rather than pinning the 11 ones (owner ruling). Concretely:
|
|
723
|
+
|
|
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
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
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.
|
|
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:
|
|
732
|
+
|
|
733
|
+
| Family | Anchor | Pattern (11 and 12) |
|
|
734
|
+
|---|---|---|
|
|
735
|
+
| flowchart / `graph` / `flowchart-elk` | node | `<g id="{renderId}-flowchart-{nodeId}-{n}" class="node default">` |
|
|
736
|
+
| | edge | `<path id="{renderId}-L_{from}_{to}_{n}" class="flowchart-link">` |
|
|
737
|
+
| | subgraph | `<g id="{renderId}-{subgraphId}" class="cluster">` under `g.clusters` |
|
|
738
|
+
| | markers | `<marker id="{renderId}_flowchart-v2-{pointEnd\|pointStart\|circleEnd\|circleStart\|crossEnd\|crossStart}[-margin]">` |
|
|
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`) |
|
|
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` |
|
|
741
|
+
| class | class box | `{renderId}-classId-{ClassName}-{n}` |
|
|
742
|
+
| | relation | `<path id="{renderId}-id_{From}_{To}_{n}" class="relation">`; markers `{renderId}_classDiagram-{kind}[-margin]` |
|
|
743
|
+
| ER | entity | `{renderId}-entity-{ENTITY}-{n}` |
|
|
744
|
+
| | relationship | `<path id="{renderId}-id_entity-{A}-{i}_entity-{B}-{j}_{n}" class="relationshipLine">`; attribute cells are class-only (`.attribute-type`, `.attribute-name`, `.attribute-keys`, `.row-rect-odd/even`) |
|
|
745
|
+
| requirement | node / relation | `{renderId}-{reqId}` / `<path id="{renderId}-{a}-{b}-{n}">` |
|
|
746
|
+
| sequence | lifeline / root | `line#actor{n}`, `g#root-{n}` — **global, un-prefixed**; messages, notes, activations and loops carry classes only (`.messageLine0/1`, `.messageText`, `.sequenceNumber`, `.note`, `.activation0`, `.loopLine`) and no ids |
|
|
747
|
+
| gitGraph / pie | — | no ids at all (`.commit`, `.commit{n}`, `.branch{n}`, `.pieCircle`, `.slice`); the per-commit hash class on gitGraph circles is derived from generated commit ids and was never stable |
|
|
748
|
+
| every family | gradient | one `<linearGradient id="{renderId}-gradient">` |
|
|
749
|
+
|
|
750
|
+
Three class-level deltas, none of which breaks an id- or class-based selector: `g.edgePaths` gains a second class (`<g class="edges edgePaths">`, additive, same element); empty `g.edgeLabel > g.label > div.labelBkg` placeholder groups are no longer emitted for edges without a label (a host that assumed one `.edgeLabel` per edge must count labeled edges only); and the gitGraph auto-hash class differs, as it always could.
|
|
751
|
+
|
|
752
|
+
**One DOM-order change a host must know about.** Children of `g.edgePaths` are now in **declaration order**. Under 11/dagre the eval's state diagram emitted `edge0, edge1, edge5, edge6, edge10, edge2, edge3, edge4`; under 12/ELK it emits `edge0 … edge10` in source order. Anything that indexes edges by DOM position (`edgePaths.children[i]`, `:nth-child`, walking siblings to pair an edge with a label) breaks; anything that selects by id (`{renderId}-L_{from}_{to}_{n}`, `{renderId}-edge{n}`, `{renderId}-id_{From}_{To}_{n}`) does not. If you need a stable order, sort by id or by the numeric suffix, never by position.
|
|
753
|
+
|
|
754
|
+
**Lazy-load contract.** Since 0.32.0 `utils/mermaid` has loaded the runtime through a slot: filled, it resolves at once; empty, `loadMermaidRuntime()` runs `import('mermaid')` on the first diagram, memoized, with a rejected load dropped so the block's automatic re-attempt and its Retry issue a fresh import (`utils/mermaid.test.ts`, `components/DiagramBlock.lazyRetry.test.tsx`). Through 0.39.0 Plannotator's plan editor filled the slot eagerly by importing `utils/mermaid-eager`, so a host copying its entry got the same. **0.40.0 removes that import**: with 12's runtime ~1.8 MB larger, a plan with no diagram must not pay for it, so Plannotator itself now takes the lazy path, and `tests/entry-assets.test.ts` fails if the eager import comes back into either app. What that means for a host:
|
|
755
|
+
|
|
756
|
+
- Nothing to change to get the lazy behaviour: it is the default of the package and always was. The first diagram on a page fetches `mermaid.core-*.js` (625 KB, 148 KB gzip on this checkout's portal build) and, for an ELK family, Mermaid then fetches `elk-*.js` (1,435 KB, 438 KB gzip); until the SVG lands the block shows the source fence under a quiet `role="status"` line ("Rendering diagram…", `data-mermaid-pending`), never the error panel, and the panel with the source plus Retry appears only for a failure (pinned by the "Mermaid pending state" case in `DiagramBlock.lazyRetry.test.tsx`).
|
|
757
|
+
- To keep the 0.39 behaviour — runtime registered and initialized at startup, in your entry chunk, unable to fail separately from the app — add the one line Plannotator used to have, before the first render: `import '@plannotator/ui/utils/mermaid-eager';`. It statically imports `mermaid`, runs `mermaid.initialize(MERMAID_CONFIG)` at module evaluation and fills the slot (`setMermaidRuntime(mermaid, 'plannotator-mermaid-eager')`); the ELK chunk is still Mermaid's own lazy import and is not hoisted by this.
|
|
758
|
+
- Own loader: `setMermaidRuntime(runtime, 'host')` after your own `import('mermaid')` + `initialize(MERMAID_CONFIG)` fills the slot the same way; there is no `mermaidRuntimeLoader` seam on `configurePlannotatorUI` (the test hook `__setMermaidRuntimeLoaderForTests` is not host surface).
|
|
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`).
|
|
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.
|
|
761
|
+
|
|
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.
|
|
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.
|
|
765
|
+
|
|
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
|
+
|
|
679
807
|
## Publishing & versioning
|
|
680
808
|
|
|
681
|
-
- The current pair is `@plannotator/ui` `0.
|
|
682
|
-
- The previous pair was `@plannotator/ui` `0.
|
|
683
|
-
-
|
|
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".
|
|
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).
|
|
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.
|
|
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.
|
|
684
814
|
- When both packages change, **publish `core` first**: ui 0.32.0 imports the `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
|
|
685
815
|
- The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
|
|
686
816
|
- UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
|
package/README.md
CHANGED
|
@@ -53,13 +53,13 @@ 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
|
|
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
|
-
- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')
|
|
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.
|
|
60
60
|
- **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
|
|
61
61
|
|
|
62
|
-
Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx
|
|
62
|
+
Plannotator's own entries import the eager math and identity modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`), which is what keeps math typeset on the first commit and names minted from the full dictionary; neither app imports `mermaid-eager` since 0.40.0, so the Mermaid runtime rides a lazy chunk on the share portal and is inlined by `inlineDynamicImports` in the single-file builds. `tests/entry-assets.test.ts` fails if an eager math/identity import is dropped or an eager Mermaid import creeps back. See HANDOFF.md "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)".
|
|
63
63
|
|
|
64
64
|
### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
|
|
65
65
|
|
|
@@ -103,7 +103,7 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
|
|
|
103
103
|
|
|
104
104
|
Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
|
|
105
105
|
|
|
106
|
-
- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection.
|
|
106
|
+
- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection. **Element context rides both helpers** (next publish, #1521): `buildPersistedHtmlAnchor` accepts `elementContext` on its source and `context` on each additional target and writes them back as `PersistedHtmlAnchor.elementContext` / `HtmlAnnotationTarget.context` (serialized after `htmlAdditionalTargets` and after each target's `anchor`, so a row without context stays byte-identical on the wire), and `projectHostThreads` returns both on the projection so a host panel and its per-row Copy see them. Contexts are shed before targets under `maxBytes`, whose 16 KiB default is unchanged. The validator, `parseHtmlElementContext`, lives in `@plannotator/core/html-anchor` and is re-exported from `components/html-viewer`.
|
|
107
107
|
- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict.
|
|
108
108
|
- **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
|
|
109
109
|
- **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
|
|
@@ -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.
|
|
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
|
|