feat(ui): the Workspaces diagram viewer becomes the diagram engine (ui 0.41.0, core 0.25.4) - #1560
Merged
Merged
Conversation
…d the feedback archive
DiagramAnchor ({ v, family, kind, id | from + to, label, sourceLine }) and its fail-closed parser live in @plannotator/core/diagram-anchor beside html-anchor. POST /api/external-annotations validates an optional diagramAnchor on plan comments in both runtimes (the module is vendored to Pi), and the feedback archive records the validated anchor as an additive field.
MermaidBlock and GraphvizBlock keep fence parsing, diagramLanguages and the lazy-retry contract and render through one renderer slot (utils/diagram-render) and one canvas (components/diagram); their own viewBox math, applyView and zoom controls are gone, and the popout is the same DiagramViewer at full size in the PopoutDialog chrome. Graphviz gets a runtime slot beside Mermaid's (utils/graphviz, @viz-js/viz pinned 3.30.0). mermaidSvg.ts is replaced by sanitizeDiagramSvg (DOMPurify parse + in-place scrub). A comment composed on a diagram part is an Annotation with diagramAnchor and the fence's document lines: the highlighter skips it, the block restores it through the engine's finder and reports unanchored rows, the export prints its location line, share links drop it. Interaction per owner feedback: click selects, drag pans (4 px threshold), no hover targeting without the platform modifier, and an invisible 14 px hit path beside every edge.
The viewer script is byte-identical (viewer.KTNT-M2b.js): the guide chain renders no fences, so the diagram engine is not in the portable bundle. Only the stylesheet hash moves (349,765 to 351,932 bytes) because the viewer's Tailwind scan covers packages/ui/components and picks up the new diagram components' classes.
…DOFF corrections HANDOFF gains 'Diagram engine (0.41.0)' (every new export, the adapter props, the anchor shape, the runtime slots, the sanitizer delta, the interaction model, migration notes, publish order). The Mermaid 12 section is corrected: the sweep is 78 combinations / 12,870 text pairs / 7,410 line pairs; consumers add their own lodash-es override; state diagrams gain the barbEnd-margin marker id; ELK is the effective default through per-diagram defaults while config.layout still reads dagre.
…hor codec Both additive within v: 1. Sequence parts carry classes, not ids, so the codec's ids are the actor's name, msg-<n>, note-<n>, frame-<n>; sourceLine for an ordinal is the n-th statement of its kind. Kind 'diagram' has no id: it is what a click that resolves no part anchors to, exported as 'Diagram (<family>), lines a–b'.
Review blockers: the inline canvas is touch-action pan-y (only the popout is touch-none); the zoom strip and block controls never print; exportLinkedDocAnnotations and exportAnnotationEntry print the diagram location line; a WebMCP reply inherits diagramAnchor and diagrams are annotation-exclude so text restore never wraps a mark inside an svg. Nits: bare hit paths (no data-*), <style> scoped to the svg's root id (no @import, no fetching url(), no rule that can restyle the page), comments that name no diagram block resolve by anchor across blocks or list as Unanchored, label fallback only for a unique label, canvas keys ignore Meta/Ctrl/Alt, the modifier ring disarms on keyup/blur/other key, 10 px touch threshold, the wheel deltaY guard. Owner findings: measured in Chromium, the only thing covering a Mermaid edge is its own label box at its midpoint, so hit paths move to one top layer with composed transforms, a label resolves to its edge, and clicks resolve by priority over elementsFromPoint (node, edge, cluster); sequence diagrams are addressable; a click on no part comments on the whole diagram.
…nd, scoped styles, unowned anchors; HANDOFF names 0.41.0 / 0.25.4
backnotprop
force-pushed
the
feat/diagram-engine
branch
from
September 17, 2026 20:31
700c69e to
0531212
Compare
backnotprop
added a commit
that referenced
this pull request
Sep 18, 2026
…scope the code-nav root exclusion exemption (#1564) * fix(api): validate PATCH bodies on /api/external-annotations, and read diagramAnchor defensively PATCH merged its body into the stored annotation verbatim in both runtimes, so any local process could store a value POST refuses. `{"diagramAnchor": null}` was answered 200 and the SSE broadcast then blanked the open page: DiagramBlock read `.family` off it during render. Two layers: - `validateAnnotationPatch` in @plannotator/core/external-annotation (the module both handlers already import) allowlists and field-validates the patch with the same validators POST applies — diagramAnchor through parseDiagramAnchor, htmlAnchor / elementContext / the target arrays through their own parsers, the scalars by type and cap. Unknown keys are dropped, `id` and `source` stay immutable, `null` clears an optional field and is refused on an anchor or a structural one. - The renderer no longer trusts the field: DiagramBlock and Viewer read it with `!= null` / `?.`, so no ingest — API, draft or share link — can make a render throw. * fix(code-nav): keep the root vendor/ tree excluded for a first-party package of the same name The origin-file exemption (#1558) lifted a directory exclusion whenever the ORIGIN path contained that segment anywhere, which lifted it tree-wide: a request from `src/main/java/com/example/vendor/app/Widget.java` also returned matches from the repo-root `vendor/` third-party tree it exists to exclude. Narrowed to the directory INSTANCE the origin lives in: - a root-only glob (`!/vendor`) is only lifted when the origin's FIRST segment is that directory — a deep same-named package was never pruned by it anyway; - `isCodeNavPathAllowed` post-filters every result, so an always-ignored name lifted for an origin under one `node_modules` no longer returns matches from a different one. A file that really lives under an excluded root still finds its own siblings. * fix(print): print the light half of the palette, so a dark-theme page prints on white paper The print stylesheet has always assumed white paper: it paints the ground white and the text near-black. A dark-palette page kept its own tokens under it, and #1560's diagram engine made that visible — Mermaid 12 draws node and edge labels as real HTML inside `<foreignObject>`, so the blanket `div, span, p { color: #1a1a1a !important }` repainted them near-black on a near-black node fill and the flowchart printed as empty boxes. - ThemeProvider renders the LIGHT half of the user's pair while printing. The class write happens inside the `beforeprint` handler, because a real print snapshot is taken before React would flush; the `print` media query drives the same switch for headless emulation and preview, where the async Mermaid re-render also lands. The stored preference is never touched, and a light-mode user sees no change. - print.css exempts diagram content from the typography rules (`:not([data-diagram-block] *)`): a diagram colours itself, and its own `<style>` has no `!important` to defend itself with. - usePrintMode shares the same subscription, so `.plannotator-print` is applied under print emulation too. * chore(guides-show): repin the viewer manifest after the print and diagram changes * docs: PATCH validation on external annotations, and the print light-half rule
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Workspaces diagram viewer becomes THE diagram engine in
@plannotator/ui(0.41.0)Owner rulings (Workspaces deviations log rows 2249 and 2251): "only the viewer, get it done" and "this needs to be the new engine, not an option; it can replace what we have." Brief:
research/handoffs/BRIEF-diagram-viewer-into-plannotator-ui-2026-09-17.md. Source copied (never imported) from Workspaces main at72e2a40e. One Opus review follows. Do not merge before it.MermaidBlockandGraphvizBlockkeep fence parsing,diagramLanguages.tsand the lazy-retry contract, and render through ONE renderer slot and ONE canvas. Their own viewBox math,applyViewand per-block zoom controls are deleted. The popout is the sameDiagramViewerat full size in thePopoutDialogchrome (theTablePopoutprecedent). A comment composed on a diagram part is anAnnotationon the document.File table
plannotator/diagram-render.tspackages/ui/utils/diagram-render.tsDiagramThemeis{ colorTheme, mode }; errors carryruntimeUnavailable; one automatic load re-attempt;sanitizeDiagramSvgsplit intoparseDiagramSvg+scrubDiagramSvg;widenEdgeHitAreaspackages/ui/utils/graphviz.tsutils/mermaid.ts;@viz-js/vizpinned exactly3.30.0(published 2026-09-01, past the 7-day gate; no exclude added)components/diagram/diagram-anchor.tspackages/core/diagram-anchor.ts(pure half) +packages/ui/utils/diagram-anchor.ts(DOM finders, re-exports core)html-anchor, because the external-annotation validator and the feedback archive (both vendored to Pi) run the parserdiagram-anchor-graphviz.tspackages/ui/utils/diagram-anchor-graphviz.tsg.node > title, nevernodeNdiagram-projection.tspackages/ui/utils/diagram-projection.tsuseDiagramViewport.ts,useDiagramRender.tspackages/ui/components/diagram/retryTokenDiagramCanvas.tsx,DiagramOverlay.tsx,DiagramComposer.tsx,DiagramViewer.tsx,DiagramSourcePane.tsxpackages/ui/components/diagram/components/diagram/useDiagramComments.ts,useDiagramSourceDraft.ts,DiagramPopout.tsx,index.ts;components/DiagramBlock.tsxuseDiagramAnnotations/useDiagramDraftdid, the popout, the barrel, the fence sidetest/fixtures/diagrams/*packages/ui/test-setup/fixtures/diagrams/test-setup)packages/core/diagram-anchor.test.ts,packages/ui/utils/diagramAnchor.test.ts,diagramAnchorGraphviz.test.ts(real viz engine),components/diagram/DiagramViewer.test.tsx,components/DiagramBlock.anchor.test.tsx, plus export, archive, POST (Bun + Pi), share-pin and highlighter-skip casesNot copied, per the brief:
useDiagramDraft.ts,useDiagramAnnotations.ts,DiagramDocumentBody.tsx,DiagramFenceOverlay.tsx,diagram-slice.ts,diagram-source.ts,diagram-kind.tsand the icons. Deleted here:components/mermaidSvg.ts.The adapter
DiagramViewer:kind,source,theme,comments: DiagramComment[](id, anchor, text, author, resolved),onCreateComment(anchor, text, additionalTargets), optionalonSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>(noonSave: no Source pane), optionalreadOnlySource. Additive optionals a host needs for parity with what Workspaces' viewer took:sourceOpen,selectedCommentId/onSelectComment,onUnanchoredChange,onDismiss,renderId,sourceLineOffset,maxAdditionalTargets(default 0),commentingDisabledReason,retryToken,onRenderState,renderFallback,autoFocus. Everything is exported (@plannotator/ui/components/diagram,utils/diagram-render,utils/graphviz,utils/diagram-anchor*,utils/diagram-projection,@plannotator/core/diagram-anchor);smoke:packagepasses.Anchors
One optional
diagramAnchor?: DiagramAnchoronAnnotation(thehtmlAnchorprecedent):useAnnotationHighlighterskips rows carrying it;exportAnnotationsprintsDiagram node <label> (<nodeId>), line <n>; the feedback archive records the validated anchor;POST /api/external-annotationsaccepts it on plan comments in both runtimes (400 when malformed); share links drop it (sharing.multiTarget.test.tsgained the diagram case). In plan review and annotate the comment carries the fence'sblockIdand DOCUMENT lines, lists in the rail, exports, drafts, and restores after reload throughfindDiagramTarget(id, label, line, unanchored), with the existing "Unanchored" chip for the last case viaViewer.onRestoreReport.Interaction
Click-to-select, drag-to-pan: a press that travels under the drag threshold is a click and opens the composer there (a slightly moving click still selects); one that travels further is a pan and never opens it. The threshold is pointer-type aware: 4 px for a mouse or pen, 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 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, and on window blur.Widened edge hit area, and what was actually covering the edges. The owner could only catch Mermaid edges at odd spots while Graphviz edges were fine. Measured in headless Chromium (
elementsFromPointat 20 points along every edge of a nested-subgraph flowchart, a state, a class and an ER diagram, then real clicks at the same points): the suspected cause, nestedg.rootgroups and cluster rects, is NOT it — Mermaid 12 + ELK emits oneg.rooteven with nested subgraphs, and clusters are painted before the edge paths. The only thing over any edge is that edge's own label box (pinsideg.edgeLabel'sforeignObject), covering 5–40% of every labelled edge (worst 12 of 20 points reachable), centred on its midpoint, which is exactly where a person clicks an edge. Unlabelled edges were reachable at 20 of 20 points, and Graphviz labels are svg<text>inside theg.edgegroup, which is why it felt fine. The fix is structural, so it also holds for a renderer that does nest: every edge gets a bare hit path (geometry only,stroke: transparent,fill: none,stroke-width: 14set!important,pointer-events: stroke, noid/class/data-*/ markers / dash pattern) in ONE layer appended last in the svg root, itstransformcomposed from the ancestors' transforms, mapped back to its edge bydiagramHitSource. Because that layer sits over the nodes, the canvas resolves a click by priority over everything under the pointer (elementsFromPoint: a node, then an edge, then a cluster), never byevent.target. An edge label is itself a target and resolves to its edge. After the fix every edge in all four families is reachable at 20 of 20 points. Covered: Mermaidg.edgePaths path,path.flowchart-link,path.transition,path.relation,path.relationshipLine, sequence.messageLine0/1; Graphvizg.edge path.Sequence diagrams are addressable. Mermaid gives sequence parts classes, not ids, so the codec gains a
sequencefamily (additive withinv: 1): actors are nodes whose id is the actor'sname(top and bottom boxes are one target), messages are edgesmsg-<n>(the message text is clickable for its message;from/tofrom the line'sdata-from/data-to), notes are nodesnote-<n>, loop / alt / opt frames are clustersframe-<n>. Because an ordinal moves when a statement is inserted above it, restore checks the label too (the ordinal when its label matches, else the one part with that label, else the ordinal).A click never does nothing. A click that resolves no part opens the composer on the WHOLE diagram: additive kind
diagram, no id, label = the diagram's first source line,sourceLine= the fence's full range; export lineDiagram (<family>), lines a–b(the export's existing en-dash range style); ring on the content bounds, badge top-left. This covers gitGraph, pie and anything future.Badges and rings for existing comments are unchanged. DOM tests: a click on an edge's hit path and on its label resolve that edge; where a hit path and a node are both under the pointer the node wins; a 20 px drag does not open the composer and a 2 px one does; an 8 px finger wobble is still a tap. Chromium tests (the parity driver): the midpoint of EVERY edge of the flowchart, state, class and ER diagrams opens the composer on that edge (8/8, 5/5, 3/3, 3/3); at a point where a hit path is genuinely topmost over a node (
path[hit] > polygon > rect > svg) the composer opens on the node; a sequence comment made by clicking a message's TEXT survives a reload onto the same message; a click on empty canvas opens the whole-diagram composer.Sanitizer delta
mermaidSvg.ts(normalizeMermaidSvgMarkup) did no sanitizing: it bakedmax-width: none,preserveAspectRatioandheight="100%"into the root tag for the old innerHTML mount, and the old Graphviz path was a regex recolor. So there was nothing of ours to merge in the security direction;sanitizeDiagramSvgis a strict superset. The one case ours covered that theirs did not: a root sized only bywidth="206pt" height="188pt"with noviewBox(ourparseViewBoxFromMarkupacceptedpt). That is merged intosvgContentSize, which now acceptspt/pxlengths as its fallback. Consolidated per the brief: Graphviz default strokes move fromvar(--muted-foreground)tovar(--foreground)(the movedthemeGraphvizSvg), a visible change; cluster outlines usevar(--border), which is faint inone-light(see the light dot-fence pair on the parity page) and is a one-token tune if the owner wants it stronger.Structural note: happy-dom cannot host DOMPurify (foreign-realm fragment, mislabelled svg namespaces, a cached
Node.prototype.nodeNamegetter), so DOM tests swap only the PARSE step for an inert XML parse through__setDiagramSvgParserForTests; the scrub runs on every test render. The real DOMPurify parse is proven in headless Chromium (on the parity page): hostile svg in, no script /on*/javascript:/data:/ anchor target /<use>out,foreignObjectlabels kept, the captured fixture whole (95 of 95 elements), nothing executed.Test-harness changes to pinned files, assertions untouched:
DiagramBlock.lazyRetry.test.tsxandMermaidBlock.theme.test.tsxinstall that parser, and the Graphviz stand-in answersrender()(the slot drives the engine throughrender, a value with status and errors, notrenderString).MermaidBlock.test.tskeeps thesecurityLevel: 'strict'pin and loses the tests of the deleted normalizer.Sizes (single-file builds, bytes)
2a51b26eapps/hook/dist/index.htmlapps/hook/dist/review.htmlNo new dependency:
@viz-js/viz, DOMPurify, CodeMirror and lucide were already in. The review bundle carries no engine (noflowchart-v2, nodata-diagram-canvas); its delta is stylesheet classes.build:opencodeandbuild:portalpass; on the portalmermaid.core(640 KB) andviz(1,350 KB) stay lazy chunks. guides.show: the viewer script is byte-identical (viewer.KTNT-M2b.js), so the diagram engine is NOT in the portable bundle (the guide chain renders no fences). The manifest is repinned for the stylesheet only (349,765 → 352,239 bytes): the viewer's Tailwind scan coverspackages/ui/componentsand picks up the new components' classes (verified:cursor-grabbing,md:order-firstetc. are in the new file and not the old).Parity proof
Local sign-off page (never an artifact):
/private/tmp/claude-501/-Users-ramos-plannotator-plannotator/34d56d5a-33a8-4878-92b2-3f4318214ae2/scratchpad/diagram-engine/index.html. Both builds served bybun apps/hook/server/index.ts annotate fixture.mdfrom their own trees, headless Chromium (Playwright 1.63), theme pinned through an isolatedPLANNOTATOR_DATA_DIRconfig.json AND cookies withdocumentElement.classNameasserted before every shot. 55 of 55 checks pass (42 parity + 13 owner-findings checks; the page also carries the edge-coverage tables before and after the fix and the Chromium sanitizer proof): each of the three diagrams (flowchart, sequence, dot) before/after inplannotatordark andone-light; the pending state; the popout; keyboard zoom in, arrow-key pan, fit (fit returns exactly to the arrival transform); no hover surface on a plain mouse-over; the edge click 6 px off the stroke; a comment on node D with its ring and badge, listed in the rail; the export containingDiagram node Approve? (D), line 9; reload offering the recovered draft and restoring the comment onto the same node (ring center inside the node's box, not unanchored); a text annotation on the prose still working beside it; a forced failed engine load showing the error panel with the source and Retry, and Retry recovering, on both builds.Verification
bun run typecheck: clean (all nine projects, Pi vendored).bun test packages/ui/utils packages/core packages/shared packages/server: 2884 tests, 0 fail (95 pre-existing skips).DOM_TESTS=1 bun test --isolate packages/ui/components packages/ui/hooks packages/editor: 1001 pass, 0 fail.bun test apps/pi-extension: 281 pass, 0 fail.bun test scripts/dom-test-allowlist.test.ts: pass (five new DOM files added to.github/workflows/test.yml);tests/entry-assets.test.ts: 26 pass against the final bundles (the viz lazy-import pin now points atutils/graphviz.ts).bun run --cwd packages/ui smoke:package: packs 0.41.0 and installs externally with core 0.25.4.bun run --cwd apps/guides-show check:manifest: passes after the stylesheet repin.Publish order
@plannotator/ui0.41.0 pins@plannotator/core0.25.4exactly and imports@plannotator/core/diagram-anchor, which 0.25.3 does not export. Publish core 0.25.4 first, then ui 0.41.0, by hand frommainafter merge. Then Workspaces re-pins, deletes its twelve copies and keeps its six host files; the.mmd/.dotrenderAsPR follows this one.Review fixes (Opus review of this PR)
Blockers:
touch-none, so a phone reader could not scroll past a diagram. It is nowtouch-action: pan-y; only the popout passestouch-none(canvasClassName). DOM tests pin both.data-print-hide(print.cssalready has the rule), as do the block's hover controls; rings and badges still print, they are the comments.exportLinkedDocAnnotationsandexportAnnotationEntrydropped the location line. Both print it now; tests for both, plus the whole-diagram form.diagramAnchor, and the diagram block and viewer root areannotation-exclude, so text restore can never wrap a<mark>inside a diagram svg. Tests for both.Nits, all fixed:
data-*(or id, class, markers, style): they are built bare rather than cloned, so[data-id="L_A_B_0"]matches what it matched before (pinned).<style>is scoped, not dropped.scopeDiagramCsskeeps only rules whose every selector starts at the svg's own root id, plus@keyframes;@import, any fetching function (url(that is not a#fragment,image-set(, …, CSS escapes decoded first), unscoped rules and@font-facego; grouping rules are filtered recursively. Proven with the reviewer's payload in Chromium: the outside#victimkeeps its color, no request reaches the hostile host, the scoped rule still applies. Mermaid's own styles survive: every rule of six captured families is kept (unit), and in Chromium the computed node, label and edge styles equal main's on three palettes (plannotatordark,one-light,draculadark) with identical rule counts (59→59, 55→55). That is an equality check against main rather than a re-run of the 78-combination contrast sweep, whose harness is not in this repo.blockIdnames no diagram block ("external", or a deleted fence) is resolved by anchor against every diagram block throughDiagramAnchorClaims: the first block in document order whose finder resolves it shows it; when every block has answered and none did it is reported Unanchored (a document with no diagram:Viewerreports it). The earlier "Known limits" text said the opposite of what the code did; corrected below.Mod+0,Mod+-,Alt+Arrowpass through to the browser.pointerType === 'touch'.|deltaY| < 0.1guard is back.Known limits
maxAdditionalTargets0 (one part per comment, sinceAnnotationgains ONE field per the brief) and noonSave(no Source pane; the in-block Show-source toggle remains).text.messageTextelements the counts differ and the text stops resolving (the line still does, with an empty label).