Skills に戻る
backnotprop/plannotator実行前に内容を確認

SKILL DETAIL

plannotator-visual-explainer

backnotprop/plannotator/plannotator-visual-explainer

Generate self-contained HTML visualizations with Plannotator theming. Use for implementation plans, PR explainers, architecture diagrams, data tables, slide decks, and any visual explanation of technical concepts. Plans and PR explainers follow Plannotator's prescriptive approach; all other visual content delegates to nicobailon/visual-explainer.

インストール · 146出典を見る

Installation

npx skills add https://github.com/backnotprop/plannotator --skill plannotator-visual-explainer

スキルファイル

SKILL.md

最終同期 · 2026/09/18

references/design-system.md
# Design System Reference

Plan documents use Plannotator's semantic theme tokens. This makes them theme-aware: standalone files render with bundled defaults; embedded in the Plannotator UI, they inherit whatever theme is active (30+ themes, light and dark variants).

## Standalone defaults

Include this `:root` block so the plan works when opened directly in a browser. These are the Plannotator light theme values — they get overridden when embedded.

```css
:root {
  --background: oklch(0.97 0.005 260);
  --foreground: oklch(0.18 0.02 260);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.18 0.02 260);
  --primary: oklch(0.50 0.25 280);
  --primary-foreground: oklch(1 0 0);
  --secondary: oklch(0.50 0.18 180);
  --secondary-foreground: oklch(1 0 0);
  --muted: oklch(0.92 0.01 260);
  --muted-foreground: oklch(0.40 0.02 260);
  --accent: oklch(0.60 0.22 50);
  --accent-foreground: oklch(0.18 0.02 260);
  --destructive: oklch(0.50 0.25 25);
  --destructive-foreground: oklch(1 0 0);
  --success: oklch(0.45 0.20 150);
  --success-foreground: oklch(1 0 0);
  --warning: oklch(0.55 0.18 85);
  --warning-foreground: oklch(0.18 0.02 260);
  --border: oklch(0.88 0.01 260);
  --input: oklch(0.92 0.01 260);
  --ring: oklch(0.50 0.25 280);
  --code-bg: oklch(0.92 0.01 260);

  --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
  --font-display: ui-serif, Georgia, 'Times New Roman', serif;
  --radius: 0.625rem;
}
```

`--font-display` is plan-specific — used for headings and titles to create visual contrast with the body. It's not part of the core Plannotator theme, so it won't be overridden when embedded (which is the desired behavior).

## Token usage map

| Role | Token | SVG equivalent |
|------|-------|----------------|
| Page background | `--background` | — |
| Primary text | `--foreground` | `fill` on text |
| Card / panel background | `--card` | `fill` on rects |
| Subdued text, labels, captions | `--muted-foreground` | `fill` on labels |
| Soft backgrounds, secondary fills | `--muted` | `fill` on secondary rects |
| Primary accent (CTA, attention, keywords) | `--primary` | `stroke` / `fill` on highlighted elements |
| Warm accent | `--accent` | — |
| Positive / success | `--success` | `stroke` / `fill` on success paths |
| Caution | `--warning` | `fill` on warning badges |
| Error / destructive | `--destructive` | `stroke` / `fill` on error paths |
| Borders, dividers | `--border` | `stroke` on box outlines |
| Arrow strokes, diagram lines | `--muted-foreground` | `stroke` on connectors |
| Code block background | `--code-bg` | — |
| Body font | `--font-sans` | `font-family` on SVG body text |
| Code / labels font | `--font-mono` | `font-family` on SVG labels |
| Display / heading font | `--font-display` | `font-family` on SVG titles |

## Base styles

```css
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: var(--font-sans);
  background: var(--background);
  color: var(--foreground);
  line-height: 1.65;
  font-size: 15px;
  -webkit-font-smoothing: antialiased;
}

.container {
  max-width: 1080px;
  margin: 0 auto;
  padding: 64px 24px;
}
```

## Typography

**Headings** — `var(--font-display)`, weight 500, `var(--foreground)`.
- H1: `2rem` page title
- H2: `1.4rem` section headers
- H3: `1.15rem` subsection headers

**Body** — `var(--font-sans)`, weight 400, `0.95rem`, line-height `1.65`. Max paragraph width `65ch`.

**Labels & metadata** — `var(--font-mono)`, `0.7–0.8rem`, weight 500, uppercase, letter-spacing `0.06em`, `var(--muted-foreground)`.

**Code** — `var(--font-mono)`, `0.85rem`, line-height `1.55`.

## Component patterns

### Page header

```html
<header>
  <span class="eyebrow">Implementation plan · Project name</span>
  <h1>Plan Title Goes Here</h1>
  <div class="prompt-box">
    <span class="prompt-label">Brief</span>
    <p>The original task or problem statement that motivated this plan.</p>
  </div>
</header>
```

```css
.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted-foreground);
}

header h1 {
  font-family: var(--font-display);
  font-size: 2rem;
  font-weight: 500;
  margin: 8px 0 24px;
  line-height: 1.2;
}

.prompt-box {
  background: var(--muted);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: 16px 24px;
}

.prompt-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted-foreground);
  display: block;
  margin-bottom: 4px;
}

.prompt-box p {
  font-size: 0.92rem;
  color: var(--muted-foreground);
  line-height: 1.55;
}
```

### Summary strip (stat cards)

```html
<div class="summary-strip">
  <div class="stat-card">
    <span class="stat-value">4</span>
    <span class="stat-label">Components</span>
  </div>
  <!-- more cards -->
</div>
```

```css
.summary-strip {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 16px;
  margin: 32px 0;
}

.stat-card {
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: 16px 24px;
  text-align: center;
  background: var(--card);
}

.stat-value {
  font-family: var(--font-display);
  font-size: 1.8rem;
  font-weight: 500;
  display: block;
  color: var(--foreground);
}

.stat-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted-foreground);
  margin-top: 4px;
  display: block;
}
```

### Section header

```html
<section>
  <div class="section-header">
    <span class="section-number">01</span>
    <h2>Solution Overview</h2>
  </div>
  <!-- content -->
</section>
```

```css
section { margin-top: 64px; }

.section-header {
  display: flex;
  align-items: baseline;
  gap: 16px;
  margin-bottom: 24px;
  padding-bottom: 8px;
  border-bottom: 1.5px solid var(--border);
}

.section-number {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--primary);
}

.section-header h2 {
  font-family: var(--font-display);
  font-size: 1.4rem;
  font-weight: 500;
}
```

### Code block (dark theme)

```html
<div class="code-panel">
  <span class="code-label">src/api/handler.ts</span>
  <pre><code><span class="kw">interface</span> <span class="fn">PlanRequest</span> {
  <span class="fn">title</span>: <span class="kw">string</span>;
  <span class="fn">sections</span>: <span class="fn">Section</span>[];
}</code></pre>
</div>
```

```css
.code-panel {
  background: var(--code-bg);
  border-radius: var(--radius);
  padding: 24px;
  overflow-x: auto;
  margin: 16px 0;
  border: 1.5px solid var(--border);
}

.code-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--muted-foreground);
  display: block;
  margin-bottom: 8px;
}

.code-panel pre {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.85rem;
  line-height: 1.55;
  color: var(--foreground);
}

/* Syntax tokens — these use semantic roles, not fixed colors */
.code-panel .kw  { color: var(--primary); }         /* keywords */
.code-panel .fn  { color: var(--accent); }           /* identifiers, types */
.code-panel .str { color: var(--success); }          /* strings */
.code-panel .cm  { color: var(--muted-foreground); font-style: italic; } /* comments */
.code-panel .num { color: var(--warning); }          /* numbers */
```

### Risk table

```html
<div class="risk-grid">
  <div class="risk-row">
    <div class="risk-name">Database migration on large table</div>
    <div><span class="badge high">HIGH</span></div>
    <div class="risk-mitigation">Run during off-peak with online DDL</div>
  </div>
  <!-- more rows -->
</div>
```

```css
.risk-grid {
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.risk-row {
  display: grid;
  grid-template-columns: 1fr auto 1.5fr;
  gap: 24px;
  padding: 16px 24px;
  align-items: center;
  border-bottom: 1px solid var(--border);
}

.risk-row:last-child { border-bottom: none; }
.risk-name { font-weight: 500; }
.risk-mitigation { font-size: 0.9rem; color: var(--muted-foreground); }

.badge {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: calc(var(--radius) - 4px);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.badge.high {
  background: color-mix(in oklab, var(--destructive) 15%, transparent);
  color: var(--destructive);
}
.badge.med {
  background: color-mix(in oklab, var(--warning) 15%, transparent);
  color: var(--warning);
}
.badge.low {
  background: color-mix(in oklab, var(--success) 15%, transparent);
  color: var(--success);
}
```

### Callout / open question

```html
<div class="callout">
  <h3>Should we use WebSockets or SSE?</h3>
  <p>SSE is simpler but unidirectional. WebSockets add infrastructure complexity.</p>
  <span class="decide-with">Decide with: infrastructure team</span>
</div>
```

```css
.callout {
  border-left: 3px solid var(--primary);
  padding: 16px 24px;
  margin: 16px 0;
  background: var(--card);
  border-radius: 0 var(--radius) var(--radius) 0;
}

.callout h3 {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 500;
  margin-bottom: 4px;
}

.callout p {
  font-size: 0.9rem;
  color: var(--muted-foreground);
  line-height: 1.55;
}

.decide-with {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--primary);
  font-weight: 500;
  display: block;
  margin-top: 8px;
}
```

### Tag chips

```html
<div class="tags">
  <span class="tag">packages/server</span>
  <span class="tag highlight">new endpoint</span>
</div>
```

```css
.tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }

.tag {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  padding: 2px 8px;
  border-radius: calc(var(--radius) - 4px);
  background: var(--muted);
  color: var(--muted-foreground);
}

.tag.highlight {
  background: color-mix(in oklab, var(--primary) 12%, transparent);
  color: var(--primary);
}
```

### Diagram panel

Wraps SVG diagrams in a bordered container:

```html
<div class="diagram-panel">
  <svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" style="width:100%">
    <!-- diagram content -->
  </svg>
  <span class="diagram-caption">Request flow through the API gateway</span>
</div>
```

```css
.diagram-panel {
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: 24px;
  margin: 24px 0;
  background: var(--card);
}

.diagram-caption {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--muted-foreground);
  display: block;
  margin-top: 8px;
  text-align: center;
}
```

### Two-column grid

```css
.two-col {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px;
}

@media (max-width: 720px) {
  .two-col { grid-template-columns: 1fr; }
}
```

### Collapsible details

```html
<details>
  <summary>Implementation details</summary>
  <div class="details-body"><!-- content --></div>
</details>
```

```css
details {
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  margin: 16px 0;
}

summary {
  font-family: var(--font-sans);
  font-weight: 500;
  padding: 16px 24px;
  cursor: pointer;
  list-style: none;
}

summary::before {
  content: '▸';
  display: inline-block;
  margin-right: 8px;
  transition: transform 0.2s;
}

details[open] summary::before { transform: rotate(90deg); }

.details-body { padding: 0 24px 24px; }
```

### Milestone timeline

Vertical timeline showing phases without time estimates.

```html
<div class="milestones">
  <div class="milestone">
    <div class="when">Phase 1</div>
    <div class="dot-col"><span class="dot done"></span><span class="line"></span></div>
    <div class="body">
      <h3>Foundation</h3>
      <p>Set up core infrastructure and initial integrations.</p>
      <div class="tags"><span class="tag">packages/server</span></div>
    </div>
  </div>
</div>
```

```css
.milestones { display: flex; flex-direction: column; gap: 0; }

.milestone {
  display: grid;
  grid-template-columns: 120px 28px 1fr;
  gap: 0 18px;
}

.milestone .when {
  text-align: right;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--muted-foreground);
  padding-top: 4px;
}

.milestone .dot-col { display: flex; flex-direction: column; align-items: center; }

.milestone .dot {
  width: 14px; height: 14px; border-radius: 50%;
  background: var(--card);
  border: 3px solid var(--primary);
  flex-shrink: 0;
}

.milestone .dot.done { background: var(--success); border-color: var(--success); }

.milestone .line { width: 2px; flex: 1; background: var(--border); margin: 4px 0; }
.milestone:last-child .line { display: none; }

.milestone .body { padding-bottom: 36px; }

.milestone .body h3 {
  font-family: var(--font-display);
  font-size: 1.15rem;
  font-weight: 500;
  margin-bottom: 4px;
}

.milestone .body p {
  font-size: 0.88rem;
  color: var(--muted-foreground);
  max-width: 620px;
  margin-bottom: 10px;
}
```
references/diagram-shell.md
# 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
<figure class="diagram-shell">
  <div class="mermaid-wrap">
    <div class="zoom-controls">
      <button data-z="out" aria-label="Zoom out">−</button>
      <button data-z="in" aria-label="Zoom in">+</button>
      <button data-z="reset" aria-label="Reset zoom">Reset</button>
      <button data-z="expand" aria-label="Toggle expanded height">Expand</button>
    </div>
    <div class="mermaid-viewport">
      <div class="mermaid-canvas">
        <pre class="diagram-source">flowchart TD
  A --> B</pre>
      </div>
    </div>
    <span class="zoom-label">100% — contain</span>
  </div>
  <figcaption><b>Figure 1 — Title.</b> Caption text.</figcaption>
</figure>
```

```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.
references/pr-components.md
# PR Component Patterns

Component patterns specific to PR explainer documents. For the base design system (colors, typography, layout), see `../../plannotator-visual-plan/references/design-system.md`.

## Table of Contents
1. [PR Header](#pr-header)
2. [TL;DR Box](#tldr-box)
3. [Diff Rendering](#diff-rendering)
4. [Review Comments](#review-comments)
5. [Risk Map](#risk-map)
6. [File Cards](#file-cards)
7. [Before / After](#before--after)
8. [Where to Focus](#where-to-focus)
9. [Test Plan](#test-plan)
10. [File Badges](#file-badges)

## PR header

```html
<header>
  <span class="eyebrow">Pull request · repo-name</span>
  <h1>Add real-time notification system</h1>
  <div class="pr-meta">
    <span>6 files</span>
    <span class="additions">+142</span>
    <span class="deletions">-38</span>
    <span>feature/notifications → main</span>
  </div>
</header>
```

```css
.pr-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--muted-foreground);
  margin-top: 8px;
}

.pr-meta .additions { color: var(--success); }
.pr-meta .deletions { color: var(--destructive); }
```

## TL;DR box

```html
<div class="tldr">
  <h3>TL;DR</h3>
  <p>Adds WebSocket-based notifications with per-user channels. Messages fan out
     from a new NotificationService through Redis pub/sub. Existing REST endpoints
     are unchanged.</p>
</div>
```

```css
.tldr {
  background: var(--card);
  border: 1.5px solid var(--border);
  border-left: 4px solid var(--primary);
  border-radius: var(--radius);
  padding: 20px 24px;
  max-width: 760px;
  margin: 24px 0;
}

.tldr h3 {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--primary);
  margin-bottom: 8px;
}

.tldr p {
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--muted-foreground);
}
```

## Diff rendering

Use Pierre diffs via CDN for syntax-highlighted, theme-aware diff rendering. Renders into shadow DOM (no style conflicts) with Shiki syntax highlighting.

```html
<script type="module">
  import { getSingularPatch, registerDiffsComponent } from 'https://cdn.jsdelivr.net/npm/@pierre/[email protected]/+esm';
  registerDiffsComponent();

  const patch = `--- a/src/handler.ts
+++ b/src/handler.ts
@@ -1,4 +1,6 @@
 import { Router } from 'express';
+import { NotificationService } from './notifications';
-import { legacyPoll } from './polling';`;

  const container = document.querySelector('diffs-container');
  container.fileDiff = getSingularPatch(patch);
  container.options = {
    themeType: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
    diffStyle: 'unified',
    diffIndicators: 'bars',
    lineDiffType: 'word-alt',
    unsafeCSS: `
      :host {
        --diffs-bg: var(--background);
        --diffs-fg: var(--foreground);
        border-radius: var(--radius);
        border: 1.5px solid var(--border);
        overflow: hidden;
      }
    `,
  };
</script>
<diffs-container></diffs-container>
```

For multiple diffs, create one `<diffs-container>` per file. Pierre handles syntax highlighting, line numbers, add/del coloring, and word-level diffs automatically.

## Review comments

Speech bubbles with severity-coded left borders, attached below a diff block.

```html
<div class="comments">
  <div class="bubble blocking">
    <span class="anchor">line 11</span>
    <span class="severity">BLOCKING</span>
    <p>This mutation isn't wrapped in a transaction. If the second write
       fails, the first persists — leaving the user in a broken state.</p>
  </div>
  <div class="bubble nit">
    <span class="anchor">line 24</span>
    <span class="severity">NIT</span>
    <p>Prefer <code>const</code> here since <code>config</code> is never reassigned.</p>
  </div>
</div>
```

```css
.comments {
  padding: 18px 20px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  background: var(--muted);
  border-top: 1px solid var(--border);
}

.bubble {
  position: relative;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-left-width: 4px;
  border-radius: 8px;
  padding: 12px 14px 12px 16px;
  max-width: 680px;
}

.bubble.blocking { border-left-color: var(--primary); }
.bubble.nit { border-left-color: var(--border); }
.bubble.suggestion { border-left-color: var(--success); }

.bubble::before {
  content: "";
  position: absolute;
  left: -9px;
  top: 16px;
  width: 12px;
  height: 12px;
  background: var(--card);
  border-left: 1.5px solid var(--border);
  border-bottom: 1.5px solid var(--border);
  transform: rotate(45deg);
}

.bubble.blocking::before {
  border-left-color: var(--primary);
  border-bottom-color: var(--primary);
}

.anchor {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--muted-foreground);
}

.severity {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-left: 8px;
}

.bubble.blocking .severity { color: var(--primary); }
.bubble.nit .severity { color: var(--muted-foreground); }
.bubble.suggestion .severity { color: var(--success); }

.bubble p {
  margin-top: 6px;
  font-size: 0.88rem;
  line-height: 1.55;
  color: var(--foreground);
}

.bubble code {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  background: var(--muted);
  padding: 1px 5px;
  border-radius: 3px;
}
```

## Risk map

Chips that give a quick overview of file risk levels.

```html
<div class="risk-map">
  <a href="#file-auth" class="chip attention">
    <span class="dot"></span>
    src/auth/middleware.ts
  </a>
  <a href="#file-db" class="chip medium">
    <span class="dot"></span>
    src/db/migrations/004.sql
  </a>
  <a href="#file-types" class="chip safe">
    <span class="dot"></span>
    src/types/index.ts
  </a>
</div>
```

```css
.risk-map {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 24px 0;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  padding: 6px 12px;
  border: 1.5px solid var(--border);
  border-radius: 20px;
  text-decoration: none;
  color: var(--foreground);
  transition: box-shadow 0.15s;
}

.chip:hover {
  box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 25%, transparent);
}

.chip .dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
}

.chip.attention {
  background: color-mix(in oklab, var(--destructive) 8%, transparent);
  border-color: color-mix(in oklab, var(--destructive) 40%, transparent);
}
.chip.attention .dot { background: var(--destructive); }

.chip.medium {
  background: color-mix(in oklab, var(--warning) 10%, transparent);
  border-color: color-mix(in oklab, var(--warning) 30%, transparent);
}
.chip.medium .dot { background: var(--warning); }

.chip.safe {
  background: color-mix(in oklab, var(--success) 8%, transparent);
  border-color: color-mix(in oklab, var(--success) 35%, transparent);
}
.chip.safe .dot { background: var(--success); }
```

## File cards

Expandable cards grouping a file's diff and review commentary.

```html
<div class="file-card" id="file-auth">
  <div class="file-head">
    <div class="file-info">
      <span class="file-path">src/auth/middleware.ts</span>
      <span class="file-badge mod">MOD</span>
      <span class="file-stats"><span class="additions">+28</span> <span class="deletions">-12</span></span>
    </div>
    <span class="risk-tag attention">ATTENTION</span>
  </div>
  <div class="file-why">
    <p>Replaced session-cookie auth with JWT verification. The trust boundary
       moves from the session store to the token signature check.</p>
  </div>
  <div class="diff"><!-- diff rows --></div>
  <div class="comments"><!-- review bubbles --></div>
</div>

<!-- Safe files: collapsed -->
<details class="file-collapsed">
  <summary>
    <span class="file-path">src/types/index.ts</span>
    <span class="file-badge mod">MOD</span>
    <span class="file-stats"><span class="additions">+4</span> <span class="deletions">-0</span></span>
    <span class="risk-tag safe">SAFE</span>
  </summary>
  <div class="file-why">
    <p>Added NotificationPayload type export.</p>
  </div>
</details>
```

```css
.file-card {
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  overflow: hidden;
  scroll-margin-top: 20px;
  margin: 16px 0;
}

.file-head {
  padding: 16px 20px;
  border-bottom: 1.5px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.file-info {
  display: flex;
  align-items: center;
  gap: 12px;
}

.file-path {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  font-weight: 600;
}

.file-stats {
  font-family: var(--font-mono);
  font-size: 0.72rem;
}

.file-stats .additions { color: var(--success); }
.file-stats .deletions { color: var(--destructive); }

.file-why {
  padding: 12px 20px;
  border-bottom: 1px solid var(--border);
}

.file-why p {
  font-size: 0.9rem;
  color: var(--muted-foreground);
  line-height: 1.55;
}

/* Collapsed (safe) files */
.file-collapsed {
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  margin: 8px 0;
}

.file-collapsed summary {
  list-style: none;
  cursor: pointer;
  padding: 14px 20px;
  display: flex;
  align-items: center;
  gap: 12px;
}

.file-collapsed summary::after {
  content: '+';
  font-family: var(--font-mono);
  font-size: 0.85rem;
  color: var(--muted-foreground);
  margin-left: auto;
}

.file-collapsed[open] summary::after {
  content: '\2212';
}
```

## Before / after

Two-column comparison grid.

```html
<div class="before-after">
  <div class="ba-panel before">
    <h4>Before</h4>
    <p>Auth checked via session cookie on every request.
       Session store hit adds ~15ms latency.</p>
  </div>
  <div class="ba-panel after">
    <h4>After</h4>
    <p>JWT signature verified in-process. No external store hit.
       Latency drops to ~1ms per request.</p>
  </div>
</div>
```

```css
.before-after {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin: 16px 0;
}

@media (max-width: 640px) {
  .before-after { grid-template-columns: 1fr; }
}

.ba-panel {
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: 18px 20px;
}

.ba-panel.after {
  border-color: var(--success);
}

.ba-panel h4 {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: 8px;
}

.ba-panel.before h4 { color: var(--muted-foreground); }
.ba-panel.after h4 { color: var(--success); }

.ba-panel p {
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--foreground);
}
```

## Where to focus

Numbered callout cards directing reviewers.

```html
<div class="focus-list">
  <div class="focus-item">
    <span class="focus-number">1</span>
    <div>
      <strong>src/auth/middleware.ts:verifyToken()</strong>
      <p>New trust boundary. Verify the JWT validation covers all edge cases:
         expired tokens, malformed signatures, missing claims.</p>
    </div>
  </div>
</div>
```

```css
.focus-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 16px 0;
}

.focus-item {
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: 16px 20px;
  display: flex;
  gap: 16px;
  align-items: flex-start;
}

.focus-number {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--primary-foreground);
  background: var(--primary);
  width: 26px;
  height: 26px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.focus-item strong {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  display: block;
  margin-bottom: 4px;
}

.focus-item p {
  font-size: 0.88rem;
  color: var(--muted-foreground);
  line-height: 1.5;
}
```

## Test plan

Checkbox-style verification checklist.

```html
<div class="test-list">
  <div class="test-item done">
    <span class="check"></span>
    <span>Expired JWT returns 401 with proper error body</span>
  </div>
  <div class="test-item">
    <span class="check"></span>
    <span>Concurrent WebSocket connections scale to 1000 without memory leak</span>
  </div>
</div>
```

```css
.test-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 16px 0;
}

.test-item {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: 13px 18px;
  font-size: 0.88rem;
}

.check {
  width: 18px;
  height: 18px;
  border-radius: 5px;
  border: 1.5px solid var(--border);
  flex-shrink: 0;
  position: relative;
  margin-top: 2px;
}

.test-item.done .check {
  background: var(--success);
  border-color: var(--success);
}

.test-item.done .check::after {
  content: '';
  position: absolute;
  left: 5px;
  top: 2px;
  width: 5px;
  height: 9px;
  border-right: 2px solid var(--card);
  border-bottom: 2px solid var(--card);
  transform: rotate(40deg);
}
```

## File badges

```css
.file-badge {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 6px;
  border-radius: calc(var(--radius) - 4px);
}

.file-badge.new {
  background: color-mix(in oklab, var(--success) 15%, transparent);
  color: var(--success);
}

.file-badge.mod {
  background: color-mix(in oklab, var(--warning) 15%, transparent);
  color: var(--warning);
}

.file-badge.del {
  background: color-mix(in oklab, var(--destructive) 15%, transparent);
  color: var(--destructive);
}

.risk-tag {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 3px 8px;
  border-radius: calc(var(--radius) - 4px);
}

.risk-tag.attention {
  background: color-mix(in oklab, var(--destructive) 12%, transparent);
  color: var(--destructive);
}

.risk-tag.medium {
  background: color-mix(in oklab, var(--warning) 12%, transparent);
  color: var(--warning);
}

.risk-tag.safe {
  background: color-mix(in oklab, var(--success) 12%, transparent);
  color: var(--success);
}
```

## Rollout plan

Phased deployment strip showing ramp percentages.

```html
<div class="rollout">
  <div class="rollout-step">
    <div class="rollout-when">Day 0</div>
    <div class="rollout-pct">internal</div>
    <div class="rollout-desc">Team only. Watch error rates.</div>
  </div>
  <div class="rollout-step">
    <div class="rollout-when">Day 2</div>
    <div class="rollout-pct">10%</div>
    <div class="rollout-desc">Random sample. Alert on anomalies.</div>
  </div>
  <div class="rollout-step">
    <div class="rollout-when">Day 4</div>
    <div class="rollout-pct">100%</div>
    <div class="rollout-desc">Full ramp.</div>
  </div>
</div>
```

```css
.rollout { display: flex; gap: 0; }

.rollout-step {
  flex: 1;
  background: var(--card);
  border: 1.5px solid var(--border);
  padding: 16px 18px;
}

.rollout-step:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.rollout-step:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
.rollout-step + .rollout-step { border-left: none; }

.rollout-when {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted-foreground);
  margin-bottom: 8px;
}

.rollout-pct {
  font-family: var(--font-mono);
  font-size: 1.35rem;
  font-weight: 600;
  color: var(--primary);
  margin-bottom: 6px;
}

.rollout-desc {
  font-size: 0.82rem;
  color: var(--muted-foreground);
}

@media (max-width: 720px) {
  .rollout { flex-direction: column; }
  .rollout-step { border-radius: var(--radius); }
  .rollout-step + .rollout-step { border-left: 1.5px solid var(--border); margin-top: 10px; }
}
```
references/svg-patterns.md
# SVG Diagram Patterns

Building blocks for creating diagrams in implementation plans. All SVGs are inline — no external dependencies. Compose these patterns to build architecture diagrams, data flow visualizations, flowcharts, and charts.

All colors reference Plannotator theme tokens. In SVG, use the CSS custom property values directly via `style` attributes or the corresponding CSS classes.

## Table of Contents
1. [Arrow Markers](#arrow-markers)
2. [Architecture Diagrams](#architecture-diagrams)
3. [Flowcharts](#flowcharts)
4. [Data Flow](#data-flow)
5. [Bar Charts](#bar-charts)
6. [Positioning & Layout](#positioning--layout)

## Arrow markers

Define reusable markers in `<defs>`. Reference them via `marker-end="url(#arrow)"`.

```svg
<defs>
  <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
          markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0,0 L10,5 L0,10 z" fill="var(--muted-foreground)"/>
  </marker>

  <marker id="arrow-primary" viewBox="0 0 10 10" refX="9" refY="5"
          markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0,0 L10,5 L0,10 z" fill="var(--primary)"/>
  </marker>

  <marker id="arrow-success" viewBox="0 0 10 10" refX="9" refY="5"
          markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0,0 L10,5 L0,10 z" fill="var(--success)"/>
  </marker>

  <marker id="arrow-destructive" viewBox="0 0 10 10" refX="9" refY="5"
          markerWidth="7" markerHeight="7" orient="auto-start-reverse">
    <path d="M0,0 L10,5 L0,10 z" fill="var(--destructive)"/>
  </marker>
</defs>
```

**Note on CSS vars in SVG:** `fill="var(--primary)"` works in inline SVG within an HTML document. For broader compatibility, you can also style via CSS classes instead of inline attributes.

## Architecture diagrams

Box-and-arrow diagrams showing how components connect.

### Box node

```svg
<g transform="translate(100, 80)">
  <rect width="140" height="56" rx="10" fill="var(--card)"
        stroke="var(--border)" stroke-width="1.5"/>
  <text x="70" y="24" text-anchor="middle"
        font-family="var(--font-sans)" font-size="13" font-weight="600"
        fill="var(--foreground)">API Server</text>
  <text x="70" y="40" text-anchor="middle"
        font-family="var(--font-mono)" font-size="10.5"
        fill="var(--muted-foreground)">Express + middleware</text>
</g>
```

### Highlighted box (new or hot-path component)

```svg
<g transform="translate(100, 80)">
  <rect width="140" height="56" rx="10"
        fill="color-mix(in oklab, var(--primary) 8%, transparent)"
        stroke="var(--primary)" stroke-width="1.5"/>
  <text x="70" y="24" text-anchor="middle"
        font-family="var(--font-sans)" font-size="13" font-weight="600"
        fill="var(--foreground)">New Service</text>
  <text x="70" y="40" text-anchor="middle"
        font-family="var(--font-mono)" font-size="10.5"
        fill="var(--primary)">to be created</text>
</g>
```

### Connecting arrows

```svg
<!-- Horizontal -->
<line x1="240" y1="108" x2="320" y2="108"
      stroke="var(--muted-foreground)" stroke-width="1.5"
      marker-end="url(#arrow)"/>

<!-- Vertical -->
<line x1="170" y1="136" x2="170" y2="200"
      stroke="var(--muted-foreground)" stroke-width="1.5"
      marker-end="url(#arrow)"/>

<!-- Dashed (async, optional, or secondary) -->
<line x1="240" y1="108" x2="320" y2="108"
      stroke="var(--primary)" stroke-width="1.5"
      stroke-dasharray="5 4"
      marker-end="url(#arrow-primary)"/>
```

### Edge labels

Place at the midpoint of an arrow, offset above:

```svg
<text x="280" y="100" text-anchor="middle"
      font-family="var(--font-mono)" font-size="9.5"
      fill="var(--muted-foreground)">REST</text>
```

### Full architecture example

```svg
<svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg"
     style="width:100%;max-width:720px">
  <style>
    .box { fill: var(--card); stroke: var(--border); stroke-width: 1.5; }
    .box-new { fill: color-mix(in oklab, var(--primary) 8%, transparent);
               stroke: var(--primary); stroke-width: 1.5; }
    .label { font-family: var(--font-sans); font-size: 13px;
             font-weight: 600; fill: var(--foreground); }
    .sublabel { font-family: var(--font-mono); font-size: 10.5px;
                fill: var(--muted-foreground); }
    .edge { stroke: var(--muted-foreground); stroke-width: 1.5; }
    .edge-label { font-family: var(--font-mono); font-size: 9.5px;
                  fill: var(--muted-foreground); }
  </style>
  <defs>
    <marker id="a" viewBox="0 0 10 10" refX="9" refY="5"
            markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0,0 L10,5 L0,10 z" fill="var(--muted-foreground)"/>
    </marker>
  </defs>

  <!-- Browser -->
  <rect x="20" y="100" width="120" height="56" rx="10" class="box"/>
  <text x="80" y="124" text-anchor="middle" class="label">Browser</text>
  <text x="80" y="140" text-anchor="middle" class="sublabel">React SPA</text>

  <!-- Arrow: Browser → API -->
  <line x1="140" y1="128" x2="220" y2="128" class="edge" marker-end="url(#a)"/>
  <text x="180" y="120" text-anchor="middle" class="edge-label">HTTPS</text>

  <!-- API Gateway (new) -->
  <rect x="220" y="100" width="140" height="56" rx="10" class="box-new"/>
  <text x="290" y="124" text-anchor="middle" class="label">API Gateway</text>
  <text x="290" y="140" text-anchor="middle" class="sublabel"
        fill="var(--primary)">new</text>

  <!-- Arrow: API → DB -->
  <line x1="360" y1="128" x2="440" y2="128" class="edge" marker-end="url(#a)"/>

  <!-- Postgres -->
  <rect x="440" y="100" width="120" height="56" rx="10" class="box"/>
  <text x="500" y="124" text-anchor="middle" class="label">Postgres</text>
  <text x="500" y="140" text-anchor="middle" class="sublabel">existing</text>

  <!-- Arrow: API → Cache (vertical) -->
  <line x1="290" y1="156" x2="290" y2="210" class="edge" marker-end="url(#a)"/>

  <!-- Redis -->
  <rect x="220" y="210" width="140" height="48" rx="10" class="box"/>
  <text x="290" y="240" text-anchor="middle" class="label">Redis Cache</text>
</svg>
```

## Flowcharts

### Process box

```svg
<rect x="250" y="80" width="120" height="40" rx="8"
      fill="var(--card)" stroke="var(--border)" stroke-width="1.5"/>
<text x="310" y="105" text-anchor="middle"
      font-family="var(--font-sans)" font-size="12" font-weight="500"
      fill="var(--foreground)">Parse input</text>
```

### Decision diamond

```svg
<path d="M310,262 L352,294 L310,326 L268,294 Z"
      fill="var(--card)" stroke="var(--border)" stroke-width="1.5"/>
<text x="310" y="298" text-anchor="middle"
      font-family="var(--font-sans)" font-size="11" font-weight="500"
      fill="var(--foreground)">Valid?</text>
```

### Terminal / pill node

```svg
<rect x="260" y="20" width="100" height="36" rx="18"
      fill="var(--card)" stroke="var(--border)" stroke-width="1.5"/>
<text x="310" y="43" text-anchor="middle"
      font-family="var(--font-sans)" font-size="12" font-weight="500"
      fill="var(--foreground)">Start</text>
```

### Success / failure endpoints

```svg
<!-- Success -->
<rect x="260" y="400" width="100" height="36" rx="18"
      fill="color-mix(in oklab, var(--success) 12%, transparent)"
      stroke="var(--success)" stroke-width="1.5"/>
<text x="310" y="423" text-anchor="middle"
      font-family="var(--font-sans)" font-size="12" font-weight="600"
      fill="var(--success)">Done</text>

<!-- Failure -->
<rect x="100" y="400" width="100" height="36" rx="18"
      fill="color-mix(in oklab, var(--destructive) 12%, transparent)"
      stroke="var(--destructive)" stroke-width="1.5"/>
<text x="150" y="423" text-anchor="middle"
      font-family="var(--font-sans)" font-size="12" font-weight="600"
      fill="var(--destructive)">Error</text>
```

### Curved branch path

For routing flow from a decision to a side branch:

```svg
<path d="M268,294 C200,294 160,294 160,240"
      fill="none" stroke="var(--destructive)" stroke-width="1.5"
      marker-end="url(#arrow-destructive)"/>
```

## Data flow

### Request / response pair

```svg
<!-- Request (solid) -->
<line x1="140" y1="100" x2="280" y2="100"
      stroke="var(--muted-foreground)" stroke-width="1.5"
      marker-end="url(#arrow)"/>
<text x="210" y="92" text-anchor="middle"
      font-family="var(--font-mono)" font-size="9.5"
      fill="var(--muted-foreground)">POST /api/plan</text>

<!-- Response (dashed) -->
<line x1="280" y1="116" x2="140" y2="116"
      stroke="var(--primary)" stroke-width="1.5" stroke-dasharray="5 4"
      marker-end="url(#arrow-primary)"/>
<text x="210" y="132" text-anchor="middle"
      font-family="var(--font-mono)" font-size="9.5"
      fill="var(--primary)">{ plan, status }</text>
```

### Fan-out pattern

```svg
<!-- Source box radiating to multiple targets -->
<line x1="200" y1="100" x2="340" y2="60"
      stroke="var(--muted-foreground)" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="200" y1="100" x2="340" y2="100"
      stroke="var(--muted-foreground)" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="200" y1="100" x2="340" y2="140"
      stroke="var(--muted-foreground)" stroke-width="1.5" marker-end="url(#arrow)"/>
```

## Bar charts

```svg
<svg viewBox="0 0 400 180" xmlns="http://www.w3.org/2000/svg"
     style="width:100%;max-width:400px">
  <!-- Gridlines -->
  <line x1="40" y1="20" x2="380" y2="20"
        stroke="var(--border)" stroke-width="1" opacity="0.5"/>
  <line x1="40" y1="60" x2="380" y2="60"
        stroke="var(--border)" stroke-width="1" opacity="0.5"/>
  <line x1="40" y1="100" x2="380" y2="100"
        stroke="var(--border)" stroke-width="1" opacity="0.5"/>
  <line x1="40" y1="140" x2="380" y2="140"
        stroke="var(--border)" stroke-width="1"/>

  <!-- Y-axis labels -->
  <text x="35" y="24" text-anchor="end"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">30</text>
  <text x="35" y="64" text-anchor="end"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">20</text>
  <text x="35" y="104" text-anchor="end"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">10</text>

  <!-- Bars (muted default, primary for peak) -->
  <rect x="60" y="60" width="40" height="80" rx="4"
        fill="var(--muted)"/>
  <rect x="120" y="40" width="40" height="100" rx="4"
        fill="var(--primary)"/>
  <rect x="180" y="80" width="40" height="60" rx="4"
        fill="var(--muted)"/>
  <rect x="240" y="100" width="40" height="40" rx="4"
        fill="var(--muted)"/>

  <!-- Value labels -->
  <text x="80" y="55" text-anchor="middle"
        font-family="var(--font-mono)" font-size="10" font-weight="600"
        fill="var(--foreground)">20</text>
  <text x="140" y="35" text-anchor="middle"
        font-family="var(--font-mono)" font-size="10" font-weight="600"
        fill="var(--primary)">25</text>

  <!-- X-axis labels -->
  <text x="80" y="158" text-anchor="middle"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">Q1</text>
  <text x="140" y="158" text-anchor="middle"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">Q2</text>
  <text x="200" y="158" text-anchor="middle"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">Q3</text>
  <text x="260" y="158" text-anchor="middle"
        font-family="var(--font-mono)" font-size="9"
        fill="var(--muted-foreground)">Q4</text>
</svg>
```

## Positioning & layout

### SVG container sizing
- Use `viewBox` with fixed coordinates; set `style="width:100%;max-width:NNNpx"` for responsive scaling
- Standard widths: `720px` full-width, `480px` half-width, `360px` sidebar
- Standard heights: `180–320px` for most diagrams

### Box sizing
- Standard node: `120–160px` wide, `48–56px` tall
- Minimum gap between nodes: `60px` horizontal, `40px` vertical
- Arrow label offset: `8–12px` above the line
- Diagram padding: `20px` inside the viewBox edges

### Color roles in diagrams

| Element | Fill / stroke | Token |
|---------|---------------|-------|
| Box background | fill | `var(--card)` |
| Box stroke | stroke | `var(--border)` |
| Highlighted box bg | fill | `color-mix(in oklab, var(--primary) 8%, transparent)` |
| Highlighted box stroke | stroke | `var(--primary)` |
| Arrow / connector | stroke | `var(--muted-foreground)` |
| Title text | fill | `var(--foreground)` |
| Subtitle / label text | fill | `var(--muted-foreground)` |
| Success path | stroke | `var(--success)` |
| Error path | stroke | `var(--destructive)` |
| Warning | fill | `var(--warning)` |

### Using CSS classes in SVG

For cleaner markup, define reusable classes in a `<style>` block inside the SVG:

```svg
<svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg">
  <style>
    .box   { fill: var(--card); stroke: var(--border); stroke-width: 1.5; }
    .new   { fill: color-mix(in oklab, var(--primary) 8%, transparent);
             stroke: var(--primary); stroke-width: 1.5; }
    .title { font-family: var(--font-sans); font-size: 13px;
             font-weight: 600; fill: var(--foreground); }
    .sub   { font-family: var(--font-mono); font-size: 10.5px;
             fill: var(--muted-foreground); }
    .conn  { stroke: var(--muted-foreground); stroke-width: 1.5; }
  </style>
  <!-- nodes and connectors use class="box", class="title", etc. -->
</svg>
```
references/theme-override.md
# Plannotator Theme Override

When visual-explainer's workflow says to pick a palette and font pairing, use these Plannotator tokens instead. Everything else — layout, structure, components, anti-slop rules — stays as visual-explainer prescribes.

## Host theme opt-in (required)

Plannotator's HTML viewer renders arbitrary documents untouched — it never injects bare theme tokens into a document unless the document asks for them. For the generated file to follow the active Plannotator theme when embedded in raw HTML annotation mode, it MUST declare the opt-in in its `<head>`:

```html
<meta name="plannotator-theme" content="host">
```

With this tag present, the viewer overrides the document's bare tokens (`--background`, `--muted`, …) with the host theme's values and mirrors the host's light/dark mode. Without it, the `:root` defaults below are all the document ever sees.

## CSS Custom Properties

Replace visual-explainer's `--bg`, `--surface`, `--border`, `--text`, `--accent` variables with Plannotator's semantic tokens. Include these as `:root` defaults so the file works standalone. When embedded in Plannotator's raw HTML annotation mode (with the meta opt-in above), these get overridden by the active theme.

```css
:root {
  /* Surfaces */
  --background: oklch(0.97 0.005 260);
  --foreground: oklch(0.18 0.02 260);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.18 0.02 260);
  --muted: oklch(0.92 0.01 260);
  --muted-foreground: oklch(0.40 0.02 260);

  /* Accents */
  --primary: oklch(0.50 0.25 280);
  --primary-foreground: oklch(1 0 0);
  --secondary: oklch(0.50 0.18 180);
  --accent: oklch(0.60 0.22 50);
  --accent-foreground: oklch(0.18 0.02 260);

  /* Semantic */
  --destructive: oklch(0.50 0.25 25);
  --success: oklch(0.45 0.20 150);
  --warning: oklch(0.55 0.18 85);

  /* Structure */
  --border: oklch(0.88 0.01 260);
  --code-bg: oklch(0.92 0.01 260);
  --ring: oklch(0.50 0.25 280);
  --radius: 0.625rem;

  /* Typography */
  --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
  --font-display: ui-serif, Georgia, 'Times New Roman', serif;
}
```

## Mapping visual-explainer variables to Plannotator tokens

When visual-explainer references or templates use these variables, substitute:

| visual-explainer | Plannotator | Notes |
|-----------------|-------------|-------|
| `--bg` | `var(--background)` | Page background |
| `--surface` | `var(--card)` | Card/panel surfaces |
| `--border` | `var(--border)` | Borders and dividers |
| `--text` | `var(--foreground)` | Primary text |
| `--text-dim` | `var(--muted-foreground)` | Secondary/subdued text |
| `--accent` (primary) | `var(--primary)` | Primary accent |
| `--accent-dim` | `color-mix(in oklab, var(--primary) 15%, transparent)` | Accent backgrounds |
| `--accent-2` | `var(--accent)` | Secondary accent (warm) |
| `--accent-3` | `var(--secondary)` | Tertiary accent |
| `--success` | `var(--success)` | Positive indicators |
| `--warning` | `var(--warning)` | Caution indicators |
| `--error` / `--danger` | `var(--destructive)` | Error/destructive indicators |
| `--font-body` | `var(--font-sans)` | Body text font |
| `--font-mono` | `var(--font-mono)` | Code and labels |
| `--font-heading` | `var(--font-display)` | Headings (serif) |

## Typography exception

Visual-explainer forbids Inter as `--font-body`. Plannotator uses Inter as its default sans-serif. This is intentional — Plannotator's identity is defined by its theme tokens, not font novelty. When using this skill, Inter is permitted as the body font because the output is meant to look like part of Plannotator, not like an independent design piece.

The `--font-display` (serif) is still used for headings to create visual contrast, matching the visual-explainer's emphasis on distinctive typography.

## Mermaid theming

Mermaid processes `themeVariables` itself and derives additional colors from them. That color-processing boundary does not accept every color syntax that browsers accept in CSS. Use Mermaid-compatible literal hex colors here instead of copying the semantic CSS token declarations above.

Do not pass OKLCH color functions, CSS custom-property references such as `var()`, or `color-mix()` values directly into `themeVariables`. This restriction applies only to Mermaid's color-processing boundary. Continue using Plannotator's OKLCH custom properties and other modern color functions for ordinary page CSS.

Use the same light/dark state as the page, but keep both Mermaid palettes literal. With the host-theme opt-in, Plannotator synchronizes `color-scheme`; standalone documents fall back to the operating-system preference:

```javascript
const colorScheme = getComputedStyle(document.documentElement).colorScheme;
const isDark = colorScheme === 'dark'
  || (colorScheme === 'normal'
    && window.matchMedia('(prefers-color-scheme: dark)').matches);

const mermaidThemeVariables = isDark
  ? {
      darkMode: true,
      primaryColor: '#9a9dff',
      primaryTextColor: '#070b14',
      primaryBorderColor: '#343b45',
      lineColor: '#9da5b2',
      secondaryColor: '#1e242e',
      secondaryTextColor: '#dadee5',
      tertiaryColor: '#1e242e',
      tertiaryTextColor: '#dadee5',
      background: '#070b14',
    }
  : {
      primaryColor: '#5537eb',
      primaryTextColor: '#ffffff',
      primaryBorderColor: '#d4d8de',
      lineColor: '#414853',
      secondaryColor: '#e1e5eb',
      secondaryTextColor: '#414853',
      tertiaryColor: '#e1e5eb',
      tertiaryTextColor: '#414853',
      background: '#f3f5f9',
    };

mermaid.initialize({
  theme: 'base',
  themeVariables: {
    ...mermaidThemeVariables,
    fontFamily: "'Inter', system-ui, sans-serif",
    fontSize: '14px',
  }
});
```

## Dark mode

Plannotator handles dark/light via theme classes, not `prefers-color-scheme`. The standalone defaults above are the light theme. When embedded in raw HTML annotation mode (with the `plannotator-theme` meta opt-in), the active theme's tokens override automatically — no media query needed in the generated HTML.

For standalone viewing, you may optionally add a `prefers-color-scheme: dark` block with the Plannotator dark theme values:

```css
@media (prefers-color-scheme: dark) {
  :root {
    --background: oklch(0.15 0.02 260);
    --foreground: oklch(0.90 0.01 260);
    --card: oklch(0.22 0.02 260);
    --card-foreground: oklch(0.90 0.01 260);
    --muted: oklch(0.26 0.02 260);
    --muted-foreground: oklch(0.72 0.02 260);
    --primary: oklch(0.75 0.18 280);
    --primary-foreground: oklch(0.15 0.02 260);
    --accent: oklch(0.70 0.20 60);
    --border: oklch(0.35 0.02 260);
    --code-bg: oklch(0.26 0.02 260);
    --destructive: oklch(0.65 0.20 25);
    --success: oklch(0.72 0.17 150);
    --warning: oklch(0.75 0.15 85);
  }
}
```

## Depth tiers

Visual-explainer defines depth tiers (hero, elevated, default, recessed). Map them using Plannotator tokens:

```css
/* Hero — elevated, accent-tinted */
.ve-card--hero {
  background: color-mix(in oklab, var(--primary) 5%, var(--card));
  border-color: var(--primary);
  box-shadow: 0 4px 24px color-mix(in oklab, var(--primary) 10%, transparent);
}

/* Default — standard card */
.ve-card {
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
}

/* Recessed — subdued */
.ve-card--recessed {
  background: var(--muted);
  border-color: transparent;
}
```

## Code blocks

```css
.code-block {
  background: var(--code-bg);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  font-family: var(--font-mono);
  color: var(--foreground);
}

/* Syntax tokens */
.code-block .kw  { color: var(--primary); }
.code-block .fn  { color: var(--accent); }
.code-block .str { color: var(--success); }
.code-block .cm  { color: var(--muted-foreground); font-style: italic; }
.code-block .num { color: var(--warning); }
```
SKILL.md
---
name: plannotator-visual-explainer
disable-model-invocation: true
description: >
  Generate self-contained HTML visualizations with Plannotator theming. Use for implementation
  plans, PR explainers, architecture diagrams, data tables, slide decks, and any visual
  explanation of technical concepts. Plans and PR explainers follow Plannotator's prescriptive
  approach; all other visual content delegates to nicobailon/visual-explainer.
---

# Plannotator Visual Explainer

Three paths depending on content type. Each has its own references and structure.

## Route by content type

**Implementation plan, design doc, or proposal** → Follow the [Plan path](#plan-path). Read `references/design-system.md` and `references/svg-patterns.md`. Prescriptive structure.

**PR explainer, diff review, or code change walkthrough** → Follow the [PR path](#pr-path). Read `references/design-system.md` and `references/pr-components.md`. Prescriptive structure.

**Everything else** (architecture diagrams, data tables, slide decks, project recaps, general visual explanations) → Follow the [Visual explainer path](#visual-explainer-path). Delegates to nicobailon/visual-explainer with Plannotator theme tokens.

## Delivery

Always deliver via Plannotator's annotation UI. Do NOT use `open` or `xdg-open`.

For any deliverable that uses Mermaid, render every diagram with Mermaid 12 in both the light
and dark palettes before opening the annotation UI. Rendering is a hard gate: an exception,
empty SVG, or error output such as `aria-roledescription="error"` or `Syntax error in text`
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 <file> --gate
```

**Everything else** (informational):
```bash
plannotator annotate <file>
```

---

## Plan path

For implementation plans, design docs, feature specs, migration guides, and proposals.

**Before generating, read:**
1. `references/design-system.md` — Plannotator theme tokens, typography, component patterns
2. `references/svg-patterns.md` — inline SVG building blocks for architecture diagrams, flowcharts, data flow

**Document structure (in order, pick what fits):**

1. **Header** — eyebrow label (mono, uppercase), title (serif, large), prompt box (the original brief)
2. **Summary strip** — 3-5 stat cards showing key numbers at a glance (components, endpoints, tables, etc.)
3. **Milestones / timeline** — vertical timeline showing phases without time estimates. Phases show sequence and dependencies, not duration.
4. **Architecture / data flow** — inline SVG diagram. Use for 3+ interacting components. Highlighted boxes for new components, dashed arrows for async paths.
5. **Mockups** — build UI mockups in HTML/CSS directly, not as descriptions
6. **Key code** — dark-theme code blocks with syntax highlighting. Only architecturally significant interfaces/schemas — not every function.
7. **Risks & mitigations** — table with severity badges (HIGH/MED/LOW)
8. **Open questions** — callout cards with decision owner ("Decide with: backend team")

Not every plan needs every section. Skip what doesn't serve the content. Never include time estimates, boilerplate sections, or exhaustive file lists.

**Adapt to the task:** Backend → lead with data flow. Frontend → lead with mockups. Refactoring → lead with before/after diagrams. Infrastructure → lead with architecture.

**Quality bar:** The plan answers "what, why, and how" within 30 seconds of reading. Whitespace is a feature — one idea per viewport.

---

## PR path

For PR walkthroughs, diff reviews, code change explainers, and reviewer guides.

**Before generating, read:**
1. `references/design-system.md` — Plannotator theme tokens, typography, component patterns
2. `references/pr-components.md` — diff rendering, review comment bubbles, risk chips, file cards, before/after panels

**Document structure (in order, pick what fits):**

1. **Header** — PR title, meta strip (file count, +/- lines, branch, author)
2. **TL;DR** — bordered card with primary accent left border. 2-3 sentences. Readers who see nothing else should get the gist.
3. **Why** — motivation and before/after comparison (two-column grid)
4. **File tour** — collapsible cards per file. Each has: file path + badge (NEW/MOD/DEL) + line stats, a "why" paragraph, and important diff hunks. High-risk files expanded, safe files collapsed.
5. **Risk map** — visual chips showing which files need careful review vs. which are mechanical. Three tiers: attention (destructive), medium (warning), safe (success).
6. **Where to focus** — numbered callout cards. Each names a file/function and describes the concern.
7. **Test plan** — checkbox-style verification checklist
8. **Rollout** (if applicable) — phased deployment with feature flags

Use Pierre diffs via CDN for syntax-highlighted inline diffs — see `references/pr-components.md` for the pattern.

---

## Visual explainer path

For architecture diagrams, data tables, slide decks, project recaps, comparisons, and any other visual explanation.

**Before generating:**

1. Ensure `visual-explainer` is installed:
   - Check: `~/.claude/skills/visual-explainer/SKILL.md` or `~/.agents/skills/visual-explainer/SKILL.md`
   - If not found: `npx skills add nicobailon/visual-explainer -g --yes`
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. 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.

---

## Design philosophy (all paths)

- **Whitespace is a feature.** Generous padding, large section gaps. If cramped, add space — don't shrink text.
- **One idea per viewport.** Hero section, then diagram, then detail grid — not all crammed together.
- **Show, don't describe.** A timeline shows sequencing. A diagram shows relationships. A code block shows the interface.
- **No time estimates.** Timelines show phases and dependencies. Never attach hour/day estimates.
SKILL.test.ts
/// <reference types="bun-types" />
/// <reference types="node" />

import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const themeOverride = readFileSync(
  join(import.meta.dir, "references/theme-override.md"),
  "utf-8",
);
const mermaidStart = themeOverride.indexOf("## Mermaid theming");
const mermaidEnd = themeOverride.indexOf("\n## ", mermaidStart + 1);
const mermaidSection = themeOverride.slice(mermaidStart, mermaidEnd);
const exampleStart = mermaidSection.indexOf("```javascript");
const exampleEnd = mermaidSection.indexOf("```", exampleStart + 3);
const mermaidExample = mermaidSection.slice(exampleStart, exampleEnd);
const mermaidExampleCode = mermaidExample.slice(
  mermaidExample.indexOf("\n") + 1,
);
const skill = readFileSync(join(import.meta.dir, "SKILL.md"), "utf-8");

interface CapturedMermaidConfig {
  theme?: string;
  themeVariables?: Record<string, unknown>;
}

function captureMermaidConfig(colorScheme: "light" | "dark") {
  let captured: CapturedMermaidConfig | undefined;
  const runExample = new Function(
    "mermaid",
    "getComputedStyle",
    "window",
    "document",
    mermaidExampleCode,
  );

  runExample(
    {
      initialize: (config: CapturedMermaidConfig) => {
        captured = config;
      },
    },
    () => ({ colorScheme }),
    { matchMedia: () => ({ matches: colorScheme === "dark" }) },
    { documentElement: {} },
  );

  if (!captured?.themeVariables) {
    throw new Error(`Mermaid example did not initialize the ${colorScheme} palette`);
  }

  return { name: colorScheme, config: captured };
}

describe("plannotator-visual-explainer Mermaid theming", () => {
  test("keeps the Mermaid theming section", () => {
    expect(mermaidStart).toBeGreaterThan(-1);
    expect(mermaidEnd).toBeGreaterThan(mermaidStart);
    expect(exampleStart).toBeGreaterThan(-1);
    expect(exampleEnd).toBeGreaterThan(exampleStart);
  });

  test("uses Mermaid-compatible literal colors", () => {
    const literalColors = mermaidExample.match(/#[0-9a-f]{6}\b/gi) ?? [];
    expect(mermaidExample).toContain("themeVariables");
    expect(literalColors.length).toBeGreaterThanOrEqual(10);
  });

  test("keeps CSS color processing outside Mermaid themeVariables", () => {
    expect(mermaidSection).not.toMatch(/\boklch\(\s*[^)]/i);
    expect(mermaidSection).not.toMatch(/\bvar\(\s*[^)]/i);
    expect(mermaidSection).not.toMatch(/\bcolor-mix\(\s*[^)]/i);
  });

  test("preserves OKLCH for ordinary page CSS", () => {
    expect(themeOverride).toMatch(/--background:\s+oklch\(/);
    expect(themeOverride).toMatch(
      /@media \(prefers-color-scheme: dark\)[\s\S]*--background:\s+oklch\(/,
    );
  });

  test("renders representative Mermaid 12 diagrams in both palettes", async () => {
    const palettes = [captureMermaidConfig("light"), captureMermaidConfig("dark")];
    const uiPackageDir = join(import.meta.dir, "../../../../packages/ui");
    const renderProbe = String.raw`
      import { GlobalRegistrator } from "@happy-dom/global-registrator";

      GlobalRegistrator.register();
      const [{ default: mermaid }, { default: mermaidPackage }] = await Promise.all([
        import("mermaid"),
        import("mermaid/package.json", { with: { type: "json" } }),
      ]);

      if (!String(mermaidPackage.version).startsWith("12.")) {
        throw new Error("Expected Mermaid 12, received " + mermaidPackage.version);
      }

      const palettes = JSON.parse(process.env.PLANNOTATOR_MERMAID_PALETTES ?? "[]");
      const diagrams = [
        {
          name: "architecture",
          source: "flowchart TD\n  A[Provider] --> B[Database]",
          labels: ["Provider", "Database"],
        },
        {
          name: "review-flow",
          source: "flowchart LR\n  U([Reviewer]) --> D{Approve?}\n  D -->|Yes| M[(Merge)]\n  D -->|No| R[Revise]",
          labels: ["Reviewer", "Approve?", "Merge", "Revise"],
        },
      ];
      const errorSignature = /aria-roledescription\s*=\s*["']error["']|Syntax error in text/i;
      const knownErrorOutputs = [
        '<svg aria-roledescription="error"></svg>',
        '<svg><text>Syntax error in text</text></svg>',
      ];

      if (knownErrorOutputs.some((output) => !errorSignature.test(output))) {
        throw new Error("Mermaid error-output guard does not recognize its required signatures");
      }

      for (const palette of palettes) {
        for (const diagram of diagrams) {
          mermaid.initialize({
            ...palette.config,
            startOnLoad: false,
            // Happy DOM cannot run DOMPurify's strict-mode serialization, but Mermaid's
            // parser, theme derivation, layout, and SVG renderer all execute in loose mode.
            securityLevel: "loose",
          });
          const { diagramType, svg } = await mermaid.render(
            "probe-" + palette.name + "-" + diagram.name,
            diagram.source,
          );

          if (!svg || !/<svg\b/i.test(svg)) {
            throw new Error(palette.name + "/" + diagram.name + " produced an empty SVG");
          }
          if (diagramType === "error" || errorSignature.test(svg)) {
            throw new Error(palette.name + "/" + diagram.name + " produced Mermaid error output");
          }
          if (!/aria-roledescription=["']flowchart-v2["']/.test(svg)) {
            throw new Error(palette.name + "/" + diagram.name + " was not rendered as a flowchart");
          }
          for (const colorKey of ["primaryColor", "primaryTextColor"]) {
            const color = String(palette.config.themeVariables?.[colorKey] ?? "").toLowerCase();
            if (!color || !svg.toLowerCase().includes(color)) {
              throw new Error(
                palette.name + "/" + diagram.name + " did not apply " + colorKey,
              );
            }
          }
          for (const label of diagram.labels) {
            if (!svg.includes(label)) {
              throw new Error(palette.name + "/" + diagram.name + " omitted label " + label);
            }
          }
        }
      }

      console.log(
        "Rendered " + (palettes.length * diagrams.length) +
          " Mermaid " + mermaidPackage.version + " SVGs without error signatures",
      );
    `;
    const child = Bun.spawn(
      [process.execPath, "--cwd", uiPackageDir, "-e", renderProbe],
      {
        env: {
          ...process.env,
          PLANNOTATOR_MERMAID_PALETTES: JSON.stringify(palettes),
        },
        stdout: "pipe",
        stderr: "pipe",
      },
    );
    const [exitCode, stdout, stderr] = await Promise.all([
      child.exited,
      new Response(child.stdout).text(),
      new Response(child.stderr).text(),
    ]);

    if (exitCode !== 0) {
      throw new Error(`Mermaid render probe failed:\n${stderr || stdout}`);
    }
    expect(stdout).toMatch(
      /Rendered 4 Mermaid 12\.[0-9.]+\.[0-9]+ SVGs without error signatures/,
    );
  }, 20_000);

  test("keeps Mermaid rendering as a pre-delivery gate", () => {
    expect(skill).toContain("render every diagram with Mermaid 12");
    expect(skill).toContain('aria-roledescription="error"');
    expect(skill).toContain("Syntax error in text");
    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/);
  });
});