29 KiB
Layout Math
SVG has no auto-layout. Every coordinate, width, and height is hand-computed. Most diagram failures trace back to text overflowing its container or elements landing outside the viewBox. This file is the arithmetic you run before writing any <rect>.
The coordinate system
- viewBox:
0 0 680 H. Width is always 680. Compute H from content. - Safe area:
x ∈ [40, 640],y ∈ [40, H - 40]. Leave 40px of breathing room on every edge. - Usable width: 600 px (640 − 40).
- Pixel units are 1:1. The
width="100%"withviewBox="0 0 680 H"means the browser scales the whole coordinate space to fit the container. A 14px font is really 14px. Character widths below assume this 1:1 ratio.
Text width estimation
SVG <text> never auto-wraps. If you put more characters in a box than it can hold, they overflow visibly. Use these character-to-pixel multipliers, calibrated on Anthropic Sans:
| Case | Approx. width per character |
|---|---|
14px latin, weight 500 (th, titles) |
~8 px |
14px latin, weight 400 (t, body) |
~8 px |
12px latin, weight 400 (ts, subtitles) |
~7 px |
| 14px CJK (Chinese, Japanese, Korean) | ~15 px (~2× Latin) |
| 12px CJK | ~13 px (~1.9× Latin) |
Special characters: chemical formulas (C₆H₁₂O₆), math symbols (∑ ∫ √), subscripts, superscripts all take the same horizontal space as normal characters — don't assume subscripts are narrower. For labels that mix Latin and formulas, add 30–50% padding to your width estimate.
Empirical calibration samples
Real measurements of Anthropic Sans at the sizes this skill uses. The multipliers above are the safe ceilings derived from these samples — not the averages. Letter composition matters: narrow letters like l/i/f push the per-char average down, tall letters like B/J/P/Q push it up.
| Text | Chars | Weight | Size | Measured width | Per-char |
|---|---|---|---|---|---|
Authentication Service |
22 | 500 | 14 px | 167 px | ~7.6 |
Background Job Processor |
24 | 500 | 14 px | 201 px | ~8.4 |
Detects and validates incoming tokens |
37 | 400 | 14 px | 279 px | ~7.5 |
forwards request to |
19 | 400 | 12 px | 123 px | ~6.5 |
データベースサーバー接続 |
12 | 400 | 14 px | 181 px | ~15.1 |
The formula uses 8 for 14px Latin and 7 for 12px Latin because those are the safe ceilings, not the average. A label like "Background Job Processor" at 8.4 px/char would overflow a box sized from a 7.6 px/char average — always round up, never down.
Worked sanity check: "Detects and validates incoming tokens" (37 chars, ts class at 12px) → formula gives 37 × 7 + 24 = 283 px. Measured 279 px + 24 px padding = 303 px. The formula is ~7% conservative, which is the correct direction for rect sizing — bias toward extra padding, never toward clipping.
Rect sizing formula
For a two-line node (title + subtitle) the width must fit whichever line is longer:
width_needed = max(title_chars × 8, subtitle_chars × 7) + 24
The + 24 is 12px of horizontal padding on each side. If the text is centered, that's 12px of clear air between the text edge and the rect stroke — looks balanced.
For a single-line node:
width_needed = title_chars × 8 + 24
For CJK content, replace the 8 with 15 and the 7 with 13.
Round up to the nearest 10px for cleaner coordinates. A box that "needs" 167px becomes 170px.
Worked examples
"JWT authentication"(18 chars, title) →18 × 8 + 24 = 168→ round to 170"Validates user credentials"(26 chars, subtitle) in a two-line box with title"Login service"(13 chars, 8-wide) →max(13 × 8, 26 × 7) + 24 = max(104, 182) + 24 = 206→ round to 210"微服务架构"(5 CJK chars, title) →5 × 15 + 24 = 99→ round to 100"发送 HTTP 请求 forwards request"(mixed, treat CJK chars as 15px and Latin as 8px) →3×15 + 4×8 + 13×8 = 45 + 32 + 104 = 181 + spaces + 24 ≈ 220
Rule of thumb: if you find yourself adding characters to a label, recheck the width. It's easy to write "Authentication" once, size the box to fit, then expand it to "Authentication service" and forget to resize.
viewBox height calculation
After placing every element, compute max_y:
- For every
<rect>, computey + height. - For every
<text>, the bottom isy + 4(roughly — 4px descent below the baseline). - For every
<circle>, it'scy + r. max_yis the maximum across all of these.H = max_y + 20
Don't guess. A 20px buffer below the last element keeps things tight without clipping descenders.
Don't leave huge empty space at the bottom. If the diagram ends at y=280, H should be 300 — not 560. Excess whitespace below the content feels like a rendering bug.
Tier packing (horizontal rows)
When placing N boxes in a horizontal row, check total width before you start writing coordinates:
total = N × box_width + (N - 1) × gap
box_width: chosen to fit the longest label in that rowgap: 20px minimum between adjacent boxes
Required: total ≤ 600 (the usable width). If not, one of three things must give:
- Shrink box_width — cut subtitles, shorten titles
- Wrap to 2 rows
- Split into an overview diagram + detail diagrams (one per sub-flow)
Worked example — four consumer boxes
- 4 boxes, each containing
"Consumer N"(10 chars title) and"Processes events"(16 chars subtitle) - Needed width per box:
max(10×8, 16×7) + 24 = max(80, 112) + 24 = 136→ round to 140 - 4 boxes × 140 + 3 gaps × 20 = 560 + 60 = 620 → overflows by 20
- Fix: shrink to 130 each (check:
max(10×8, 16×7) + 24 = 136, so 130 is tight — cut subtitle to 14 chars or drop it entirely) - Or: drop to 3 boxes per row, stack the fourth below
- Or: 4 × 130 + 3 × 20 = 580, centered at
x = 40 + (600-580)/2 = 50→ boxes at x = 50, 200, 350, 500
Swim-lane (sequence) layout
Sequence diagrams have their own coordinate system — actor columns ("lanes") along the top, dashed lifelines running down, numbered horizontal messages between them. The numbers below are fixed conventions so every sequence diagram in the skill feels consistent.
Lane widths and centers
N = number of actors. Lane width lane_w, header rect width header_w = lane_w − 20, lifeline x = lane center. Gap between lanes = 20.
| N | lane_w | header_w | First offset | Lane centers (x) |
|---|---|---|---|---|
| 2 | 290 | 270 | 40 | 185, 495 |
| 3 | 186 | 166 | 40 | 133, 340, 547 |
| 4 | 140 | 120 | 30 | 100, 260, 420, 580 |
| 5 | 112 | 92 | 26 | 82, 214, 346, 478, 610 |
| 6 | 100 | 80 | 30 | 80, 190, 300, 410, 520, 630 |
Hard cap: 4 actors (comfortable), 6 actors (maximum). 5 is allowed only when every actor title is ≤12 characters and has no subtitle. 6 requires every title ≤9 characters (9 × 8 + 24 = 96, just inside header_w=100) and no subtitle. N=6 also collapses the intra-lane gap (the 20px padding between lane_w and header_w becomes 0 — the headers pack shoulder-to-shoulder) and borrows 10px of left-edge margin and all of the right-edge margin: the rightmost header rect sits flush against x=680. Use N=6 only when every actor is genuinely essential — otherwise merge actors or split the diagram. More than 6 doesn't fit 680px legibly.
Vertical geometry (fixed for all sequence diagrams)
actor header box y = 40..88 (height 48, rx 8)
title (th) y = 60 (dominant-baseline="central")
role subtitle (ts) y = 78 (optional, one line, ≤16 chars)
lifeline start y = 92
first message row arrow_y[0] = 120
message row pitch 44 px → arrow_y[k] = 120 + 44 · k
lifeline tail y = last_arrow_y + 24
viewBox height
header_bottom = 88
first_arrow_y = 120
last_arrow_y = 120 + 44 × (M − 1) # M = number of messages
lifeline_bottom = last_arrow_y + 24
note_bottom = lifeline_bottom + 16 + 48 # only if side note is at the bottom
H = max(lifeline_bottom, note_bottom) + 20
Message arrows
Each message is a horizontal <line> at y = arrow_y[k], running from the sender lifeline x to the receiver lifeline x with a 6px offset so the arrowhead doesn't touch the dashes:
- Left-to-right:
x1 = sender_x + 6,x2 = receiver_x − 6 - Right-to-left:
x1 = sender_x − 6,x2 = receiver_x + 6
Use class="arr-{ramp}" where {ramp} matches the sender's actor ramp. Always set marker-end="url(#arrow)".
Message labels
Label sits above the arrow at y = arrow_y − 10 with text-anchor="middle", class="ts", and the number prefix baked into the text ("1. Click login", not separate tspans). Label x is the midpoint of sender and receiver: (sender_x + receiver_x) / 2.
Max characters that fit in a label between two adjacent lanes (e.g. lanes 1↔2 for N=4):
label_chars_max ≈ (|sender_x − receiver_x| − 8) / 7
For N=4 adjacent lanes (|Δx|=160): ~21 chars. For the longest cross-diagram leap (lane 1↔4, |Δx|=480): ~67 chars. In practice, aim for ≤30 chars — short messages read faster, even when there's room.
Self-messages
A self-message is a 16×24 <rect> using the actor's c-{ramp} class, horizontally centered on the lifeline: rect_x = lifeline_x − 8, rect_y = arrow_y − 12. The label sits to the left of the rect (so it stays inside the diagram for the rightmost lane) at x = rect_x − 8 with text-anchor="end", class="ts", dominant-baseline="central", and the same numbered format.
Side note
A class="box" rect with a th title and optional ts subtitle. Default size 240×48, rx="10". Placement depends on N:
- N ≤ 3: top-left at
(40, 40)— tucks beside the first actor header, no overlap becausefirst_offset ≥ 40. - N ≥ 4: bottom-left at
(40, lifeline_bottom + 16)— the top is packed tight against the leftmost header, so use the bottom margin.
Worked example — OAuth 2.0 (N=4, M=10)
lane centers 100, 260, 420, 580 (lane_w=140, header_w=120)
header_bottom 88
first_arrow_y 120
last_arrow_y 120 + 44×9 = 516 (10 messages)
lifeline_bottom 540
note_bottom 540 + 16 + 48 = 604 (note at bottom)
H 604 + 20 = 624
viewBox "0 0 680 624"
Label budgets: adjacent lanes (|Δx|=160) fit ~21 chars; "Redirect with client_id, scope" at 30 chars needs |Δx| ≥ 218, so it must span at least 2 lanes — put it between Client app (260) and Auth server (420), |Δx|=160 — too tight. Shorten to "Redirect (client_id + scope)" (28 chars, still over). Shorten further to "Redirect + client_id" (20 chars). Always verify every label against its span before finalizing.
Centering inside the safe area
For N boxes of width w with gap g:
total = N × w + (N - 1) × g
offset = 40 + (600 - total) / 2
box[i].x = offset + i × (w + g)
Centered content feels deliberate. Left-aligned content feels incomplete.
Sibling subsystem containers (2-up)
The structural subsystem-architecture pattern (see structural.md → "Subsystem architecture pattern") places two sibling dashed-border containers side by side, each holding a short internal flow. The 2-up layout sacrifices the default 40px horizontal safe margin in exchange for a usable container interior wide enough to hold 3–4 flowchart nodes.
Standard 2-up geometry at viewBox width 680:
container_w = 315 # each sibling
container_gap = 10 # space between siblings
left_margin = 20 # leaner than the usual 40
container_A.x = 20
container_B.x = 345 # left_margin + container_w + container_gap
rightmost edge = 345 + 315 = 660 # leaves 20px to x=680
interior_w = 315 − 40 = 275 # after 20px padding on each side
interior_A.x = 40
interior_B.x = 365
Inside each 275-wide interior, node widths follow the normal text-width formula but should cap at ~235 so the L-bend connectors have clear vertical channels on both sides. Typical nodes are 180 wide centered, or 150 wide for a 2-column row.
A labeled cross-system arrow bridges the 10px container gap:
from B_right_node.right_edge → to A_left_node.left_edge
arrow_x1 = interior_B.rightmost_node.x + 10
arrow_x2 = interior_A.leftmost_node.x − 10
arrow_y = matching row y # both nodes on the same tier
label at (x_mid, arrow_y − 6), class="ts", text-anchor="middle"
The cross-system arrow label is the one place where a flowchart-style connector must have a label — the reader cannot infer the relationship from the container titles alone. Keep the label to ≤3 words.
Dashed container borders use inline stroke-dasharray="4 4" on the <rect> rather than a CSS class — dashed is a one-off container-styling concern, not a reusable shape property, so the template's class list stays stable.
Worked example — Pi session + Background analyzer
Two siblings, each with 3 internal nodes stacked vertically:
container A "Pi session" container B "Background analyzer"
x=20, y=40, w=315, h=260 x=345, y=40, w=315, h=260
rx=20, stroke-dasharray="4 4" same styling
Inside A (x_center = 177): Inside B (x_center = 502):
[ User input ] y=96 [ Analyze patterns ] y=96
[ Pi responds ] y=170 [ Write summary ] y=170
[ (session ends) ] y=244 [ Update memory ] y=244
Cross-system arrow: B top row → A top row at y=118
x1 = 347 (container B left edge + 2)? No — from interior node B₁ left edge
Actually: from B₁.x − 10 (headed right-to-left pointing at A₁)
Details below in structural.md's worked example.
viewBox H for 3 stacked rows: last node bottom y = 244 + 44 = 288; container bottom 40 + 260 = 300; H = 300 + 20 = 320.
Sub-pattern coordinate tables
Fixed coordinate tables for the sub-patterns introduced in structural.md, illustrative.md, and sequence.md. Use these values verbatim — they're tuned so each sub-pattern respects the 680 viewBox and the 40 safe margin.
Bus topology geometry
For structural.md → "Bus topology sub-pattern". A central horizontal bar bridges N agents above it and N agents below it.
| Element | Coordinates |
|---|---|
| Bus bar | x=40 y=280 w=600 h=40 rx=20 |
| Bus label baseline | y=304, text-anchor="middle" at x=340 |
| Top agent row (box top) | y=80, h=60 |
| Bottom agent row (box top) | y=400, h=60 |
| Default viewBox H | 500 |
Agent centers by row count:
| Agents per row | Box width | Centers (x) |
|---|---|---|
| 2 | 180 | 180, 500 |
| 3 | 140 | 170, 340, 510 |
| 4 | 110 | 120, 260, 420, 560 |
Publish/subscribe arrow pair: two vertical lines per agent, offset by 8px horizontally (one at agent_cx − 8, one at agent_cx + 8). Publish goes down from agent to bar; Subscribe goes up from bar to agent. Labels sit at the channel midpoint y, text-anchor="end" (Publish on left) and text-anchor="start" (Subscribe on right).
Radial star geometry (3 / 4 / 5 / 6 satellites)
For structural.md → "Radial star topology sub-pattern". A central hub surrounded by N peripheral satellites, each bidirectionally connected.
Hub (always fixed):
| Element | Coordinates |
|---|---|
| Hub rect | x=260 y=280 w=160 h=80 rx=10 |
| Hub center | (340, 320) |
| viewBox H | 560 |
Satellite positions:
| N | Satellites (box top-left → w=160 h=60) |
|---|---|
| 3 | (60, 120), (460, 120), (260, 460) |
| 4 | (60, 120), (460, 120), (60, 460), (460, 460) |
| 5 | (260, 60), (60, 200), (460, 200), (60, 460), (460, 460) |
| 6 | (20, 120), (260, 60), (500, 120), (20, 460), (260, 520), (500, 460) |
Satellite centers: add (80, 30) to the box top-left to get (cx, cy).
Arrow pairs: each satellite connects to the hub with two single-headed arrows offset by 8 px perpendicular to the arrow direction. For a satellite at (sx, sy) and hub center (340, 320), compute the direction vector, normalize to unit length, then offset each line by (+4dy, −4dx) and (−4dy, +4dx) respectively (where (dx, dy) is the unit direction).
For straightforward placement, use these pre-computed endpoint offsets for N=4:
| Satellite center | Outbound start (satellite → hub) | Outbound end |
|---|---|---|
TL (140, 150) |
(224, 176) |
(264, 276) |
TR (540, 150) |
(456, 176) |
(416, 276) |
BL (140, 490) |
(224, 464) |
(264, 364) |
BR (540, 490) |
(456, 464) |
(416, 364) |
Inbound (hub → satellite) is the reverse of each outbound, offset perpendicular by 8.
Spectrum geometry
For illustrative.md → "Spectrum / continuum". A 1-D axis with end labels, tick points, option boxes, and italic captions.
Fixed vertical positions:
| Element | Coordinates |
|---|---|
| Eyebrow (optional) | y=50, text-anchor="middle" at x=340 |
| End labels (L / R) | y=120, at x=80 (start) / x=600 (end) |
| Axis line | x1=80 y1=140 x2=600 y2=140 |
| Tick circles | cy=140, r=6 |
| Option box top | y=200, h=60 |
| Caption line 1 | y=292, italic ts |
| Caption line 2 | y=308, italic ts |
| Default viewBox H | 332 |
Tick and option-box x positions by tick count:
| Ticks | Tick centers (x) | Option box x (w=120) |
|---|---|---|
| 2 | 200, 480 | 140, 420 |
| 3 | 160, 340, 520 | 100, 280, 460 |
| 4 | 140, 280, 420, 560 | 80, 220, 360, 500 |
| 5 | 120, 240, 360, 480, 600 (w=100) | 70, 190, 310, 430, 550 |
Axis uses marker-start and marker-end (both ends arrowed to signal no natural direction). Axis stroke class is arr (neutral gray) — never a color ramp.
Sweet-spot tick and its option box use c-green or c-amber; all other ticks and boxes stay c-gray. Captions cap at 24 characters per line, 2 lines per option.
Parallel rounds geometry
For sequence.md → "Parallel independent rounds". Stacked independent call/response rounds without shared lifelines.
Stacked rounds variant (full-width):
| Element | Coordinates |
|---|---|
| Source actor | x=60 y=120 w=120 h=60 |
| Round k action box | x=340 y=100 + (k-1)×80 w=160 h=48 |
| Round k call arrow | y = 124 + (k-1)×80 |
| Round k response | y = 134 + (k-1)×80 |
| Call label | y = 115 + (k-1)×80 |
| Response label | y = 145 + (k-1)×80 |
| Row pitch | 80 |
Arrow x endpoints: x1 = source_right_edge + 6 = 186, x2 = action_left_edge − 6 = 334 (going right); reverse for the return arrow.
Script-wrapper variant:
| Element | Coordinates |
|---|---|
| Source actor | x=60 y=120 w=120 h=60 |
| Script wrapper | x=240 y=100 w=140 h=260 rx=6 |
{ } label |
(310, 124), class th |
| Divider line | x1=256 y1=140 x2=364 y2=140 |
| Wrapped band k | x=252 y=152 + (k-1)×60 w=116 h=48 |
| External action box | x=440 y=200 w=160 h=48 (optional) |
| Default viewBox H | 380 |
Up to 3 wrapped bands inside the script wrapper (with script_h = 260). Grow script_h by 60 for each additional band.
Inside a 315-wide subsystem container (half-width), scale the coordinates:
| Element | Container A (x=20) |
|---|---|
| Source actor | x=40 y=120 w=100 h=50 |
| Round k action | x=200 y=104 + (k-1)×64 w=120 h=40 |
| Row pitch | 64 |
Multi-line box body line heights
For structural.md → "Multi-line box body". A three-line box used for advisor-role labels: title + italic role + meta.
| Line | y-offset from rect_y |
Class |
|---|---|---|
| Title | +22 |
th |
| Role | +42 |
ts + italic |
| Meta | +62 |
ts muted |
| Min h | 80 |
Row pitch is 20. For a box at y=140, lines sit at 162, 182, 202. Box height never drops below 80 — if the role or meta line is absent, use a standard 56-tall two-line box instead, don't shrink a three-line box.
Annotation circle on connector geometry
For illustrative.md → "Annotation circle on connector". A labeled circle sitting above a pair of bidirectional arrows between two subjects.
| Element | Coordinates |
|---|---|
| Subject A box | x=80 y=160 w=180 h=56 |
| Subject B box | x=440 y=160 w=180 h=56 |
| A → B arrow | (266, 184) → (434, 184), arr |
| B → A arrow | (434, 204) → (266, 204), arr |
| Arrow pair y midpoint | 194 |
| Annotation circle | outer c="box" r=30 at (cx, cy) = (350, 150) |
| Inner pill | x=cx−28 y=cy+12 w=56 h=20 rx=10 (uses ramp) |
| Connector line | (cx, cy+30) → (cx, 194), arr (no marker) |
Label goes inside the pill at (cx, cy+26), th, centered. Subject A/B labels use th at (subject_cx, subject_cy).
Container loop geometry (simple flowchart)
For flowchart.md → "Loop container". An outer rounded rect framing a simple flowchart.
| Element | Coordinates |
|---|---|
| Container rect | x=20 y=40 w=640 h=H−60 rx=20, class box |
| Title | (340, 72), class th |
| Subtitle | (340, 92), class ts |
| First inner box y | ≥ 116 |
| Inner safe area | x ∈ [40, 620] |
| Bottom padding | 20px |
After placing the inner flow, compute inner_bottom = max y of any inner element, then H = inner_bottom + 40 and container_h = H − 60.
Arrow routing
Every arrow has a start point (source box edge) and an end point (target box edge), with an optional bend in between.
Direct arrow — when source and target are aligned on an axis (same x for vertical, same y for horizontal):
<line x1="200" y1="76" x2="200" y2="120" class="arr" marker-end="url(#arrow)"/>
Leave 10px between the arrow's endpoint and the target box's top edge so the arrowhead doesn't touch the border.
L-bend arrow — when the direct line would cross another box:
<path d="M x1 y1 L x1 ymid L x2 ymid L x2 y2" class="arr" fill="none" marker-end="url(#arrow)"/>
ymidis a horizontal "channel" chosen to thread cleanly between the two levels- Always include
fill="none"on<path>used as a connector — SVG defaults to black fill
Collision check. Before finalizing every arrow, trace its path against every rect you've already placed:
for each rect R in the diagram:
if rect R is not the source or target of this arrow:
does the arrow's line segment intersect the interior of R?
if yes: use an L-bend to route around R
This is the #1 diagram failure mode. Arrows slashing through unrelated boxes.
Connection-point convention. Every arrow anchors to the midpoint of a box edge (top, bottom, left, or right), never to a corner. With rx="6" or rx="12" rounded corners, the actual corner curve occupies the first ~6–12px of each edge — so stay ≥20px from any corner when picking a connection point. An arrow that enters a box 4px from its corner reads as "touching the rounded curve" instead of "attached to the edge", and the arrowhead looks smeared. For a 180-wide rect, valid top-edge connection points sit at x ∈ [rect_x + 20, rect_x + 160]; the outer 20px on each side belong to the corner radius.
Multi-arrow stagger. When two or more arrows terminate on the same edge of the same box from different sources (three fan-in arrows hitting a single aggregator; two feedback lines returning to a start node), stagger their arrival points by ≥12px along the target edge. Example: three arrows fanning into the top edge of a 180-wide box sit at target_x − 24, target_x, target_x + 24. Same rule applies to horizontal arrows converging on a left/right edge (stagger the y by ≥12px). Without the stagger, the arrowheads stack on top of each other and the reader can't tell how many incoming edges there are — a 3-to-1 fan-in reads as a single thick arrow.
text-anchor safety
text-anchor controls which point of the text aligns to the x coordinate:
"start"— x is the left edge. Text extends right."middle"— x is the horizontal center. Text extends both ways."end"— x is the right edge. Text extends left.
Danger: text-anchor="end" at low x values. If x=50 and the label is 200px wide, the text starts at x = -150 — outside the viewBox. Check: label_chars × 8 < x must hold for text-anchor="end" labels.
Default to text-anchor="start" on the left side and "middle" in the center. Use "end" only when you've verified the x coordinate is high enough.
Vertical text centering inside a rect
Every <text> inside a rect needs dominant-baseline="central". Without it, SVG treats y as the baseline — the glyph body sits ~4px above where you think and descenders hit the next line.
<rect x="100" y="20" width="180" height="44" rx="6" class="c-blue"/>
<text class="th" x="190" y="42" text-anchor="middle" dominant-baseline="central">Login</text>
For a rect at (x, y, w, h):
- Single line centered:
text_x = x + w/2,text_y = y + h/2,dominant-baseline="central" - Two lines (44 + 14 layout inside a 56-tall box):
- Title:
text_y = y + 20(center of top 40px) - Subtitle:
text_y = y + 40(center of bottom 16px) - Both use
dominant-baseline="central"
- Title:
Quick-reference sizes
Standard dimensions that work well and keep the diagram rhythm consistent:
| Element | Size |
|---|---|
| Single-line node height | 44 px |
| Two-line node height | 56 px |
| Default node width | 180 px (adjust up) |
| Minimum gap between nodes | 20 px horizontal, 60 px vertical |
| Container padding | 20 px |
| Rect corner radius | rx="6" (subtle), rx="12" (container), rx="20" (outer container) |
| Connector stroke width | 1.5 px (set by .arr) |
| Diagram border stroke | 0.5 px |
| Viewport safe margin | 40 px on each edge |
Stick to these unless you have a concrete reason to deviate.