From 21acb62568eaded7fec90e2e243edb8ba88b25fe Mon Sep 17 00:00:00 2001 From: FND Date: Wed, 16 Sep 2026 14:35:31 +0200 Subject: [PATCH] docs(skills): canonical zoomable diagram shell for visual-explainer Hand-rolled shells regress the #1546 caption overlap (abs-pos canvas escaping a static viewport's overflow). Add a copy-instead-of-invent reference with a five-rule clipping contract, wire it into the visual-explainer path and delivery gate, and pin the contract with tests. Refs #1546. --- .../plannotator-visual-explainer/SKILL.md | 5 +- .../SKILL.test.ts | 36 ++++++ .../references/diagram-shell.md | 120 ++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 apps/skills/extra/plannotator-visual-explainer/references/diagram-shell.md diff --git a/apps/skills/extra/plannotator-visual-explainer/SKILL.md b/apps/skills/extra/plannotator-visual-explainer/SKILL.md index 01ad73f6b..d85ea5aef 100644 --- a/apps/skills/extra/plannotator-visual-explainer/SKILL.md +++ b/apps/skills/extra/plannotator-visual-explainer/SKILL.md @@ -30,6 +30,8 @@ empty SVG, or error output such as `aria-roledescription="error"` or `Syntax err means the explainer is not deliverable. Fix the diagram or theme configuration and rerun both palettes until every SVG passes. +For zoomable diagram shells, additionally zoom to the maximum and pan to all extremes in both palettes before delivering: the figure caption must stay fully legible throughout (see `references/diagram-shell.md`). + **Plans/proposals** (user should approve/deny): ```bash plannotator annotate --gate @@ -104,8 +106,9 @@ For architecture diagrams, data tables, slide decks, project recaps, comparisons 2. Read visual-explainer's `SKILL.md` (workflow, diagram types, anti-slop rules) 3. Read the relevant visual-explainer references and templates for your content type 4. Read `references/theme-override.md` — Plannotator tokens replacing Nico's palettes +5. For zoomable Mermaid diagrams with controls and a caption: read `references/diagram-shell.md` and copy its shell — do not hand-roll viewport, canvas, or caption markup -Follow visual-explainer's structure, component classes (`.ve-card`, `.kpi-card`, `.pipeline`), and anti-slop rules. The only override is the color/typography layer — Plannotator tokens instead of Nico's custom palettes. +Follow visual-explainer's structure, component classes (`.ve-card`, `.kpi-card`, `.pipeline`), and anti-slop rules. Overrides are the color/typography layer — Plannotator tokens instead of Nico's custom palettes — plus the zoomable diagram shell in `references/diagram-shell.md` when the deliverable has one. --- diff --git a/apps/skills/extra/plannotator-visual-explainer/SKILL.test.ts b/apps/skills/extra/plannotator-visual-explainer/SKILL.test.ts index 798843a72..9943a4d2f 100644 --- a/apps/skills/extra/plannotator-visual-explainer/SKILL.test.ts +++ b/apps/skills/extra/plannotator-visual-explainer/SKILL.test.ts @@ -195,3 +195,39 @@ describe("plannotator-visual-explainer Mermaid theming", () => { expect(skill).toContain("the explainer is not deliverable"); }); }); + +const diagramShell = readFileSync( + join(import.meta.dir, "references/diagram-shell.md"), + "utf-8", +); + +describe("plannotator-visual-explainer diagram shell", () => { + test("visual-explainer path points at the shell reference", () => { + // Failure caught: the shell reference rotting — the path stops telling the + // agent to read it, and hand-rolled shells regress the caption overlap. + expect(skill).toContain("references/diagram-shell.md"); + }); + + test("shell reference keeps its sections", () => { + for (const heading of ["## Clipping contract", "## Skeleton", "## Self-check"]) { + expect(diagramShell).toContain(heading); + } + }); + + test("viewport rule pins the positioned clip container", () => { + // Deliberate contract pin (#1546): an absolutely-positioned canvas escapes + // a static viewport's overflow, painting the zoomed diagram over the + // caption. The reference viewport rule must keep both declarations. + const rule = diagramShell.match(/\.mermaid-viewport\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(rule).toMatch(/position:\s*relative/); + expect(rule).toMatch(/overflow:\s*hidden/); + }); + + test("canvas stays absolutely positioned", () => { + // Failure caught: the canvas losing absolute positioning, which would put + // the zoomed SVG back in flow and push the caption down the page instead + // of panning inside the viewport. + const rule = diagramShell.match(/\.mermaid-canvas\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(rule).toMatch(/position:\s*absolute/); + }); +}); diff --git a/apps/skills/extra/plannotator-visual-explainer/references/diagram-shell.md b/apps/skills/extra/plannotator-visual-explainer/references/diagram-shell.md new file mode 100644 index 000000000..ff258d104 --- /dev/null +++ b/apps/skills/extra/plannotator-visual-explainer/references/diagram-shell.md @@ -0,0 +1,120 @@ +# Zoomable diagram shell + +For the visual-explainer path only: standalone deliverables that render a zoomable Mermaid +diagram with `- / + / Reset / Expand` controls, a zoom label, and a `Figure N` caption below +the viewport. Plans and PR explainers render inside Plannotator's own diagram blocks and do +not need this. + +Copy the skeleton below; do not hand-roll a new shell. #1546 was a hand-rolled shell whose +zoomed diagram painted over its own caption. + +## Clipping contract (hard rules) + +1. The viewport is the positioned clip container: `position: relative`, + `overflow: hidden`, and a fixed `height`. Both declarations are load-bearing. +2. The canvas is `position: absolute; top: 0; left: 0` as a direct child of the + viewport — never a sibling of it, never anywhere else. +3. Zoom resizes the SVG in pixels and translates the canvas; pan translates the + canvas. The shell's layout height never changes with zoom (only the Expand + toggle changes the viewport height, and it re-fits afterwards). +4. The `figcaption` sits in normal flow directly below the wrap. Controls and + the zoom label are absolutely positioned inside the wrap, above the canvas. +5. Why rule 1 exists: an absolutely-positioned canvas is clipped only by + ancestors up to and including its containing block. With a static viewport + the canvas positions against the wrap, the viewport's `overflow: hidden` + never applies, and the zoomed diagram paints over the caption. Positioning + the viewport makes it the containing block, so the clip holds at any zoom. + +## Skeleton + +```html +
+
+
+ + + + +
+
+
+
flowchart TD
+  A --> B
+
+
+ 100% — contain +
+
Figure 1 — Title. Caption text.
+
+``` + +```css +.diagram-shell { + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card); + overflow: hidden; + margin: 18px 0; +} +.mermaid-wrap { + position: relative; +} +.mermaid-viewport { + position: relative; + height: 460px; + overflow: hidden; + cursor: grab; + touch-action: none; +} +.mermaid-canvas { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + will-change: transform; +} +.mermaid-canvas svg { + display: block; + max-width: none; +} +.zoom-controls { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 6px; + z-index: 5; +} +.zoom-label { + position: absolute; + bottom: 10px; + left: 12px; + z-index: 5; +} +figcaption { + padding: 12px 18px; + border-top: 1px solid var(--border); + font-size: 0.82rem; + color: var(--muted-foreground); +} +.diagram-source { + display: none; +} +``` + +Zoom/pan behavior: render the Mermaid SVG into the canvas, then `fit()` scales it +to the viewport with padding and centers it. Zoom buttons, wheel, and pinch call +`zoomAround(factor, cx, cy)` toward the pointer; drag pans; all three set a +`custom` mode and re-apply `svg.style.width/height` plus +`canvas.style.transform = translate(panX, panY)`, with pan constrained so the +diagram always covers the viewport (center it when it is smaller). Reset re-fits; +Expand toggles the viewport height between the default and expanded values and +re-fits. Never grow layout height with zoom, and never let the canvas escape the +viewport element. + +## Self-check (delivery gate) + +Before opening the annotation UI, in both palettes: zoom to the maximum, pan to +all four extremes, and toggle Expand. The figure caption must stay fully legible +throughout — no diagram node or edge may paint over caption text — and nothing +may paint past the shell's rounded border.