excalidraw
Produce and edit .excalidraw files — a flat scene of elements (rectangles,
text, arrows) on an infinite canvas. Two ways to author, picked by scale:
- By hand (JSON) — for a handful of boxes, or editing an existing scene (recolor,
rename, re-wire, append). The element format and binding/grouping rules below are
what you need — and they're also what the programmatic path emits, so read them
either way.
- Programmatically — for anything bigger or that you'll maintain. The toolchain in
scripts/ builds scenes with Excalidraw's own
convertToExcalidrawElements (it owns the gotchas: reciprocal binding, container
labels, text sizing, z-order), renders a faithful SVG preview with exportToSvg,
and lints geometry. See Programmatic authoring below.
.excalidraw is Excalidraw's own on-disk JSON format. Mermaid is a separate
thing but a useful input: for standard shapes (flowchart, sequence, class, ER)
write Mermaid and convert it to a native Excalidraw scene — the editor's built-in
"Mermaid to Excalidraw", or fromMermaid() in scripts/. Either way the file you
then edit is Excalidraw JSON, which is what this skill covers.
When to use
- Creating a
.excalidraw file from scratch (architecture diagram, flow, boxes-and-arrows).
- Editing an existing scene: add a section, replace part of it, recolor/rename a box,
re-wire arrows.
- Reading a scene to summarize or reason about it (build an outline from the elements).
If a real Excalidraw editor is available and the user just wants to draw, prefer the
editor. This skill is for generating/editing the file directly — pick the by-hand
path for small or edit work, the programmatic path (next) for non-trivial diagrams.
Programmatic authoring (the preview-and-lint loop)
For non-trivial or maintained diagrams, don't hand-place dozens of elements — use the
JS toolchain in scripts/. It wraps the official Excalidraw functions,
so its output can't drift from the format:
convertToExcalidrawElements(skeleton) — Excalidraw's own compiler. Hand it
high-level shapes plus which boxes an arrow connects; it returns valid elements with
reciprocal binding, container labels, measured text, ids and z-order. The single
biggest correctness win — the Gotchas further down become its job, not yours.
exportToSvg(scene) — renders the real look (rough strokes, fonts), so previews
don't lie the way an approximate renderer would.
One-time setup (installs the headless toolchain + bundles @excalidraw/*; needs node +
npm, plus canvas/jsdom for headless text measurement):
sh scripts/setup.sh
Then loop — build → lint → render → look → iterate:
node examples/architecture.example.mjs # build with the Diagram builder
node scripts/lint-scene.mjs /tmp/arch.excalidraw # arrow crossings / label overflow / overlap
node scripts/render.mjs /tmp/arch.excalidraw # -> /tmp/arch.svg (faithful)
rsvg-convert -w 1200 /tmp/arch.svg -o /tmp/arch.png # rasterize, then actually LOOK
Looking at the render is the most important step. Valid JSON still produces
unreadable diagrams — arrows through unrelated boxes, labels off-card. The linter
catches the mechanical ones; your eyes catch the routing. Iterate on the builder code,
not on the JSON.
The Diagram builder (scripts/excalidraw-lib.mjs) gives card(), zone(), note()
and arrow(a, b, label, {waypoints, dashed, color}); arrow() binds both ends and
routes through absolute waypoints (keep them axis-aligned for clean orthogonal lines).
fromMermaid() converts Mermaid for standard diagram types (optional dependency).
The element-format reference below still matters — it's what the builder emits, what you
edit by hand on small scenes, and how you reason about an existing file.
The file envelope
Every .excalidraw file is this top-level object:
{
"type": "excalidraw",
"version": 2,
"source": "your-tool-name",
"elements": [ /* the scene, in draw order */ ],
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
"files": {}
}
type / version are fixed ("excalidraw" / 2 for the current schema).
source is a free string stamped by the writer (e.g. "my-tool").
elements is the whole scene — array order is paint order (later elements draw on
top). Put rectangles before the text/arrows that sit on them.
appState — keep it minimal; viewBackgroundColor is the only field most files need.
files — binary blobs for embedded images, keyed by file-id. Empty {} for diagrams.
The element model
Elements are a discriminated union on type. Every element carries this common block
(values below are the safe defaults):
| Field |
Type |
Default / notes |
id |
string |
Unique within the scene. Any string works ("el-1", a nanoid…). |
type |
string |
"rectangle" | "text" | "arrow" (others exist; these three cover most diagrams). |
x,y |
number |
Top-left on the canvas. Y grows downward, X rightward, px units. |
width,height |
number |
Bounding size. For arrows this is the extent of points. |
angle |
number |
Rotation in radians. 0 = upright. |
strokeColor |
string |
Outline/text color. Default #1e1e1e. |
backgroundColor |
string |
Fill. "transparent" for unfilled shapes. |
fillStyle |
string |
"solid" | "hachure" | "cross-hatch". Use "solid". |
strokeWidth |
number |
1 thin, 2 bold (default), 4 extra-bold. |
strokeStyle |
string |
"solid" | "dashed" | "dotted". |
roughness |
number |
Sketchiness: 0 architect (clean), 1 artist (default), 2 cartoonist. |
opacity |
number |
0–100. |
groupIds |
string[] |
Group membership — see Groups. [] = ungrouped. |
frameId |
string | null |
Owning frame id, or null. |
roundness |
object | null |
Corner/curve style — see per-type below. |
seed |
number |
Arbitrary integer (Excalidraw's hand-drawn RNG seed). Any int. |
versionNonce |
number |
Arbitrary integer; changes on every edit. |
version |
number |
Edit counter; start at 1, bump on each change. |
isDeleted |
boolean |
false. Soft-deleted elements stay in the file with true. |
boundElements |
array |
Back-references to bound arrows/labels — see Binding. |
updated |
number |
Epoch-ms timestamp (or any monotonic int for deterministic output). |
link |
string | null |
Optional hyperlink. |
locked |
boolean |
false. |
index (z-order): the Excalidraw editor adds a fractional-index string per element
("a0", "a1", …) to track stacking order. When hand-authoring, omit it — the
editor assigns indices on load from your array order. Do not set it on some elements
and not others; partial indices corrupt the z-order.
rectangle
{ "type": "rectangle", "roundness": { "type": 3 }, /* + common fields */ }
roundness: { "type": 3 } → rounded corners. roundness: null → sharp corners.
- A box with a label is a rectangle plus a separate text element (see Labels).
text
{
"type": "text",
"text": "Login service",
"originalText": "Login service",
"fontSize": 16,
"fontFamily": 2,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"lineHeight": 1.25,
"baseline": 13,
"autoResize": true,
"roundness": null
/* + common fields */
}
text is what renders; originalText mirrors it (Excalidraw keeps both — keep them
equal unless you know why not).
fontFamily: 1 = hand-drawn (Virgil), 2 = normal (Helvetica, default), 3 = code
(Cascadia/monospace).
width/height must match the rendered text or the editor will look wrong until it
recomputes. With no font metrics available, approximate:
width ≈ longestLineChars × fontSize × 0.58, height ≈ lineCount × fontSize × 1.25.
baseline ≈ round(fontSize × 0.8). Setting autoResize: true lets the editor correct
sizing on load.
textAlign: "left" | "center" | "right". verticalAlign: "top" | "middle" | "bottom".
arrow
{
"type": "arrow",
"points": [[0, 0], [0, 80]],
"lastCommittedPoint": null,
"startBinding": { "elementId": "el-1", "focus": 0, "gap": 4 },
"endBinding": { "elementId": "el-2", "focus": 0, "gap": 4 },
"startArrowhead": null,
"endArrowhead": "arrow",
"roundness": { "type": 2 }
/* + common fields */
}
points are relative to the arrow's own x/y. The first point is [0, 0]; the
arrow's x/y is that first point in absolute canvas coordinates. width/height
are the bounding extent of the points. A common mistake is storing absolute points —
don't.
- Multi-segment (orthogonal/elbow) arrows are just more points:
[[0,0],[0,40],[120,40]] draws down-then-right. Keep segments axis-aligned (purely
horizontal or vertical) for clean routing.
roundness: { "type": 2 } rounds the bends; null keeps sharp elbows.
endArrowhead/startArrowhead: "arrow" (default end), null (none), or "triangle",
"dot", "bar".
- Color arrows via
strokeColor (a muted #495057 reads well as default).
Binding — make arrows follow their shapes
Binding is two-way and reciprocal. To bind an arrow between two shapes you must touch
three places, or the arrow won't track when shapes move:
arrow.startBinding = { elementId: from.id, focus: 0, gap: 4 }
arrow.endBinding = { elementId: to.id, focus: 0, gap: 4 }
- push a back-reference into each shape's
boundElements:
from.boundElements.push({ id: arrow.id, type: "arrow" }) and the same for to.
focus (-1..1) is where along the bound edge the arrow attaches; 0 = centered.
gap is the px gap the arrowhead keeps from the shape (2–6 looks right).
- If you only set the arrow's bindings but not the shapes'
boundElements, the arrow
renders but detaches the moment a shape is dragged. Always do both directions.
- Prefer the builder.
diagram.arrow(a, b) does all three for you via the official
compiler — reach for hand-written bindings only when editing raw JSON. Either way,
lint-scene.mjs now fails (exit 1) on any arrow that is unbound, one-way, or dangling,
so run it before calling a scene done: an arrow that doesn't follow on move is an error,
not a style nit.
Labels — text inside a box (two ways)
A. Container-bound label (Excalidraw-native). The text becomes a child the editor
auto-centers and wraps inside the box:
- text:
containerId = rect.id, and position it inside the rect.
- rect:
boundElements includes { "type": "text", "id": text.id }.
The editor reflows the label when the box resizes. Best when a human will keep editing.
B. Free-floating label (deterministic). The text is an independent element placed by
absolute x/y inside the box, with containerId: null and not referenced in the
box's boundElements. Simpler to emit and fully predictable, but it won't auto-reflow.
Good for generated, static diagrams. A common pattern is a "card": a rounded rect + a title
text near the top-left + an optional smaller body text below it, all sharing the same
groupIds.
Groups
A group is just a shared id in every member's groupIds — there is no separate group
object. To group elements, give them all the same group-id string in groupIds. Nested
groups stack ids (outermost last). To draw a labelled "container" visually, add a dashed
rectangle behind the members (strokeStyle: "dashed", muted stroke, backgroundColor: "transparent") — it's a normal rectangle, optionally in the same group.
Programmatic path: diagram.group(ids, { title }) makes a real group from cards you
already placed — it stamps the shared groupId onto every part of each card (rect + title +
body) and draws the optional dashed frame + label behind them. Use it whenever blocks
should move together as a unit; pass the same ids you gave card(). (groupIds survives
convertToExcalidrawElements, so the builder just sets it on the skeleton.)
Coordinates & layout conventions
- Infinite canvas; pick any origin (e.g. start near
0,0). Y is down.
- Lay columns left→right by increasing
x; stack rows top→down by increasing y.
- Leave gutters between boxes (e.g. 80–90px between columns, ~28px between stacked boxes) so
arrows have room and don't cross unrelated shapes.
- Don't overlap boxes if you want arrows to read cleanly. Overlaps and arrows passing
through unrelated boxes are the usual cause of a messy scene.
- A readable, Excalidraw-friendly palette: default stroke
#1e1e1e; muted greys #495057
/ #868e96 / #adb5bd; accents blue #1971c2, orange #e8590c, violet #7048e8,
green #2f9e44. Use color to encode meaning (e.g. one hue per edge kind), not decoration.
Reading an existing scene
Parse the JSON and walk elements. To produce a compact outline (easier for a model to
reason over than raw JSON):
- rectangle → its label is the text whose
containerId equals the rect id, or a
free-floating text positioned inside the rect's bounds.
- arrow → resolve
startBinding.elementId / endBinding.elementId to their shapes' labels
and render Arrow: "A" → "B".
- Skip
isDeleted: true elements. Cap very large scenes (e.g. first ~200 elements).
Example outline line shapes: Rectangle "Login", Arrow: "Login" → "Session" (label: "auth").
Updating sections of a scene
The three operations you'll actually perform on an existing file:
1. Append a new block below existing content
Don't guess coordinates — measure and shift:
- Compute the union bounding box of the existing elements (
minX/minY and
maxX = max(x+width), maxY = max(y+height)).
- Compute the union box of the incoming elements.
dy = existing.maxY + gap − incoming.minY (a gap of ~60px reads well). dx = 0.
- Translate every incoming element by
(dx, dy) — just add to each x/y. Because arrow
points are relative to the arrow's own x/y, moving x/y moves the whole
arrow; no point rewriting. Bindings reference ids, so they survive the shift untouched.
- Concatenate:
existing.concat(shiftedIncoming).
2. Replace a region
- Identify the elements to drop (by id, or by a shared
groupIds entry).
- Remove them from
elements (hard delete), or set isDeleted: true to keep history.
- Re-wire dangling arrows: for any arrow whose
startBinding/endBinding pointed at a
removed shape, either delete the arrow too, or repoint the binding's elementId to the
replacement shape and update that shape's boundElements. Also remove stale entries from
the surviving shapes' boundElements that reference deleted arrows.
- Insert the replacement elements (and bind their new arrows per Binding).
3. Edit an element in place
Mutate fields directly, then signal the change so the editor reconciles cleanly:
- bump
version (version + 1), set versionNonce to a new integer, set updated to now.
- For text changes, update both
text and originalText, and recompute
width/height (or rely on autoResize: true).
- For move/resize, update
x/y/width/height; bound arrows follow automatically
via their bindings (no need to edit the arrow).
- To recolor, change
strokeColor / backgroundColor.
Gotchas (the things that actually break a scene)
The programmatic path (convertToExcalidrawElements) handles the first four
automatically — it sets reciprocal binding, relative points, text sizing and z-order
for you. These bite mainly when hand-authoring.
- One-way binding → arrows detach on drag. Always set the arrow's
*Binding and the
shapes' boundElements.
- Absolute arrow points → arrow renders in the wrong place. Points are relative; first
is
[0,0].
- Missing text
width/height → mis-sized labels until reload. Approximate them, or set
autoResize: true.
- Partial
index fields → corrupted z-order. Omit index entirely when hand-authoring.
- Overlapping boxes / arrows through unrelated shapes → unreadable diagram. Keep gutters
and route orthogonally.
- Soft-delete leftovers → a shape with
isDeleted: true still leaves boundElements
entries pointing at it elsewhere; clean those up if you want the bound arrows gone.
text/originalText drift → keep them equal unless intentionally different.
Reference & worked example
references/element-reference.md — exhaustive per-field tables, value enumerations
(fontFamily, arrowhead, roundness, appState keys), and binding/grouping semantics.
references/minimal-scene.excalidraw — a complete, loadable two-box-and-an-arrow scene
you can copy and adapt.
scripts/ — the programmatic toolchain: excalidraw-lib.mjs (the Diagram builder +
writeScene/writeSvg/fromMermaid), render.mjs (faithful SVG export), lint-scene.mjs
(geometry linter), setup.sh (one-time install + bundle). See scripts/README.md.
examples/architecture.example.mjs — a runnable end-to-end diagram built with the builder.
Source of truth
The element format above is the minimal subset that real Excalidraw loads, validated
against scenes from Excalidraw 0.18.1. The editor may add or carry extra fields (e.g.
index, custom per-element data) on round-trips; the fields documented here are sufficient
to author files the editor opens cleanly. When in doubt about a field this skill doesn't
cover, draw the shape once in the Excalidraw editor, save, and inspect the resulting JSON —
a round-tripped file is the ground truth.
1---2name: excalidraw3description: Author and edit Excalidraw scene files (`.excalidraw`): the JSON element model (rectangle, text, arrow), two-way arrow binding, ids/z-order, coordinates & sizing, groups, and reading/updating an existing scene. Two paths — by hand for small scenes/edits, or programmatically via `scripts/` which builds scenes with Excalidraw's own `convertToExcalidrawElements`, renders a faithful preview with `exportToSvg`, and lints geometry. Use whenever you create or modify a `.excalidraw` file directly (not through the editor UI). Mermaid is a supported INPUT for standard diagrams, not the file format.4---56# excalidraw78Produce and edit **`.excalidraw` files** — a flat scene of *elements* (rectangles,9text, arrows) on an infinite canvas. Two ways to author, picked by scale:1011- **By hand (JSON)** — for a handful of boxes, or editing an existing scene (recolor,12 rename, re-wire, append). The element format and binding/grouping rules below are13 what you need — and they're also what the programmatic path emits, so read them14 either way.15- **Programmatically** — for anything bigger or that you'll maintain. The toolchain in16 [`scripts/`](scripts/) builds scenes with Excalidraw's **own**17 `convertToExcalidrawElements` (it owns the gotchas: reciprocal binding, container18 labels, text sizing, z-order), renders a **faithful** SVG preview with `exportToSvg`,19 and lints geometry. See **Programmatic authoring** below.2021> `.excalidraw` is Excalidraw's own on-disk JSON format. **Mermaid** is a separate22> thing but a useful **input**: for standard shapes (flowchart, sequence, class, ER)23> write Mermaid and convert it to a native Excalidraw scene — the editor's built-in24> "Mermaid to Excalidraw", or `fromMermaid()` in `scripts/`. Either way the file you25> then edit is Excalidraw JSON, which is what this skill covers.2627## When to use2829- Creating a `.excalidraw` file from scratch (architecture diagram, flow, boxes-and-arrows).30- Editing an existing scene: add a section, replace part of it, recolor/rename a box,31 re-wire arrows.32- Reading a scene to summarize or reason about it (build an outline from the elements).3334If a real Excalidraw editor is available and the user just wants to *draw*, prefer the35editor. This skill is for **generating/editing the file directly** — pick the by-hand36path for small or edit work, the programmatic path (next) for non-trivial diagrams.3738## Programmatic authoring (the preview-and-lint loop)3940For non-trivial or maintained diagrams, don't hand-place dozens of elements — use the41JS toolchain in [`scripts/`](scripts/). It wraps the **official** Excalidraw functions,42so its output can't drift from the format:4344- `convertToExcalidrawElements(skeleton)` — Excalidraw's own compiler. Hand it45 high-level shapes plus which boxes an arrow connects; it returns valid elements with46 reciprocal binding, container labels, measured text, ids and z-order. The single47 biggest correctness win — the **Gotchas** further down become *its* job, not yours.48- `exportToSvg(scene)` — renders the **real** look (rough strokes, fonts), so previews49 don't lie the way an approximate renderer would.5051One-time setup (installs the headless toolchain + bundles `@excalidraw/*`; needs node +52npm, plus `canvas`/`jsdom` for headless text measurement):5354```sh55sh scripts/setup.sh56```5758Then loop — **build → lint → render → look → iterate**:5960```sh61node examples/architecture.example.mjs # build with the Diagram builder62node scripts/lint-scene.mjs /tmp/arch.excalidraw # arrow crossings / label overflow / overlap63node scripts/render.mjs /tmp/arch.excalidraw # -> /tmp/arch.svg (faithful)64rsvg-convert -w 1200 /tmp/arch.svg -o /tmp/arch.png # rasterize, then actually LOOK65```6667**Looking at the render is the most important step.** Valid JSON still produces68unreadable diagrams — arrows through unrelated boxes, labels off-card. The linter69catches the mechanical ones; your eyes catch the routing. Iterate on the builder code,70not on the JSON.7172The `Diagram` builder (`scripts/excalidraw-lib.mjs`) gives `card()`, `zone()`, `note()`73and `arrow(a, b, label, {waypoints, dashed, color})`; `arrow()` binds both ends and74routes through absolute waypoints (keep them axis-aligned for clean orthogonal lines).75`fromMermaid()` converts Mermaid for standard diagram types (optional dependency).7677The element-format reference below still matters — it's what the builder emits, what you78edit by hand on small scenes, and how you reason about an existing file.7980## The file envelope8182Every `.excalidraw` file is this top-level object:8384```json85{86 "type": "excalidraw",87 "version": 2,88 "source": "your-tool-name",89 "elements": [ /* the scene, in draw order */ ],90 "appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },91 "files": {}92}93```9495- `type` / `version` are fixed (`"excalidraw"` / `2` for the current schema).96- `source` is a free string stamped by the writer (e.g. `"my-tool"`).97- `elements` is the whole scene — **array order is paint order** (later elements draw on98 top). Put rectangles before the text/arrows that sit on them.99- `appState` — keep it minimal; `viewBackgroundColor` is the only field most files need.100- `files` — binary blobs for embedded images, keyed by file-id. Empty `{}` for diagrams.101102## The element model103104Elements are a discriminated union on `type`. **Every** element carries this common block105(values below are the safe defaults):106107| Field | Type | Default / notes |108|---|---|---|109| `id` | string | Unique within the scene. Any string works (`"el-1"`, a nanoid…). |110| `type` | string | `"rectangle" \| "text" \| "arrow"` (others exist; these three cover most diagrams). |111| `x`,`y` | number | Top-left on the canvas. Y grows **downward**, X rightward, px units. |112| `width`,`height` | number | Bounding size. For arrows this is the extent of `points`. |113| `angle` | number | Rotation in radians. `0` = upright. |114| `strokeColor` | string | Outline/text color. Default `#1e1e1e`. |115| `backgroundColor` | string | Fill. `"transparent"` for unfilled shapes. |116| `fillStyle` | string | `"solid" \| "hachure" \| "cross-hatch"`. Use `"solid"`. |117| `strokeWidth` | number | `1` thin, `2` bold (default), `4` extra-bold. |118| `strokeStyle` | string | `"solid" \| "dashed" \| "dotted"`. |119| `roughness` | number | Sketchiness: `0` architect (clean), `1` artist (default), `2` cartoonist. |120| `opacity` | number | `0`–`100`. |121| `groupIds` | string[] | Group membership — see **Groups**. `[]` = ungrouped. |122| `frameId` | string \| null | Owning frame id, or `null`. |123| `roundness` | object \| null | Corner/curve style — see per-type below. |124| `seed` | number | Arbitrary integer (Excalidraw's hand-drawn RNG seed). Any int. |125| `versionNonce` | number | Arbitrary integer; changes on every edit. |126| `version` | number | Edit counter; start at `1`, bump on each change. |127| `isDeleted` | boolean | `false`. Soft-deleted elements stay in the file with `true`. |128| `boundElements` | array | Back-references to bound arrows/labels — see **Binding**. |129| `updated` | number | Epoch-ms timestamp (or any monotonic int for deterministic output). |130| `link` | string \| null | Optional hyperlink. |131| `locked` | boolean | `false`. |132133> **`index` (z-order):** the Excalidraw editor adds a fractional-index string per element134> (`"a0"`, `"a1"`, …) to track stacking order. **When hand-authoring, omit it** — the135> editor assigns indices on load from your array order. Do *not* set it on some elements136> and not others; partial indices corrupt the z-order.137138### rectangle139140```json141{ "type": "rectangle", "roundness": { "type": 3 }, /* + common fields */ }142```143144- `roundness: { "type": 3 }` → rounded corners. `roundness: null` → sharp corners.145- A box with a label is a rectangle **plus** a separate text element (see **Labels**).146147### text148149```json150{151 "type": "text",152 "text": "Login service",153 "originalText": "Login service",154 "fontSize": 16,155 "fontFamily": 2,156 "textAlign": "left",157 "verticalAlign": "top",158 "containerId": null,159 "lineHeight": 1.25,160 "baseline": 13,161 "autoResize": true,162 "roundness": null163 /* + common fields */164}165```166167- `text` is what renders; `originalText` mirrors it (Excalidraw keeps both — keep them168 equal unless you know why not).169- `fontFamily`: `1` = hand-drawn (Virgil), `2` = normal (Helvetica, default), `3` = code170 (Cascadia/monospace).171- **`width`/`height` must match the rendered text** or the editor will look wrong until it172 recomputes. With no font metrics available, approximate:173 `width ≈ longestLineChars × fontSize × 0.58`, `height ≈ lineCount × fontSize × 1.25`.174 `baseline ≈ round(fontSize × 0.8)`. Setting `autoResize: true` lets the editor correct175 sizing on load.176- `textAlign`: `"left" | "center" | "right"`. `verticalAlign`: `"top" | "middle" | "bottom"`.177178### arrow179180```json181{182 "type": "arrow",183 "points": [[0, 0], [0, 80]],184 "lastCommittedPoint": null,185 "startBinding": { "elementId": "el-1", "focus": 0, "gap": 4 },186 "endBinding": { "elementId": "el-2", "focus": 0, "gap": 4 },187 "startArrowhead": null,188 "endArrowhead": "arrow",189 "roundness": { "type": 2 }190 /* + common fields */191}192```193194- **`points` are relative to the arrow's own `x`/`y`.** The first point is `[0, 0]`; the195 arrow's `x`/`y` *is* that first point in absolute canvas coordinates. `width`/`height`196 are the bounding extent of the points. A common mistake is storing absolute points —197 don't.198- Multi-segment (orthogonal/elbow) arrows are just more points:199 `[[0,0],[0,40],[120,40]]` draws down-then-right. Keep segments **axis-aligned** (purely200 horizontal or vertical) for clean routing.201- `roundness: { "type": 2 }` rounds the bends; `null` keeps sharp elbows.202- `endArrowhead`/`startArrowhead`: `"arrow"` (default end), `null` (none), or `"triangle"`,203 `"dot"`, `"bar"`.204- Color arrows via `strokeColor` (a muted `#495057` reads well as default).205206## Binding — make arrows follow their shapes207208Binding is **two-way and reciprocal**. To bind an arrow between two shapes you must touch209*three* places, or the arrow won't track when shapes move:2102111. `arrow.startBinding = { elementId: from.id, focus: 0, gap: 4 }`2122. `arrow.endBinding = { elementId: to.id, focus: 0, gap: 4 }`2133. push a back-reference into **each shape's** `boundElements`:214 `from.boundElements.push({ id: arrow.id, type: "arrow" })` and the same for `to`.215216- `focus` (`-1`..`1`) is where along the bound edge the arrow attaches; `0` = centered.217- `gap` is the px gap the arrowhead keeps from the shape (2–6 looks right).218- If you only set the arrow's bindings but **not** the shapes' `boundElements`, the arrow219 renders but detaches the moment a shape is dragged. Always do both directions.220- **Prefer the builder.** `diagram.arrow(a, b)` does all three for you via the official221 compiler — reach for hand-written bindings only when editing raw JSON. Either way,222 **`lint-scene.mjs` now fails (exit 1) on any arrow that is unbound, one-way, or dangling**,223 so run it before calling a scene done: an arrow that doesn't follow on move is an error,224 not a style nit.225226## Labels — text inside a box (two ways)227228**A. Container-bound label (Excalidraw-native).** The text becomes a child the editor229auto-centers and wraps inside the box:230- text: `containerId = rect.id`, and position it inside the rect.231- rect: `boundElements` includes `{ "type": "text", "id": text.id }`.232The editor reflows the label when the box resizes. Best when a human will keep editing.233234**B. Free-floating label (deterministic).** The text is an independent element placed by235absolute `x`/`y` inside the box, with `containerId: null` and **not** referenced in the236box's `boundElements`. Simpler to emit and fully predictable, but it won't auto-reflow.237Good for generated, static diagrams. A common pattern is a "card": a rounded rect + a title238text near the top-left + an optional smaller body text below it, all sharing the same239`groupIds`.240241## Groups242243A group is just a **shared id in every member's `groupIds`** — there is no separate group244object. To group elements, give them all the same group-id string in `groupIds`. Nested245groups stack ids (outermost last). To draw a labelled "container" visually, add a dashed246rectangle behind the members (`strokeStyle: "dashed"`, muted stroke, `backgroundColor:247"transparent"`) — it's a normal rectangle, optionally in the same group.248249**Programmatic path:** `diagram.group(ids, { title })` makes a real group from cards you250already placed — it stamps the shared groupId onto every part of each card (rect + title +251body) and draws the optional dashed frame + label behind them. Use it whenever blocks252should move together as a unit; pass the same ids you gave `card()`. (`groupIds` survives253`convertToExcalidrawElements`, so the builder just sets it on the skeleton.)254255## Coordinates & layout conventions256257- Infinite canvas; pick any origin (e.g. start near `0,0`). Y is **down**.258- Lay columns left→right by increasing `x`; stack rows top→down by increasing `y`.259- Leave gutters between boxes (e.g. 80–90px between columns, ~28px between stacked boxes) so260 arrows have room and don't cross unrelated shapes.261- **Don't overlap boxes** if you want arrows to read cleanly. Overlaps and arrows passing262 through unrelated boxes are the usual cause of a messy scene.263- A readable, Excalidraw-friendly palette: default stroke `#1e1e1e`; muted greys `#495057`264 / `#868e96` / `#adb5bd`; accents blue `#1971c2`, orange `#e8590c`, violet `#7048e8`,265 green `#2f9e44`. Use color to encode meaning (e.g. one hue per edge kind), not decoration.266267## Reading an existing scene268269Parse the JSON and walk `elements`. To produce a compact outline (easier for a model to270reason over than raw JSON):271272- rectangle → its label is the text whose `containerId` equals the rect id, **or** a273 free-floating text positioned inside the rect's bounds.274- arrow → resolve `startBinding.elementId` / `endBinding.elementId` to their shapes' labels275 and render `Arrow: "A" → "B"`.276- Skip `isDeleted: true` elements. Cap very large scenes (e.g. first ~200 elements).277278Example outline line shapes: `Rectangle "Login"`, `Arrow: "Login" → "Session" (label: "auth")`.279280## Updating sections of a scene281282The three operations you'll actually perform on an existing file:283284### 1. Append a new block below existing content285Don't guess coordinates — measure and shift:2861. Compute the union bounding box of the **existing** elements (`minX/minY` and287 `maxX = max(x+width)`, `maxY = max(y+height)`).2882. Compute the union box of the **incoming** elements.2893. `dy = existing.maxY + gap − incoming.minY` (a `gap` of ~60px reads well). `dx = 0`.2904. Translate every incoming element by `(dx, dy)` — just add to each `x`/`y`. Because arrow291 `points` are **relative** to the arrow's own `x`/`y`, moving `x`/`y` moves the whole292 arrow; no point rewriting. Bindings reference ids, so they survive the shift untouched.2935. Concatenate: `existing.concat(shiftedIncoming)`.294295### 2. Replace a region2961. Identify the elements to drop (by id, or by a shared `groupIds` entry).2972. Remove them from `elements` (hard delete), **or** set `isDeleted: true` to keep history.2983. **Re-wire dangling arrows:** for any arrow whose `startBinding`/`endBinding` pointed at a299 removed shape, either delete the arrow too, or repoint the binding's `elementId` to the300 replacement shape and update that shape's `boundElements`. Also remove stale entries from301 the surviving shapes' `boundElements` that reference deleted arrows.3024. Insert the replacement elements (and bind their new arrows per **Binding**).303304### 3. Edit an element in place305Mutate fields directly, then signal the change so the editor reconciles cleanly:306- bump `version` (`version + 1`), set `versionNonce` to a new integer, set `updated` to now.307- For **text** changes, update **both** `text` and `originalText`, and recompute308 `width`/`height` (or rely on `autoResize: true`).309- For **move/resize**, update `x`/`y`/`width`/`height`; bound arrows follow automatically310 via their bindings (no need to edit the arrow).311- To recolor, change `strokeColor` / `backgroundColor`.312313## Gotchas (the things that actually break a scene)314315The programmatic path (`convertToExcalidrawElements`) handles the first four316automatically — it sets reciprocal binding, relative points, text sizing and z-order317for you. These bite mainly when **hand-authoring**.318319- **One-way binding** → arrows detach on drag. Always set the arrow's `*Binding` **and** the320 shapes' `boundElements`.321- **Absolute arrow points** → arrow renders in the wrong place. Points are relative; first322 is `[0,0]`.323- **Missing text `width`/`height`** → mis-sized labels until reload. Approximate them, or set324 `autoResize: true`.325- **Partial `index` fields** → corrupted z-order. Omit `index` entirely when hand-authoring.326- **Overlapping boxes / arrows through unrelated shapes** → unreadable diagram. Keep gutters327 and route orthogonally.328- **Soft-delete leftovers** → a shape with `isDeleted: true` still leaves `boundElements`329 entries pointing at it elsewhere; clean those up if you want the bound arrows gone.330- **`text`/`originalText` drift** → keep them equal unless intentionally different.331332## Reference & worked example333334- `references/element-reference.md` — exhaustive per-field tables, value enumerations335 (fontFamily, arrowhead, roundness, appState keys), and binding/grouping semantics.336- `references/minimal-scene.excalidraw` — a complete, loadable two-box-and-an-arrow scene337 you can copy and adapt.338- `scripts/` — the programmatic toolchain: `excalidraw-lib.mjs` (the `Diagram` builder +339 `writeScene`/`writeSvg`/`fromMermaid`), `render.mjs` (faithful SVG export), `lint-scene.mjs`340 (geometry linter), `setup.sh` (one-time install + bundle). See `scripts/README.md`.341- `examples/architecture.example.mjs` — a runnable end-to-end diagram built with the builder.342343## Source of truth344345The element format above is the **minimal subset that real Excalidraw loads**, validated346against scenes from Excalidraw `0.18.1`. The editor may add or carry extra fields (e.g.347`index`, custom per-element data) on round-trips; the fields documented here are sufficient348to author files the editor opens cleanly. When in doubt about a field this skill doesn't349cover, draw the shape once in the Excalidraw editor, save, and inspect the resulting JSON —350a round-tripped file is the ground truth.