Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .claude/scripts/rebuild-diagrams.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Rebuild diagram .png + .svg outputs from .excalidraw sources.
#
# Walks docs/*.excalidraw, regenerates the sibling .png + .svg for each file
# whose source is newer than its outputs (or whose outputs don't exist).
# Skips files that are already up-to-date.
#
# Usage:
# .claude/scripts/rebuild-diagrams.sh # incremental (only stale)
# .claude/scripts/rebuild-diagrams.sh --force # force-rebuild everything
# .claude/scripts/rebuild-diagrams.sh path.excalidraw # one file
#
# Requires the excalidraw-diagram skill's Playwright setup. If you've never
# rendered before:
# cd .claude/skills/excalidraw-diagram/references
# uv sync
# uv run playwright install chromium

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RENDER_SCRIPT="$REPO_ROOT/.claude/skills/excalidraw-diagram/references/render_excalidraw.py"
DOCS_DIR="$REPO_ROOT/docs"

if [ ! -f "$RENDER_SCRIPT" ]; then
echo "ERROR: render script not found at $RENDER_SCRIPT" >&2
exit 1
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

FORCE=0
TARGETS=()
for arg in "$@"; do
case "$arg" in
--force|-f) FORCE=1 ;;
*.excalidraw) TARGETS+=("$arg") ;;
*) echo "ignoring unknown arg: $arg" >&2 ;;
esac
done

# If no explicit targets, walk every .excalidraw under docs/.
if [ ${#TARGETS[@]} -eq 0 ]; then
while IFS= read -r -d '' f; do
TARGETS+=("$f")
done < <(find "$DOCS_DIR" -maxdepth 2 -name "*.excalidraw" -type f -print0 2>/dev/null)
fi

if [ ${#TARGETS[@]} -eq 0 ]; then
echo "No .excalidraw files found under $DOCS_DIR/"
exit 0
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# A file is stale if either output (.png or .svg) is older than the source, or
# either output doesn't exist.
is_stale() {
local src="$1"
local base="${src%.excalidraw}"
local png="${base}.png"
local svg="${base}.svg"
[ ! -f "$png" ] && return 0
[ ! -f "$svg" ] && return 0
[ "$src" -nt "$png" ] && return 0
[ "$src" -nt "$svg" ] && return 0
return 1
}

rebuilt=0
skipped=0
failed=0
cd "$REPO_ROOT/.claude/skills/excalidraw-diagram/references"
for src in "${TARGETS[@]}"; do
rel="${src#$REPO_ROOT/}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if [ "$FORCE" -eq 0 ] && ! is_stale "$src"; then
echo " [skip] $rel (up-to-date)"
skipped=$((skipped + 1))
continue
fi
echo " [build] $rel"
if uv run python "$RENDER_SCRIPT" "$src" >/dev/null 2>&1; then
rebuilt=$((rebuilt + 1))
else
echo " FAILED — try running the command directly to see the error:"
echo " uv run python $RENDER_SCRIPT $src"
failed=$((failed + 1))
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
done

echo
echo "Rebuilt: $rebuilt Skipped: $skipped Failed: $failed"

# Exit non-zero if anything failed so this script is CI-friendly.
[ "$failed" -eq 0 ]
98 changes: 94 additions & 4 deletions .claude/skills/excalidraw-diagram/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,36 @@ Guide the eye: typically left→right or top→bottom for sequences, radial for
### Connections Required
Position alone doesn't show relationships. If A relates to B, there must be an arrow.

---
### Text Width Validation (Prevent Overlap)

**The #1 cause of unreadable diagrams is text rendered wider than its container.** SVG `<text>` does NOT wrap automatically — long strings spill into adjacent elements and produce overlapping garbage. This is especially common in multi-column layouts where each cell's available width is narrower than authors anticipate.

**Compute width before placing.** For monospace fonts at typical sizes:

| Font size | Approx. px per character |
|-----------|--------------------------|
| 12px | ~7.2 |
| 14px | ~8.4 |
| 16px | ~9.6 |
| 18px | ~10.8 |
| 22px | ~13.2 |

So a 150-character explanatory line at 14px is ≈ **1260 px wide**. If your two-column layout gives each column only 840 px, that line overflows by ~420 px directly into the adjacent column's title.

## Text Rules
**For every long text element, before committing it to JSON or SVG:**

1. Count the string length (characters).
2. Multiply by the per-character width for the chosen font size.
3. Compare against the available container width.
4. If it overflows, pick one:
- **Shorten the string** until it fits with ~10% margin.
- **Break the text into multiple `<text>` elements** at explicit line breaks (each element = one wrapped line at a stepped `y`).
- **Restructure to a wider container** — collapse a 2-column layout into 1 column if all the long content sits in one section.
- **Use `<foreignObject>` with HTML `<div>`** so the browser handles wrapping. Adds complexity, but works.

**Multi-column layout rule of thumb:** only use multi-column when *every* text element in a column is ≤ ~80 characters at the chosen font. Tool names, short labels, and short notes are fine. Paragraph-length explanations belong in a single column.

**Caught-in-the-wild example.** A "Gaps" section put 10 items in two 840-px-wide columns. Each gap had a one-line "Fix" description averaging 140 characters at 14px (≈ 1175 px wide). Result: left-column fix lines streamed into right-column titles, producing visually garbled text on GitHub. Fix was restructuring to a single full-width column — everything fit on one line each with 1690 px of room. **The cost of computing widths up front is one minute; the cost of fixing it after the fact is reflow + re-render + screenshot review.**
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

**CRITICAL**: The JSON `text` property contains ONLY readable words.

Expand Down Expand Up @@ -444,6 +471,63 @@ See `references/element-templates.md` for copy-paste JSON templates for each ele

---

## SVG Output for GitHub Embedding

When a diagram is destined for GitHub markdown (README, `docs/*.md`), the `.excalidraw` source alone isn't enough — you need a sibling `.svg` because GitHub renders SVG inline in markdown views. PNG is fine for screenshots, but SVG is what scales/zooms in the browser without quality loss. Two paths to producing the SVG:

### Path A — Render via Playwright (preferred)

The `references/render_excalidraw.py` script writes both `.png` and `.svg` output side-by-side:

```bash
cd .claude/skills/excalidraw-diagram/references
uv run python render_excalidraw.py docs/diagram.excalidraw
# produces docs/diagram.png AND docs/diagram.svg
```

Path A's SVG output goes through Excalidraw's own `exportToSvg()`, so layout/fonts/colors are guaranteed to match the source. Use this whenever it works.

### Path B — Hand-written SVG (fallback when Playwright fails)

When the render script can't run (`esm.sh` module-load timeout on slow networks, no Playwright dependency available, etc.), hand-write the SVG. **GitHub renders SVG with three failure modes that you must counter:**

**1. Explicit `width` and `height` attributes are mandatory.** Without them, GitHub downscales the SVG to fit the markdown column width (~900 px on desktop). An 1800-px-wide design becomes 50% scale, and 14 px text becomes 7 px on screen — unreadable. Fix:
```xml
<svg viewBox="0 0 1800 1500" width="1800" height="1500" ...>
```
Trade-off: the SVG renders at natural size with a horizontal scroll bar instead of fitting the column. Worth it — text readability beats single-screen viewing.

**2. Explicit white background rect is mandatory.** Without it, the SVG renders with a transparent background. On GitHub's dark theme, text colored for white background (typical `#374151`, `#64748b`) becomes nearly invisible. Fix — put this as the first child of `<svg>`:
```xml
<rect width="1800" height="1500" fill="#ffffff"/>
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**3. Single-column for any text >80 chars.** SVG `<text>` doesn't wrap. See the **Text Width Validation** section earlier — multi-column layouts only work for short labels. If your content has paragraph-length lines, stack vertically.

**4. Font sizes in hand-written SVG should be 16-22 px range** (title 22-36 px). Below 14 px tends to be illegible at GitHub's render size even with explicit `width`/`height`.

### When the `.excalidraw` and `.svg` drift

If the `.excalidraw` source is one design and the `.svg` is a different, hand-written design (because the render kept failing while you needed to ship), **document the drift inline in the doc that references them**, e.g.:

> Source: `dev-loop.excalidraw` is the highway skeleton; `dev-loop.svg` is the full hand-written diagram (Playwright was unavailable when this was authored).

Don't pretend they match when they don't — that becomes a future debugging tax.

### Rebuilding diagrams in bulk

To keep all sibling `.png`/`.svg` outputs in sync with their `.excalidraw` sources, use the rebuild script:

```bash
.claude/scripts/rebuild-diagrams.sh # incremental (only regenerates stale outputs)
.claude/scripts/rebuild-diagrams.sh --force # rebuild every diagram regardless of mtime
.claude/scripts/rebuild-diagrams.sh docs/specific-diagram.excalidraw # single file
```

The script walks `docs/*.excalidraw`, checks each source's mtime against its `.png` and `.svg` outputs, and only re-renders the stale ones. Useful after editing `.excalidraw` files in the Excalidraw VS Code extension (which updates the source but not the sibling outputs).

---

## Render & Validate (MANDATORY)

You cannot judge a diagram from JSON alone. After generating or editing the Excalidraw JSON, you MUST render it to PNG, view the image, and fix what you see — in a loop until it's right. This is a core part of the workflow, not a final check.
Expand Down Expand Up @@ -544,9 +628,15 @@ uv run playwright install chromium

### Visual Validation (Render Required)
21. **Rendered to PNG**: Diagram has been rendered and visually inspected
22. **No text overflow**: All text fits within its container
22. **No text overflow**: All text fits within its container — width computed up front per "Text Width Validation"
23. **No overlapping elements**: Shapes and text don't overlap unintentionally
24. **Even spacing**: Similar elements have consistent spacing
25. **Arrows land correctly**: Arrows connect to intended elements without crossing others
26. **Readable at export size**: Text is legible in the rendered PNG
27. **Balanced composition**: No large empty voids or overcrowded regions
27. **Balanced composition**: No large empty voids or overcrowded regions

### GitHub SVG (if the diagram is referenced from markdown)
28. **Sibling `.svg` exists**: Markdown that references the `.excalidraw` also has a `.svg` (either Playwright-rendered or hand-written) — GitHub renders SVG inline in markdown views
29. **Explicit `width` and `height` attributes**: SVG has `width="X" height="Y"` matching the viewBox so GitHub doesn't downscale text below readable size
30. **Explicit white background rect**: First child of `<svg>` is `<rect width="X" height="Y" fill="#ffffff"/>` so text is readable on GitHub's dark theme
31. **Fonts ≥ 14 px in body text, ≥ 16 px preferred**: smaller becomes illegible at GitHub's render
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Render Excalidraw JSON to PNG using Playwright + headless Chromium.
"""Render Excalidraw JSON to PNG (and a GitHub-ready SVG) using Playwright + headless Chromium.

For each input .excalidraw file, writes:
- <name>.png — the screenshot, for embedding via Markdown <img> or direct viewing
- <name>.svg — the same diagram as SVG markup, post-processed for GitHub rendering
(explicit width/height attrs + white background rect — see SKILL.md
"SVG Output for GitHub Embedding")

Usage:
cd .claude/skills/excalidraw-diagram/references
Expand Down Expand Up @@ -156,19 +162,68 @@ def render(
# Wait for render completion signal
page.wait_for_function("window.__renderComplete === true", timeout=15000)

# Screenshot the SVG element
# Screenshot the SVG element to PNG
svg_el = page.query_selector("#root svg")
if svg_el is None:
print("ERROR: No SVG element found after render.", file=sys.stderr)
browser.close()
sys.exit(1)

svg_el.screenshot(path=str(output_path))

# Also save the SVG markup as a sibling file. GitHub renders SVG inline in
# markdown views (PNG must be embedded with <img>; SVG can also be linked).
# Post-process to add the GitHub-friendly attributes documented in
# SKILL.md "SVG Output for GitHub Embedding":
# 1. explicit width + height (so GitHub doesn't downscale text below readable)
# 2. white background rect (so text is readable on dark theme)
svg_markup = page.evaluate("document.querySelector('#root svg').outerHTML")
svg_output_path = output_path.with_suffix(".svg")
svg_markup = _make_github_friendly(svg_markup)
svg_output_path.write_text(svg_markup, encoding="utf-8")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

browser.close()

return output_path


def _make_github_friendly(svg_markup: str) -> str:
"""Post-process Excalidraw's exportToSvg output so it renders well on GitHub.

Two adjustments (see SKILL.md "SVG Output for GitHub Embedding"):
1. Ensure the root <svg> has explicit width/height attributes matching the
viewBox — without them, GitHub downscales to fit the markdown column and
text becomes illegible.
2. Insert a white <rect> as the first child of <svg> so text colored for a
light background stays readable on GitHub's dark theme.
"""
import re

# Parse the root <svg> tag attributes.
match = re.match(r"<svg([^>]*)>", svg_markup)
if not match:
return svg_markup # malformed; leave alone
attrs = match.group(1)

# Extract viewBox dimensions. Excalidraw always emits a viewBox.
vb_match = re.search(r'viewBox="\s*([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)"', attrs)
if vb_match:
_, _, vb_w, vb_h = vb_match.groups()
# Add width/height if missing.
if 'width=' not in attrs:
attrs += f' width="{vb_w}"'
if 'height=' not in attrs:
attrs += f' height="{vb_h}"'
# Build the white bg rect using viewBox dimensions.
bg_rect = f'<rect width="{vb_w}" height="{vb_h}" fill="#ffffff"/>'
else:
bg_rect = '<rect width="100%" height="100%" fill="#ffffff"/>'

# Reconstruct: open tag with merged attrs, then inject bg rect as first child.
new_open = f"<svg{attrs}>{bg_rect}"
return svg_markup.replace(match.group(0), new_open, 1)


def main() -> None:
parser = argparse.ArgumentParser(description="Render Excalidraw JSON to PNG")
parser.add_argument("input", type=Path, help="Path to .excalidraw JSON file")
Expand All @@ -182,7 +237,10 @@ def main() -> None:
sys.exit(1)

png_path = render(args.input, args.output, args.scale, args.width)
svg_path = png_path.with_suffix(".svg")
print(str(png_path))
if svg_path.exists():
print(str(svg_path))


if __name__ == "__main__":
Expand Down
Loading