docs(.claude/skills/svg-docs-diagrams): skill for authoring SVG docs diagrams

Captures the conventions learned while iterating on the Claude Code
self-hosted runners diagrams:

* viewBox sizing for the ~657 CSS-pixel docs prose column, with a
  table mapping viewBox font sizes to on-page font sizes.
* The 'paint-order: stroke' white halo pattern for arrow labels so
  they stay readable over zone-tinted backgrounds.
* Card title wrapping rules so titles don't overflow a 144 px card.
* Locked-to-one-user badge and per-instance suffix conventions for
  surfacing the things readers most need to see.
* A reproducible test loop: render the docs page (not the raw SVG)
  with a copy-pastable headless Chrome script, then verify the
  click-through full-size view with a computer_use subagent.
* Pitfalls: subagents reusing stale screenshots, Next.js asset
  caching, and headless Chrome rendering SVGs at sub-natural width
  when the file is opened directly.

Ships with two reference files: 'render-diagram.sh' (a cache-busting
docs-page screenshot script) and 'template.svg' (a starter SVG with
all conventions baked in).
This commit is contained in:
Ben Potter
2026-05-13 16:57:49 +00:00
parent 2943bf5f21
commit f6088d3b52
3 changed files with 474 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
---
name: svg-docs-diagrams
description: "Author and iterate on SVG architecture diagrams embedded in Coder docs pages. Covers viewBox sizing for the docs column, readable arrow labels, the docs-page test loop with headless Chrome, and the recurring pitfalls."
---
# SVG Docs Diagrams
Use this skill when you are writing or editing an SVG diagram that ships in
`docs/images/**` and renders inside a docs page.
Coder docs render at a fixed prose column width (`max-w-[65ch]`, roughly
650 to 680 CSS pixels regardless of viewport). Every diagram is scaled to
that column. A diagram that looks great at native 1920px will be
unreadable on the page. This skill captures the conventions that produce
diagrams that read at column width and still look correct when a reader
clicks the image to view it full-size.
## When to use this skill
- Adding a new SVG to `docs/images/**` for an existing or new doc page.
- Iterating on an existing diagram (font sizes, layout, arrow routing).
- Debugging an overlap, overflow, or unreadable label complaint.
- Removing or splitting a diagram that is too dense for the column width.
Do not use this skill for PNG screenshots, icon SVGs, or third-party
brand SVGs.
## File layout
```text
docs/images/guides/<guide-name>/
<diagram>.svg
<flow>.svg
```
Reference each SVG from Markdown as a plain `<img>` tag so you control
the alt text and the path. The frontend renders SVGs at the column
width and lets the reader click for a full-size view, so the SVG must
look correct at both sizes.
```markdown
<img
src="../../images/guides/<guide-name>/<diagram>.svg"
alt="One sentence that fully describes the diagram for screen readers."
/>
```
## Authoring rules
### viewBox sizing
The docs column is ~657 CSS pixels wide. The SVG is scaled to fit. To
keep text readable on the page, target a viewBox aspect ratio where the
horizontal scale factor is no smaller than 0.5. That means a viewBox
width of about 1000 to 1180 works well. Wider than 1280 and side-card
text becomes unreadable.
For a square-ish concept diagram: `viewBox="0 0 1000 460"` is a good
default.
For a flow diagram: `viewBox="0 0 960 360"` keeps things proportionate
and matches the existing flow diagrams in
`docs/images/guides/claude-code-self-hosted-runners/`.
Always set `preserveAspectRatio="xMidYMid meet"` and a `width` attribute
matching the viewBox width.
### Text size minima
Compute the on-page font size as
`viewBoxFontSize × (657 / viewBoxWidth)`. Aim for these on-page sizes:
| Role | On-page target | Example viewBox size at width 1000 |
|---------------------|----------------|------------------------------------|
| Layer / zone title | 16 px or more | 26 to 30 px |
| Card title | 9 to 10 px | 14 to 16 px |
| Body / mono text | 7 to 8 px | 11 to 14 px |
| Arrow label | 7 to 8 px | 11 to 12 px |
| Footnote | 8 to 9 px | 13 to 14 px |
Body text rendering below 7 px on the page is unreadable. Either shrink
the viewBox, shorten the copy, or drop the text from the diagram and
put it in the surrounding Markdown instead.
### Arrow labels
Floating gray text over a tinted zone background is hard to read,
especially at the docs column width. Always give arrow labels a white
halo so they stay legible regardless of background color:
```css
.arrow-label {
font-size: 11.5px;
font-weight: 600;
fill: #111827;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
}
```
`paint-order: stroke` paints the stroke before the fill, producing a
clean white outline that does not eat the glyphs. Bump
`stroke-width` until labels are clearly readable on the page; 3px in
the viewBox is usually enough.
### Card title wrapping
SVG `<text>` does not wrap. A two-word card title like
"Developer surfaces" or "Package registries" overflows a 144 px wide
card at 15 px bold. When a title is longer than about 10 to 12
characters, split it into two `<text>` elements stacked vertically:
```xml
<text class="side-title" x="44" y="100">Developer</text>
<text class="side-title" x="44" y="120">surfaces</text>
```
Account for the extra height in the card rect.
### Visible identity and lock semantics
When a runner, workspace, or process is locked to a single user, show
that on the diagram with a pill badge. Do not bury it in the
description. Example:
```xml
<rect class="badge-locked"
x="358" y="160" width="148" height="24" rx="6"/>
<text class="badge-label badge-locked-l" x="372" y="176">
Locked to 1 user
</text>
```
The reader should see the lock at a glance, not have to parse a
paragraph.
### Differentiating repeated items
If the diagram shows N instances of the same concept (sessions, pool
slots, replicas) and they differ in some way, surface that difference
in the labels. Three identical-looking session cards with identical
`cwd: /workspace` lines hide the per-session subtree. Use a unique
suffix per instance:
```xml
<text class="session-mono" x="264" y="324">_sessions/cse_01ST</text>
<text class="session-mono" x="438" y="324">_sessions/cse_a4Bf</text>
<text class="session-mono" x="612" y="324">_sessions/cse_9pXk</text>
```
### Always include accessible metadata
Every SVG must have `<title>` and `<desc>` elements referenced by
`aria-labelledby` on the root `<svg>`. The `<desc>` should be one or
two sentences that fully describe what the diagram shows; this is what
screen readers announce and what search indexes pick up.
```xml
<svg ... role="img" aria-labelledby="diag-title diag-desc">
<title id="diag-title">Short title</title>
<desc id="diag-desc">Longer prose description.</desc>
...
</svg>
```
## Test loop
Treat diagrams like code: change, render, eyeball, repeat. The docs
dev server is the source of truth, not native SVG rendering.
### Render the diagram inside the actual docs page
Start the docs dev server (see the project's existing dev instructions;
in this repo it's coder.com running on port 4001 with `DOCS_ROOT`
pointed at the coder/coder checkout).
Then render the docs page with headless Chrome and screenshot it. See
`references/render-diagram.sh` in this skill directory for a
copy-pastable script.
```bash
bash .claude/skills/svg-docs-diagrams/references/render-diagram.sh \
http://localhost:4001/docs/ai-coder/claude-code-self-hosted-runners \
/tmp/svg-preview/page.png
```
### Click into the full-size view
The docs frontend lets the reader click the inline image to view it at
its natural size. Verify both:
- The inline rendering at column width: text is readable, no overlap.
- The clicked-open view: text is sharp, no overflow past card borders.
If only the inline version has problems, the viewBox is too wide. If
only the full-size has problems, individual elements are mispositioned.
### Use a `computer_use` subagent for the click-through
Headless Chrome can capture the inline rendering, but verifying the
clicked-open full-size view and the overall feel is best done with a
`computer_use` subagent. Spawn one with a clear list of checks:
```text
For the diagram on <url>:
1. Click through to the full-size view.
2. For each of these labels, report OK or OVERFLOWS / OVERLAPS:
- "<label 1>"
- "<label 2>"
...
3. Are arrow labels readable against the zone backgrounds?
4. Save the full-size screenshot to /tmp/svg-preview/<name>.png.
Read-only. Do not modify any files.
```
Do not trust the subagent's "looks good" if it reuses an old
screenshot path. Verify the screenshot's `md5sum` changes when you
edit the SVG.
### Cache busting
Next.js dev servers cache static assets. If a fresh screenshot shows
your old SVG:
- Confirm the file on disk has the new content (`md5sum` it).
- Append a cache-buster to the URL: `<svg-url>?t=$(date +%s%N)`.
- Or restart the dev server.
If `curl <svg-url> | md5sum` matches the on-disk file, the dev server
is serving the new SVG. Any stale-looking render is on the client
side; force-reload or use incognito.
## Pitfalls
### "Looks good" from a subagent without proof
Computer-use subagents sometimes claim they re-screenshotted when they
actually reused a stale file. Always verify with:
```bash
md5sum /tmp/svg-preview/<name>.png
```
If the hash matches a previous version after you edited the SVG,
re-run the screenshot yourself.
### Pixel-counting in headless Chrome is misleading
Headless Chrome can render an SVG with a sub-natural width when the
window is wider than the SVG width. Trust the docs page render, not
raw `chrome --headless ... file://.../foo.svg` output. Always test
inside the docs page at viewport 1280.
### Estimating text width
There is no SVG equivalent of `text-overflow: ellipsis`. A 15 px bold
title can fit roughly:
- 10 to 11 characters in a 144 px wide card.
- 14 to 15 characters in a 200 px wide card.
- 18 to 20 characters in a 260 px wide card.
If a title is longer than that, wrap to two lines or shrink the font.
### Stroked text via `paint-order` breaks if you forget `stroke-linejoin`
Without `stroke-linejoin: round`, the white halo around arrow labels
has sharp corners that look like glitches at small sizes. Always
include it.
### Don't `emdash` or `endash` in `<desc>` or `<text>`
`make lint/emdash` runs across the entire repo, SVGs included. Use a
period, comma, or semicolon. Use plain ASCII hyphens.
### Don't commit preview PNGs
Put preview artifacts under `/tmp/svg-preview/`. Never commit them.
Add to `.gitignore` if you accidentally `git add -A` them in.
## Reference checklist
Before you commit an SVG change:
- [ ] `viewBox` is no wider than 1280 (target 960 to 1180).
- [ ] On-page font sizes hit the targets above.
- [ ] Arrow labels use the white halo via `paint-order: stroke`.
- [ ] No card title overflows its container at the docs column width.
- [ ] Click-through to full-size still looks correct.
- [ ] `<title>` and `<desc>` exist and describe the diagram.
- [ ] `aria-labelledby` references both IDs.
- [ ] No emdash, endash, or `--` punctuation anywhere.
- [ ] Preview PNGs are not staged.
- [ ] Tested with the docs dev server, not just raw chrome on the file.
- [ ] `make lint/emdash` and `pnpm run lint-docs` pass.
## Related files
- `references/render-diagram.sh` — copy-pastable headless Chrome
rendering script.
- `references/template.svg` — minimal SVG template with the
conventions baked in (viewBox, arrow-label halo, title and desc,
badge classes).
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# render-diagram.sh — capture a docs page rendering for SVG diagram review.
#
# Usage:
# render-diagram.sh <docs-url> <output-png> [viewport-width] [page-height]
#
# Defaults: viewport-width=1280, page-height=2400.
#
# Requires google-chrome (or chromium) in PATH and a docs dev server
# running at the URL.
#
# Why this exists: rendering the SVG directly with `chrome --headless
# file://.../foo.svg` does NOT match what the docs page produces. The
# docs frontend scales SVGs to a fixed prose-column width, and that is
# what readers see. Always test inside the docs page.
set -euo pipefail
URL="${1:-}"
OUT="${2:-}"
WIDTH="${3:-1280}"
HEIGHT="${4:-2400}"
if [[ -z "$URL" || -z "$OUT" ]]; then
echo "usage: $0 <docs-url> <output-png> [viewport-width] [page-height]" >&2
exit 2
fi
BROWSER=""
for c in google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then
BROWSER="$c"
break
fi
done
if [[ -z "$BROWSER" ]]; then
echo "no chrome or chromium found in PATH" >&2
exit 1
fi
# Use a fresh user-data-dir per invocation to avoid clashing with any
# existing Chrome instance and to bypass disk cache.
TMP_PROFILE="$(mktemp -d -t chrome-render.XXXXXX)"
trap 'rm -rf "$TMP_PROFILE"' EXIT
# Cache-bust the URL so the dev server returns the latest asset.
SEP="?"
[[ "$URL" == *\?* ]] && SEP="&"
URL_CB="${URL}${SEP}t=$(date +%s%N)"
"$BROWSER" \
--headless=new \
--disable-gpu \
--no-sandbox \
--disable-dev-shm-usage \
--hide-scrollbars \
--user-data-dir="$TMP_PROFILE" \
--window-size="${WIDTH},${HEIGHT}" \
--screenshot="$OUT" \
"$URL_CB" 2>&1 |
grep -vE 'dbus|GPU process|registration_request|TensorFlow|XNNPACK' || true
if [[ ! -s "$OUT" ]]; then
echo "screenshot failed: $OUT is empty or missing" >&2
exit 1
fi
echo "saved $OUT"
md5sum "$OUT" 2>/dev/null || true
@@ -0,0 +1,100 @@
<!--
SVG diagram template for Coder docs pages.
Conventions baked in:
- viewBox sized for the ~657 CSS-pixel docs column.
- preserveAspectRatio="xMidYMid meet" and width attribute set.
- <title> and <desc> wired up via aria-labelledby.
- Arrow labels use paint-order:stroke + white halo for readability.
- Locked badge class shown.
See SKILL.md for the full ruleset and test loop.
-->
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 460"
width="1000"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-labelledby="diag-title diag-desc">
<title id="diag-title">Short, factual diagram title</title>
<desc id="diag-desc">One or two sentences fully describing the diagram for screen readers and search indexes.</desc>
<defs>
<style>
svg { font-family: -apple-system, "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif; }
.bg { fill: #ffffff; }
/* Zones (outer containers, one per managing party). */
.zone-coder { fill: #f5f3ff; stroke: #7c3aed; stroke-width: 2; }
.zone-anthropic { fill: #fff7ed; stroke: #f59e0b; stroke-width: 1.75; }
.zone-network { fill: #ecfeff; stroke: #0e7490; stroke-width: 1.5; }
/* Cards inside zones. */
.card { fill: #ffffff; stroke-width: 1.25; }
.card-coder { stroke: #a78bfa; }
.card-anthropic { stroke: #fbbf24; }
.card-network { stroke: #67e8f9; }
/* Badges. */
.badge-coder { fill: #ede9fe; stroke: #7c3aed; stroke-width: 1; }
.badge-anthropic { fill: #fef3c7; stroke: #b45309; stroke-width: 1; }
.badge-locked { fill: #fee2e2; stroke: #b91c1c; stroke-width: 1; }
.badge-coder-l { fill: #6d28d9; }
.badge-anthropic-l{ fill: #b45309; }
.badge-locked-l { fill: #b91c1c; }
/* Type. Sizes target the docs column width; see SKILL.md. */
.zone-label { font-size: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.layer-title { font-size: 26px; font-weight: 700; fill: #111827; }
.layer-def { font-size: 14px; fill: #374151; }
.card-title { font-size: 15px; font-weight: 700; fill: #111827; }
.card-line { font-size: 13px; fill: #374151; }
.mono { font-family: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace; font-size: 11px; fill: #1f2937; }
.badge-label { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
.footnote { font-size: 13px; fill: #4b5563; font-style: italic; }
/* Arrows. The white halo on labels keeps them readable. */
.arrow { fill: none; stroke: #374151; stroke-width: 1.4; }
.arrow-label {
font-size: 11.5px;
font-weight: 600;
fill: #111827;
paint-order: stroke;
stroke: #ffffff;
stroke-width: 3px;
stroke-linejoin: round;
}
</style>
<marker id="ah" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#374151"/>
</marker>
</defs>
<rect class="bg" width="1000" height="460"/>
<!-- Example zone -->
<rect class="zone-coder" x="200" y="40" width="600" height="380" rx="16"/>
<text class="zone-label" x="224" y="68" fill="#6d28d9">Coder</text>
<!-- Example card with locked badge -->
<rect class="card card-coder" x="220" y="96" width="560" height="140" rx="10"/>
<text class="card-title" x="240" y="124">Card title</text>
<text class="card-line" x="240" y="146">Body line one.</text>
<text class="card-line" x="240" y="164">Body line two.</text>
<rect class="badge-locked" x="620" y="106" width="148" height="22" rx="6"/>
<text class="badge-label badge-locked-l" x="634" y="121">Locked to 1 user</text>
<!-- Example arrow with halo label -->
<path class="arrow"
d="M 220 300 C 360 240, 540 240, 780 300"
marker-end="url(#ah)"/>
<text class="arrow-label" x="420" y="244">arrow label stays readable</text>
<!-- Footnote -->
<text class="footnote" x="20" y="446">Brief footnote summarizing what the diagram is and is not.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB