🤖 Restructure SVG skill: extract references, add legibility rules

Extract inline SVG rules to references/inline-svg.md for progressive
disclosure. Add references/layout-legibility.md with 7 rules for
readable SVG output (text sizing, contrast, margins, chart patterns,
palettes) based on concrete failure modes from issue #4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:17:25 +02:00
parent f7e1b96930
commit 61af13521c
3 changed files with 193 additions and 96 deletions
@@ -7,7 +7,7 @@ description: Use when authoring or editing a Markdown file that contains (or sho
Markdown renderers diverge dramatically on what SVG they allow. The safe default is **not** to embed SVG inline — it's to **save the SVG as a separate `.svg` file and reference it with image-link syntax**. Inline SVG should be treated as a special case the user explicitly asks for, not as the default.
This skill encodes the reasoning behind that choice plus the per-renderer rules for when inline SVG is what's wanted.
This skill encodes the reasoning behind that choice, layout and legibility rules for readable output, and the per-renderer rules for when inline SVG is what's wanted.
## Default approach: external `.svg` file + image link
@@ -37,64 +37,20 @@ Inline SVG fails silently and inconsistently across renderers. From source-code
The **strict cross-renderer intersection for inline SVG is effectively empty** — there is no inline-SVG recipe that produces a rendered diagram on GitHub, Gitea, and Obsidian alike. This is why the default has to be the external file.
## When the user does ask for inline SVG
## Layout and legibility
If the user explicitly says "embed it inline", "put the SVG directly in the markdown", or similar, follow these rules. Pick the rule set that matches the target renderer. If the target is unknown, ask.
Renderer compatibility is only half the problem — the other half is producing SVG that's actually readable. Agent-generated SVGs commonly fail on text overflow, poor contrast, and missing margins. **Read `references/layout-legibility.md` before generating any non-trivial SVG** (charts, matrices, diagrams with labels). It covers:
### Rule 1: Embed as a raw HTML block, never as a fenced code block
- Estimating text width before sizing containers (SVG doesn't clip text)
- Contrast contracts for text over colored backgrounds (aim for 4.5:1)
- Background rects or halos for text on data-dependent surfaces
- Margin reservation for rotated labels
- The sparse-cells-plus-legend pattern for dense categorical charts
- Palette selection with declared text-color roles
The two paths look similar but produce completely different output.
## When the user asks for inline SVG
- **Embed SVG as a raw HTML block.** Place `<svg …>…</svg>` directly in the Markdown source, with a blank line before and after. CommonMark / GFM treat that as a raw HTML block and pass it through verbatim; the browser then parses what survives the sanitizer as real SVG.
- **Never wrap renderable content in ` ```svg ` (or ` ```html `, ` ```xml `) fences.** Fenced code blocks are *by definition* "display this as text". The renderer wraps the contents in `<pre><code>`, HTML-escapes every `<`, `>`, and `&`, and hands the escaped text to a syntax highlighter. The browser sees a string, not markup — no SVG ever gets parsed.
- **Don't assume non-standard "render this code block" extensions exist.** Some renderers (GitLab, certain Obsidian plugins, custom static-site setups) special-case ` ```mermaid ` or ` ```svg ` to render. This is non-standard. GitHub, VS Code's default preview, and CommonMark do not. Default to the raw-HTML-block path.
- **Same rule applies to other inline-renderable HTML.** `<details>`/`<summary>`, `<picture>`, `<video>`, raw `<table>` with attributes Markdown can't express — all use the raw-HTML-block path, never a fence.
Diagnostic: "I see SVG source code as text" → fence problem. "I see blank space or default-styled shapes" → sanitizer/styling problem (see Rule 2).
### Rule 2: Per-renderer element / attribute rules
| Element / feature | GitHub inline `.md` | Gitea (default) | Obsidian | Notes |
|---|---|---|---|---|
| `<svg>` | ❌ stripped | ✅ | ✅ | |
| `<path>` | ❌ | ✅ | ✅ | The most portable shape primitive. |
| `<rect>` | ❌ | ❌ | ✅ | Substitute with `<path>` rectangle on Gitea. |
| `<circle>` | ❌ | ❌ | ✅ | Substitute with `<ellipse rx=R ry=R>` (Obsidian only) or `<path>` arc circle. |
| `<ellipse>` | ❌ | ❌ | ✅ | |
| `<line>`, `<polyline>`, `<polygon>` | ❌ | ❌ | ✅ | |
| `<text>`, `<tspan>` | ❌ | ❌ | ✅ | |
| `<g>` | ❌ | ❌ | ✅ | Group inheritance dies on Gitea — repeat presentation attrs per element if cross-renderer matters. |
| `<defs>`, `<linearGradient>`, `<radialGradient>`, `<stop>` | ❌ | ❌ | ✅ | |
| `<marker>` | ❌ | ❌ | ✅ | Arrowheads only work on Obsidian inline. |
| `<use>` | ❌ | ❌ | ❌ (empirically confirmed) | Strip on Obsidian was unexpected; treat as not portable. |
| `<style>` element | ❌ | ❌ | ❌ (empirically confirmed) | Don't use it anywhere. CSS rules don't have a clear scope boundary so sanitizers strip them as injection risk. |
| `<title>` element | ❌ | ❌ | ❌ (empirically confirmed) | |
| `<foreignObject>` | ❌ | ❌ | ❌ (empirically confirmed) | |
| `<script>`, `on*` event handlers, `javascript:` URLs | ❌ | ❌ | ❌ | Always stripped, every renderer. Don't use. |
| `class=` attribute (for styling) | ❌ | ❌ | ✅ | Even when class survives, the `<style>` block defining the rules doesn't, so styling is lost — use inline presentation attributes. |
| Inline presentation attributes (`fill`, `stroke`, `stroke-width`, `font-size`, `font-family`, `text-anchor`, `stroke-dasharray`, etc.) | ❌ | ❌ | ✅ | These survive on Obsidian and any sanitizer that allows SVG at all. They never apply on GitHub-inline / Gitea because the elements themselves are stripped. |
Reading the table: an Obsidian-only target gives you most of SVG. A Gitea target restricts you to `<svg>` + `<path>` (no colors, no text, no groups). A GitHub-inline target gives you nothing — switch to the external-file recipe.
### Rule 3: Surviving the sanitizer where SVG is allowed
When the renderer allows SVG (Obsidian, VS Code preview, some self-hosted Gitea with extended `app.ini`, and the standalone-`.svg`-file path on GitHub), structure the SVG to survive its sanitizer:
- **Use SVG presentation attributes inline on every element.** `fill`, `stroke`, `stroke-width`, `stroke-dasharray`, `font-size`, `font-weight`, `font-family`, `text-anchor`. These can't escape the SVG and survive every sanitizer that allows SVG at all.
- **Don't rely on `<style>` blocks or `class=` for styling.** `<style>` is stripped on every renderer's inline sanitizer (including Obsidian, empirically). Even if `class=` survives, the rules are gone, so styling is lost.
- **Prefer longhand over CSS shorthand.** Use `font-size`, `font-weight`, `font-family` separately rather than the `font:` shorthand. The shorthand is CSS-only; longhand maps directly to SVG presentation attributes.
- **`<defs>` and id references are fine** on renderers that allow them at all (Obsidian; not Gitea/GitHub-inline). `<marker>`, `<linearGradient>` referenced via `url(#id)` survive the Obsidian sanitizer.
- **Group-level inheritance works for `font-family` and `fill`** (set on `<g>`, inherited by children) on Obsidian. Don't rely on it for cross-renderer; set the attribute on the leaf element.
The visible failure mode is silent: shapes still render, but with browser-default fill (usually black), no stroke, and default font sizing. On dark themes this looks like "nothing showed up"; on light themes like "a row of black blobs with no labels". There is no console error and no Markdown lint warning.
### Rule 4: No blank lines inside inline SVG
pulldown-cmark (and most CommonMark parsers) terminate an HTML block at a blank line. Any content after the blank line — even if it's still inside `<svg>...</svg>` — is parsed as Markdown, not HTML. Indented SVG elements become fenced code blocks (`<pre><code>`); unindented ones become paragraphs wrapping escaped angle brackets.
This means **the SVG source must be one unbroken block with no empty lines between `<svg>` and `</svg>`**. Use comments (`<!-- section: axes -->`) instead of blank lines if you need visual separation inside the SVG source.
Diagnostic: "Part of my SVG renders but the rest appears as code or escaped text" → blank-line-induced block break. Remove all blank lines between `<svg>` and `</svg>`.
If the user explicitly says "embed it inline", "put the SVG directly in the markdown", or similar — **read `references/inline-svg.md` before proceeding**. It contains the full per-renderer compatibility table, sanitizer survival rules, and inline-specific checklists. Pick the rule set that matches the target renderer; if the target is unknown, ask.
## Default size
@@ -112,50 +68,10 @@ Each component handles its own state.
Where `./images/architecture.svg` is a normal SVG file — use any elements you want; standalone SVG is not subject to the inline-Markdown sanitizer.
## Minimal example (inline, Obsidian-friendly — only if user asks)
```markdown
Some prose before the diagram.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<g font-family="sans-serif" font-size="12" fill="#222">
<!-- inputs -->
<rect x="10" y="20" width="80" height="40" fill="#e8f0fe" stroke="#1a73e8" stroke-width="2"/>
<text x="50" y="45" text-anchor="middle">Input</text>
<!-- arrow -->
<line x1="90" y1="40" x2="130" y2="40" stroke="#1a73e8" stroke-width="2"/>
<!-- outputs -->
<rect x="130" y="20" width="60" height="40" fill="#fce8e6" stroke="#d93025" stroke-width="2"/>
<text x="160" y="45" text-anchor="middle">Output</text>
</g>
</svg>
Prose after.
```
Note: blank line before `<svg>`, blank line after `</svg>`, **no blank lines inside the SVG** (use `<!-- comments -->` for section separation), no fence, all styling via presentation attributes, font longhand on the group. This will render on Obsidian and similar permissive renderers. It will **not** render on GitHub `.md`. It will partially render on Gitea (the `<svg>` survives, the rest is stripped).
## Pre-commit checklist
If you went with the **external file** default:
- [ ] SVG saved as a `.svg` file in a sensible location.
- [ ] Markdown references it with `![alt](path.svg)`.
- [ ] `width` (the rendered width) ≤ 750px unless asked otherwise.
If the user asked for **inline SVG**:
- [ ] Confirmed the target renderer allows SVG inline (not GitHub `.md`).
- [ ] `<svg>` block has a blank line before and after, no surrounding ` ``` ` fence.
- [ ] No blank lines between `<svg>` and `</svg>` (pulldown-cmark terminates the HTML block at blank lines).
- [ ] No `<style>`, `<title>`, `<use>`, `<foreignObject>` elements (don't survive Obsidian).
- [ ] No `class=` for styling, no CSS `font:` shorthand.
- [ ] Every shape has explicit `fill` and (where applicable) `stroke` / `stroke-width` as inline attributes.
- [ ] No `<script>`, `on*` handlers, or `javascript:` URLs.
- [ ] `width` ≤ 750px unless asked otherwise.
If the target is Gitea specifically and inline is required:
- [ ] Only `<svg>` + `<path>` elements used.
- [ ] No `fill`, `stroke`, `class`, `style`, `transform` attributes (stripped by default policy).
- [ ] OR confirmed the Gitea instance has `[markup.sanitizer.N]` extensions in `app.ini` permitting more.
For inline SVG checklists, see `references/inline-svg.md`.
@@ -0,0 +1,98 @@
# Inline SVG in Markdown — Per-Renderer Rules
Read this document only when the user explicitly asks for inline SVG (e.g., "embed it inline", "put the SVG directly in the markdown"). Pick the rule set that matches the target renderer. If the target is unknown, ask.
## Rule 1: Embed as a raw HTML block, never as a fenced code block
The two paths look similar but produce completely different output.
- **Embed SVG as a raw HTML block.** Place `<svg …>…</svg>` directly in the Markdown source, with a blank line before and after. CommonMark / GFM treat that as a raw HTML block and pass it through verbatim; the browser then parses what survives the sanitizer as real SVG.
- **Never wrap renderable content in ` ```svg ` (or ` ```html `, ` ```xml `) fences.** Fenced code blocks are *by definition* "display this as text". The renderer wraps the contents in `<pre><code>`, HTML-escapes every `<`, `>`, and `&`, and hands the escaped text to a syntax highlighter. The browser sees a string, not markup — no SVG ever gets parsed.
- **Don't assume non-standard "render this code block" extensions exist.** Some renderers (GitLab, certain Obsidian plugins, custom static-site setups) special-case ` ```mermaid ` or ` ```svg ` to render. This is non-standard. GitHub, VS Code's default preview, and CommonMark do not. Default to the raw-HTML-block path.
- **Same rule applies to other inline-renderable HTML.** `<details>`/`<summary>`, `<picture>`, `<video>`, raw `<table>` with attributes Markdown can't express — all use the raw-HTML-block path, never a fence.
Diagnostic: "I see SVG source code as text" → fence problem. "I see blank space or default-styled shapes" → sanitizer/styling problem (see Rule 2).
## Rule 2: Per-renderer element / attribute rules
| Element / feature | GitHub inline `.md` | Gitea (default) | Obsidian | Notes |
|---|---|---|---|---|
| `<svg>` | ❌ stripped | ✅ | ✅ | |
| `<path>` | ❌ | ✅ | ✅ | The most portable shape primitive. |
| `<rect>` | ❌ | ❌ | ✅ | Substitute with `<path>` rectangle on Gitea. |
| `<circle>` | ❌ | ❌ | ✅ | Substitute with `<ellipse rx=R ry=R>` (Obsidian only) or `<path>` arc circle. |
| `<ellipse>` | ❌ | ❌ | ✅ | |
| `<line>`, `<polyline>`, `<polygon>` | ❌ | ❌ | ✅ | |
| `<text>`, `<tspan>` | ❌ | ❌ | ✅ | |
| `<g>` | ❌ | ❌ | ✅ | Group inheritance dies on Gitea — repeat presentation attrs per element if cross-renderer matters. |
| `<defs>`, `<linearGradient>`, `<radialGradient>`, `<stop>` | ❌ | ❌ | ✅ | |
| `<marker>` | ❌ | ❌ | ✅ | Arrowheads only work on Obsidian inline. |
| `<use>` | ❌ | ❌ | ❌ (empirically confirmed) | Strip on Obsidian was unexpected; treat as not portable. |
| `<style>` element | ❌ | ❌ | ❌ (empirically confirmed) | Don't use it anywhere. CSS rules don't have a clear scope boundary so sanitizers strip them as injection risk. |
| `<title>` element | ❌ | ❌ | ❌ (empirically confirmed) | |
| `<foreignObject>` | ❌ | ❌ | ❌ (empirically confirmed) | |
| `<script>`, `on*` event handlers, `javascript:` URLs | ❌ | ❌ | ❌ | Always stripped, every renderer. Don't use. |
| `class=` attribute (for styling) | ❌ | ❌ | ✅ | Even when class survives, the `<style>` block defining the rules doesn't, so styling is lost — use inline presentation attributes. |
| Inline presentation attributes (`fill`, `stroke`, `stroke-width`, `font-size`, `font-family`, `text-anchor`, `stroke-dasharray`, etc.) | ❌ | ❌ | ✅ | These survive on Obsidian and any sanitizer that allows SVG at all. They never apply on GitHub-inline / Gitea because the elements themselves are stripped. |
Reading the table: an Obsidian-only target gives you most of SVG. A Gitea target restricts you to `<svg>` + `<path>` (no colors, no text, no groups). A GitHub-inline target gives you nothing — switch to the external-file recipe.
## Rule 3: Surviving the sanitizer where SVG is allowed
When the renderer allows SVG (Obsidian, VS Code preview, some self-hosted Gitea with extended `app.ini`, and the standalone-`.svg`-file path on GitHub), structure the SVG to survive its sanitizer:
- **Use SVG presentation attributes inline on every element.** `fill`, `stroke`, `stroke-width`, `stroke-dasharray`, `font-size`, `font-weight`, `font-family`, `text-anchor`. These can't escape the SVG and survive every sanitizer that allows SVG at all.
- **Don't rely on `<style>` blocks or `class=` for styling.** `<style>` is stripped on every renderer's inline sanitizer (including Obsidian, empirically). Even if `class=` survives, the rules are gone, so styling is lost.
- **Prefer longhand over CSS shorthand.** Use `font-size`, `font-weight`, `font-family` separately rather than the `font:` shorthand. The shorthand is CSS-only; longhand maps directly to SVG presentation attributes.
- **`<defs>` and id references are fine** on renderers that allow them at all (Obsidian; not Gitea/GitHub-inline). `<marker>`, `<linearGradient>` referenced via `url(#id)` survive the Obsidian sanitizer.
- **Group-level inheritance works for `font-family` and `fill`** (set on `<g>`, inherited by children) on Obsidian. Don't rely on it for cross-renderer; set the attribute on the leaf element.
The visible failure mode is silent: shapes still render, but with browser-default fill (usually black), no stroke, and default font sizing. On dark themes this looks like "nothing showed up"; on light themes like "a row of black blobs with no labels". There is no console error and no Markdown lint warning.
## Rule 4: No blank lines inside inline SVG
pulldown-cmark (and most CommonMark parsers) terminate an HTML block at a blank line. Any content after the blank line — even if it's still inside `<svg>...</svg>` — is parsed as Markdown, not HTML. Indented SVG elements become fenced code blocks (`<pre><code>`); unindented ones become paragraphs wrapping escaped angle brackets.
This means **the SVG source must be one unbroken block with no empty lines between `<svg>` and `</svg>`**. Use comments (`<!-- section: axes -->`) instead of blank lines if you need visual separation inside the SVG source.
Diagnostic: "Part of my SVG renders but the rest appears as code or escaped text" → blank-line-induced block break. Remove all blank lines between `<svg>` and `</svg>`.
## Minimal example (inline, Obsidian-friendly)
```markdown
Some prose before the diagram.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<g font-family="sans-serif" font-size="12" fill="#222">
<!-- inputs -->
<rect x="10" y="20" width="80" height="40" fill="#e8f0fe" stroke="#1a73e8" stroke-width="2"/>
<text x="50" y="45" text-anchor="middle">Input</text>
<!-- arrow -->
<line x1="90" y1="40" x2="130" y2="40" stroke="#1a73e8" stroke-width="2"/>
<!-- outputs -->
<rect x="130" y="20" width="60" height="40" fill="#fce8e6" stroke="#d93025" stroke-width="2"/>
<text x="160" y="45" text-anchor="middle">Output</text>
</g>
</svg>
Prose after.
```
Note: blank line before `<svg>`, blank line after `</svg>`, **no blank lines inside the SVG** (use `<!-- comments -->` for section separation), no fence, all styling via presentation attributes, font longhand on the group. This will render on Obsidian and similar permissive renderers. It will **not** render on GitHub `.md`. It will partially render on Gitea (the `<svg>` survives, the rest is stripped).
## Pre-commit checklist (inline SVG)
- [ ] Confirmed the target renderer allows SVG inline (not GitHub `.md`).
- [ ] `<svg>` block has a blank line before and after, no surrounding ` ``` ` fence.
- [ ] No blank lines between `<svg>` and `</svg>` (pulldown-cmark terminates the HTML block at blank lines).
- [ ] No `<style>`, `<title>`, `<use>`, `<foreignObject>` elements (don't survive Obsidian).
- [ ] No `class=` for styling, no CSS `font:` shorthand.
- [ ] Every shape has explicit `fill` and (where applicable) `stroke` / `stroke-width` as inline attributes.
- [ ] No `<script>`, `on*` handlers, or `javascript:` URLs.
- [ ] `width` ≤ 750px unless asked otherwise.
If the target is Gitea specifically and inline is required:
- [ ] Only `<svg>` + `<path>` elements used.
- [ ] No `fill`, `stroke`, `class`, `style`, `transform` attributes (stripped by default policy).
- [ ] OR confirmed the Gitea instance has `[markup.sanitizer.N]` extensions in `app.ini` permitting more.
@@ -0,0 +1,83 @@
# Layout and Legibility Rules for SVG Diagrams
These rules prevent the most common legibility failures in agent-generated SVG: text overflow, poor contrast, and missing margins. They apply to both external `.svg` files and inline SVG.
## Rule 1 — Estimate max-text-width before sizing a container
For any text that lives **inside** a bounded shape (cell, box, button), estimate the rendered width of the longest string:
- Rough bound: `text_width_px ≈ chars × font_size × 0.6` for proportional fonts at common weights.
- If the container is narrower than the longest text, choose one of:
- **Widen the container** (preferred when there's space).
- **Truncate to a short label** (e.g. a count, a code, or an abbreviation) and put the full text outside (legend, footnote table, sidebar).
- **Wrap to multiple `<tspan>` lines** when truncation loses meaning.
- SVG doesn't clip text by default — never assume CSS overflow rules apply.
Default to **counts + short labels inside the chart, full names in a legend below**. For dense categorical charts this is almost always the right answer.
## Rule 2 — Text-over-background needs a contrast contract
If a `<text>` element renders on top of an arbitrary background, declare which class of background it's designed for:
- **Light-bg text** (`fill: #1a1a1a` or similar): legible on whites, light grays, light pastels.
- **Dark-bg text** (`fill: #ffffff` or similar): legible on saturated mid-to-dark colors only.
- **High-contrast halo** (`paint-order="stroke fill"` + `stroke-width="3"` + `stroke="#fff"`): legible on anything; use when the background is unpredictable or varies.
Never apply one fill class across multiple background colors without verifying contrast for each. WCAG large-text minimum is **3:1**; for text-in-data-viz aim for **4.5:1** to leave margin.
When colors are picked by category, group them into a "dark fills" palette (use white text) and a "light fills" palette (use dark text). Don't mix.
## Rule 3 — Backgrounds for text on uncertain surfaces
When text is placed near or over a colored region whose color depends on data, give the text its own background:
- A `<rect>` behind the text, sized to its bounding box (compute as `chars × font_size × 0.6 + padding`), with `fill="#fff"` and a small `rx` for softness.
- Or a halo via `paint-order="stroke fill"` with a white stroke.
For category legends, axis labels, and chart titles — anything that sits in the chart's "frame" rather than inside a data shape — always give them a clean background or place them in the margin.
## Rule 4 — Reserve margin for rotated labels
Rotated text (typically column headers at `-30°` to `-45°`) extends beyond its anchor in both x and y. Rule of thumb: an N-character rotated header at θ degrees and font-size F needs:
- horizontal reach: `cos(θ) × chars × F × 0.6`
- vertical reach: `sin(θ) × chars × F × 0.6`
Plus the anchor offset. Always add 20% padding. If the rotated headers extend into the chart body, increase the chart's top margin until they don't.
## Rule 5 — Default chart pattern: sparse cells, full legend below
For categorical density / heatmap / matrix charts:
```
[ Title ]
[ Rotated category headers (with margin) ]
[ Row label │ count │ count │ . │ count │ count │ . ] (cells: just the number, color-coded)
[ Row label │ . │ count │ count │ . │ count │ . ]
[ ... ]
[ ]
[ Legend / details table: ]
[ Row × Category → action_a, action_b, action_c ]
[ Row × Category → action_d ]
[ ... ]
```
Counts inside cells; names in the table below. This pattern composes well across renderers and prevents the overflow trap entirely.
## Rule 6 — Stick to a small palette with declared roles
Categorical palettes should be picked once, with their **role** declared:
- "Dark fills" (use **white** text): `#1f77b4`, `#d62728`, `#9467bd`, `#8c564b`
- "Light fills" (use **dark** text): `#aec7e8`, `#ffbb78`, `#98df8a`
Avoid mid-tones (`#ff7f0e`, `#2ca02c`, `#e377c2`) unless the text color is matched per-color. If the chart needs more than 6 categories, that's usually a sign the categorisation is too fine — group first.
## Rule 7 — Sanity check the rendered output
Before saving, mentally render at the target display width (typically 600750px effective in Markdown), and ask:
- Can I read every label?
- Does any text overlap another shape?
- Is there a category whose color makes its text invisible?
- Are empty cells distinguishable from filled ones at a glance?
If any answer is "no", iterate before checking in.