edododraw 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1782 @@
1
+ # EDodoDraw — complete documentation (v0.1.1)
2
+
3
+ > A 100% code/syntax-to-diagram engine with the Excalidraw hand-drawn aesthetic, magic-move camera, scriptable + real-time annotations, animated arrows, and first-class Mermaid import.
4
+ >
5
+ > This ONE file is the full, LLM-consumable documentation for EDodoDraw. It
6
+ > concatenates every guide and every runnable example, generated verbatim
7
+ > from the source repo — nothing is summarised or omitted.
8
+
9
+ - Live site (rendered docs + live demos): https://vivmagarwal.github.io/edododraw/
10
+ - npm package: https://www.npmjs.com/package/edododraw (`npm i edododraw`)
11
+ - Source repo: https://github.com/vivmagarwal/edododraw
12
+
13
+ What EDodoDraw is: a 100% code/syntax-to-diagram engine with the Excalidraw
14
+ hand-drawn look, a magic-move camera, scriptable + real-time annotations,
15
+ animated arrows, and first-class Mermaid import. You write `.edd` source; it
16
+ renders a diagram. Use it via the DSL (see "Language reference") and/or embed
17
+ it in an app via the `EdodoDraw` facade (see "Embed in your app").
18
+
19
+ ## Contents
20
+
21
+ 1. Language reference
22
+ 2. Camera & timeline
23
+ 3. Annotations
24
+ 4. Import & export
25
+ 5. Embed in your app
26
+ 6. Extend & make plugins
27
+ 7. Architecture
28
+ 8. Development standards
29
+ 9. Runnable examples (.edd)
30
+
31
+ ==============================================================================
32
+
33
+ # ═══ Language reference (docs/DSL_LANGUAGE_GUIDE.md) ═══
34
+
35
+ # EDodoDraw Language Guide (`.edd`)
36
+
37
+ The complete reference for **EDodoDraw code** — the text syntax that compiles 100% to a diagram. Written to be equally readable by humans and generatable by LLMs.
38
+
39
+ > Implementation: `src/engine/dsl/` (lexer → parser → compiler). This guide documents what the compiler **actually accepts today**. Runnable examples live in `examples/`.
40
+
41
+ ---
42
+
43
+ ## 1. Mental model
44
+
45
+ A program has two halves:
46
+
47
+ 1. **Structure** — declared once inside `scene { … }`: nodes, edges, groups, styles, layout. Mermaid-friendly (arrow glyphs, `id[Label]` sugar, `:::class`) with clean `{ key: value }` attribute blocks for styling.
48
+ 2. **Choreography** (optional) — a `timeline { … }` of ordered `beat`s. Each beat is a keyframe of **camera** (fit/focus/zoom/pan), **annotate** (highlight/underline/point-at/…), and **reveal** (show/hide). The engine "magic-moves" between beats.
49
+
50
+ Minimal program:
51
+
52
+ ```edd
53
+ scene {
54
+ a[Start] --> b[Do work] --> c{OK?}
55
+ c -->|yes| d(Done)
56
+ c -->|no| a
57
+ }
58
+ ```
59
+
60
+ Everything else is optional layering on top.
61
+
62
+ ---
63
+
64
+ ## 2. Top-level statements
65
+
66
+ | Statement | Purpose |
67
+ |---|---|
68
+ | `edd 1.0` | Optional version marker (first line). |
69
+ | `meta { title: "…", background: "#…" }` | Diagram metadata. |
70
+ | `theme name { tokens { $x: #hex, … } }` | Named theme + reusable `$tokens`. |
71
+ | `style .name { … }` / `style .name extends .other { … }` | Reusable style class. |
72
+ | `defaults { node { … } edge { … } }` | Defaults applied to every node/edge. |
73
+ | `scene [name] { … }` | Structure. Multiple `scene` blocks merge. |
74
+ | `annotate ["label"] { … }` | Always-on annotations (outside the timeline). |
75
+ | `timeline [name] { … }` | Choreography (beats). |
76
+ | `overrides { id at (x, y) [size (w, h)] … }` | Pinned positions/sizes (usually machine-written by direct editing — see §11). |
77
+ | `mermaid """ … """` | Import a raw Mermaid diagram (see IMPORT_AND_EXPORT_GUIDE.md). |
78
+
79
+ Comments: `// line`, `%% line` (Mermaid-style), `/* block */` (nestable).
80
+
81
+ ---
82
+
83
+ ## 3. Nodes — three equivalent forms
84
+
85
+ ```edd
86
+ // 1. Mermaid sugar (shape from the bracket kind)
87
+ db[(Postgres)] // cylinder
88
+ q{{Load Balancer}} // hexagon
89
+ ok([Success]) // pill / stadium
90
+
91
+ // 2. Keyword form (explicit shape keyword first) — best for LLMs
92
+ cylinder db "Postgres"
93
+ hexagon lb "Load Balancer"
94
+
95
+ // 3. Explicit form
96
+ node db "Postgres" as cylinder
97
+ ```
98
+
99
+ ### Shape sugar table
100
+
101
+ | Sugar | Shape | Sugar | Shape |
102
+ |---|---|---|---|
103
+ | `[x]` | rectangle | `[(x)]` | cylinder |
104
+ | `(x)` | round-rectangle | `((x))` | circle |
105
+ | `([x])` | pill / stadium | `{x}` | diamond |
106
+ | `[[x]]` | rectangle | `{{x}}` | hexagon |
107
+ | `[/x/]` | parallelogram | `[/x\]` | trapezoid |
108
+
109
+ ### Full shape keyword list
110
+
111
+ `rect` · `round-rect` · `ellipse` · `circle` · `diamond` (`decision`) · `triangle` · `hexagon` · `parallelogram` · `trapezoid` · `cylinder` (`db`) · `cloud` · `document` · `note` · `actor` · `pill` (`stadium`) · `text` · `star`
112
+
113
+ Unknown shape names resolve through the **shape plugin registry** (`registerShape`) — see DEVELOPMENT_STANDARDS.md.
114
+
115
+ ### Node attributes
116
+
117
+ ```edd
118
+ rect api "API Service" {
119
+ fill: green // palette name or #hex → soft bg + matching stroke
120
+ stroke: #1971c2
121
+ fillStyle: hachure // hachure | cross-hatch | solid | zigzag | dots | none
122
+ strokeWidth: bold // thin | medium | bold | thick | <number>
123
+ strokeStyle: dashed // solid | dashed | dotted
124
+ roughness: artist // architect(0) | artist(1) | cartoonist(2) | <number>
125
+ roundness: 16 // corner radius (px)
126
+ font: hand // hand | normal | code
127
+ fontSize: 22
128
+ opacity: 0.9 // 0..1 or 0..100
129
+ at: (1180, 40) // absolute world position
130
+ pin: true // exclude from auto-layout (pinned)
131
+ size: (180, 80) // explicit width,height
132
+ tags: [edge, critical] // selectable via .edge / .critical
133
+ }
134
+ ```
135
+
136
+ Apply style classes with `:::` — `rect api "API" :::card :::critical`.
137
+
138
+ ### Named colors
139
+
140
+ Stroke palette: `black gray red pink grape violet blue cyan teal green lime yellow orange brown white`.
141
+ `fill: green` yields the pleasant Excalidraw soft-green box with a matching darker-green outline. `transparent`/`none` → no fill.
142
+
143
+ ---
144
+
145
+ ## 4. Edges
146
+
147
+ ```edd
148
+ a --> b // arrow
149
+ a --> b "label" // trailing label
150
+ a -->|mid label| b // Mermaid mid-label
151
+ a -> b -> c // chain → two edges
152
+ a -> b & c // fan-out → a→b and a→c
153
+ a@(1,0.5) -> b.north // anchored endpoints (see §7)
154
+ edge e1: a --> b "named" // give the edge an id
155
+ ```
156
+
157
+ ### Glyph table
158
+
159
+ | Glyph | Line | End arrowhead | Notes |
160
+ |---|---|---|---|
161
+ | `--` `---` | solid | none | plain connector |
162
+ | `->` `-->` | solid | arrow | canonical |
163
+ | `<-` `<--` | solid | arrow at start | reversed |
164
+ | `<->` `<-->` | solid | arrows both | bidirectional |
165
+ | `-.->` | dashed | arrow | async |
166
+ | `..>` | dotted | arrow | dependency |
167
+ | `==>` | solid (thick) | arrow | emphasis |
168
+ | `~>` | solid (curved) | arrow | **animated `flow` by default** |
169
+ | `--o` `-o` | solid | circle | association |
170
+ | `--x` `-x` | solid | bar | termination |
171
+
172
+ ### Edge attributes
173
+
174
+ ```edd
175
+ a -> b {
176
+ color: #1971c2
177
+ strokeWidth: bold
178
+ strokeStyle: dashed
179
+ startArrow: dot // none arrow triangle bar dot circle diamond crow …
180
+ endArrow: triangle
181
+ curve: curved // straight | curved | orthogonal | elbow | bezier | arc
182
+ animate: dash-march // see §5
183
+ }
184
+ ```
185
+
186
+ Parallel edges between the same pair automatically fan out so they never overlap.
187
+
188
+ ---
189
+
190
+ ## 5. Animated arrows
191
+
192
+ Set `animate: <kind>` on any edge. EDodoDraw ships far more than Excalidraw:
193
+
194
+ | Kind | Effect |
195
+ |---|---|
196
+ | `flow` | dashes flow along the path |
197
+ | `dash-march` | marching ants |
198
+ | `draw-on` | the line draws itself in (loops) |
199
+ | `comet` | bright head with a fading trail + glow |
200
+ | `gradient-flow` | animated multi-color gradient stroke |
201
+ | `electric` | fast jittered dashes |
202
+ | `pulse` | opacity/width pulse |
203
+
204
+ `glow`→comet, `caravan`→dash-march, `wiggle`→flow are accepted aliases. Speed: `animate: flow { speed: 1.4 }`.
205
+
206
+ ---
207
+
208
+ ## 6. Layout
209
+
210
+ ```edd
211
+ scene {
212
+ layout dag { direction: down, gap: 64, rankGap: 110 }
213
+
214
+ }
215
+ ```
216
+
217
+ | Kind | Behavior |
218
+ |---|---|
219
+ | `dag` / `tree` / `flow` | layered graph via dagre (respects `direction`) |
220
+ | `grid` | row-major grid |
221
+ | `radial` | hub + ring (`center: id`) |
222
+ | `manual` / `free` | use each node's `at:` position |
223
+
224
+ `direction`: `down`(TB) · `up`(BT) · `right`(LR) · `left`(RL). Nodes with `pin: true` (or `at:` set) are excluded from auto-layout and kept fixed. Groups are clustered so their members stay together.
225
+
226
+ ---
227
+
228
+ ## 7. Connection anchors (richer than Excalidraw's 4)
229
+
230
+ Attach an edge endpoint to a specific point on a node with `.anchor` or `@(u,v)`:
231
+
232
+ - Compass: `.n .s .e .w .ne .nw .se .sw .c` (aliases `.top .bottom .left .right .center`).
233
+ - Side fraction: `.top:0.3` (30% along the top edge), also `.right:0.75`, `.bottom:0.5`, `.left:0.2`.
234
+ - Normalized: `@(0.5, 1.0)` — u,v in 0..1 of the node box.
235
+ - Angle: `.angle:45` — degrees from the center (0 = east, clockwise).
236
+
237
+ ```edd
238
+ gw.s -> api.n // bottom of gw → top of api
239
+ api@(1, 0.5) -> db.top:0.3 // right-middle of api → 30% along db's top edge
240
+ ```
241
+
242
+ Omit the anchor for automatic border attachment aimed at the other endpoint.
243
+
244
+ ---
245
+
246
+ ## 8. Groups
247
+
248
+ ```edd
249
+ scene {
250
+ group services "Application Services" {
251
+ round-rect auth "Auth"
252
+ round-rect api "API"
253
+ }
254
+ }
255
+ ```
256
+
257
+ Draws a labelled dashed frame around its members. A group is addressable as one target (e.g. `camera focus services`, `spotlight services`).
258
+
259
+ ---
260
+
261
+ ## 9. Annotations
262
+
263
+ Annotate elements in an always-on `annotate { … }` block or inside a timeline beat.
264
+
265
+ | Command | Example |
266
+ |---|---|
267
+ | `highlight` | `highlight cdn { color: yellow }` |
268
+ | `underline` | `underline api { color: #2563eb, style: wavy }` |
269
+ | `strike` | `strike legacy { color: red }` |
270
+ | `box` (over a set) | `box [gateway, api, db] "critical path" { color: red }` |
271
+ | `circle-mark` | `circle-mark queue "retry here"` |
272
+ | `point-at` | `point-at gateway "single entry" { from: ne, color: #2563eb }` |
273
+ | `callout` | `callout db "source of truth" { placement: bottom }` |
274
+ | `spotlight` | `spotlight services { dim: 0.72 }` |
275
+ | `note-marker` | `note-marker db "1"` |
276
+
277
+ `from` / `placement` take a cardinal (`n s e w ne …`). Annotations anchor to their target and track the camera. See ANNOTATIONS_GUIDE.md for the real-time editor and round-trip.
278
+
279
+ ---
280
+
281
+ ## 10. Timeline (magic-move presentation)
282
+
283
+ ```edd
284
+ timeline story {
285
+ beat overview "The whole system" {
286
+ camera fit-all over 800ms
287
+ reveal { show all with fade-in }
288
+ narrate: "End-to-end request path."
289
+ }
290
+
291
+ beat data "Where state lives" {
292
+ camera focus [db, cache, queue] zoom 1.7 ease ease-in-out
293
+ annotate {
294
+ callout db "primary of record" { placement: right }
295
+ spotlight [db, cache, queue] { dim: 0.7 }
296
+ }
297
+ narrate: "All writes funnel through Postgres."
298
+ hold: 2s
299
+ }
300
+ }
301
+ ```
302
+
303
+ ### Camera operations (inline or block form)
304
+
305
+ | Op | Inline | Block |
306
+ |---|---|---|
307
+ | Frame all | `camera fit-all` | `camera { }` |
308
+ | Focus target(s) | `camera focus [a,b] zoom 1.6` | `camera { focus: [a,b], zoom: 1.6 }` |
309
+ | Absolute zoom | `camera zoom 1.3` | `camera { zoom: 1.3 }` |
310
+ | Pan | `camera pan (x, y)` | `camera { pan: (x,y) }` |
311
+ | Reset | `camera reset` | — |
312
+
313
+ Shared modifiers: `zoom N` · `over <ms|s>` · `ease <easing>` · `pad N`.
314
+ Easings: `linear ease ease-in ease-out ease-in-out back-out anticipate spring` — plus `magic` (an alias for the tuned spring). `spring(…)` is accepted and animates with the tuned spring; `cubic-bezier(…)` is accepted syntax but currently falls back to the default `ease-in-out` (parameters for both are reserved for a future release).
315
+
316
+ ### Reveal / hide
317
+
318
+ `reveal { show all }`, `reveal { hide legacy }`, `reveal { show .critical with pop }`. Visibility is **sticky** across beats until changed. Beat annotations are cleared at the start of each beat (unless in the always-on `annotate` block).
319
+
320
+ ### Beat properties
321
+
322
+ `narrate: "caption"` (speaker note shown under the diagram), `hold: 2s` (dwell before auto-advance during Play).
323
+
324
+ ---
325
+
326
+ ## 11. Overrides — where direct edits live
327
+
328
+ The layout engine positions nodes automatically. When you **drag, resize, add, rename, restyle, or delete elements directly on the canvas** (in the playground, or via the editor API — see INTEGRATION_GUIDE.md), those changes are written straight back into the source so the code stays the single source of truth. Geometry lands in an `overrides { … }` block:
329
+
330
+ ```edd
331
+ scene {
332
+ layout dag { direction: down }
333
+ rect a "A"
334
+ rect b "B"
335
+ a --> b
336
+ }
337
+
338
+ overrides {
339
+ a at (120, 40) // pin a node's top-left corner (world units)
340
+ b at (120, 220) size (160, 90) // …and optionally its width/height
341
+ }
342
+ ```
343
+
344
+ - Each entry is `id at (x, y)` with an optional `size (w, h)`. Coordinates are the node's top-left in world units; `id` may be any node — declared, auto-created from an edge, or Mermaid-imported.
345
+ - A pinned node keeps its coordinates; the layout still routes edges and places any *un-pinned* nodes around it.
346
+ - The block is **machine-managed**: dragging a node rewrites its entry, and the engine freezes the other nodes' current positions into the block at the same time so the layout doesn't reshuffle under you. You can hand-edit or delete entries freely — remove the whole block to hand control back to auto-layout.
347
+ - Restyle/rename/add/delete edits patch the node's declaration and edges in place (e.g. a fill swatch upserts `{ fill: green }`); only position/size use `overrides`.
348
+
349
+ ---
350
+
351
+ ## 12. Diagnostics
352
+
353
+ The compiler recovers from errors and reports many at once, each with `line:col`, a stable code (`E-EDGE-NOOP`, `E-STMT`, `W-UNKNOWN-KEY`, …), and a hint. A bad statement never blanks the diagram — valid statements still render.
354
+
355
+ ---
356
+
357
+ ## 13. LLM authoring tips
358
+
359
+ - Prefer the **keyword node form** (`cylinder db "…"`) and the **block form** for camera/annotations — they're unambiguous.
360
+ - One statement per line. Attributes are `{ key: value, … }` (commas or newlines both separate).
361
+ - Declare structure once in `scene`; reference ids everywhere else. Forward references are fine.
362
+ - Colors: use palette names (`green`, `blue`) for the Excalidraw look, or `#hex`.
363
+ - To animate a connector, add `animate: flow` (or use the `~>` glyph).
364
+ - To present, add a `timeline` of `beat`s with `camera focus …` — that's the differentiator.
365
+
366
+ See `examples/*.edd` for complete, runnable programs.
367
+
368
+ ==============================================================================
369
+
370
+ # ═══ Camera & timeline (docs/CAMERA_AND_TIMELINE_GUIDE.md) ═══
371
+
372
+ # Camera & Timeline Guide
373
+
374
+ How EDodoDraw does "magic-move" — smooth, scriptable camera moves and step-by-step presentations.
375
+
376
+ ## Architecture
377
+
378
+ The camera is a `{ cx, cy, zoom }` transform (world point centered in the viewport). It is applied as the SVG `transform` on the world `<g>`:
379
+
380
+ ```
381
+ translate(vw/2, vh/2) · scale(zoom) · translate(-cx, -cy)
382
+ ```
383
+
384
+ - **Math** — `src/engine/camera/fit.ts` (`cameraForBBox`: frame any bbox with padding).
385
+ - **Easing** — `src/engine/camera/easing.ts` (`linear ease ease-in ease-out ease-in-out back-out anticipate spring`; `spring` is a tuned overshoot for magic-move).
386
+ - **Controller** — `src/engine/camera/controller.ts` drives interruptible, retargetable rAF tweens. Position eases linearly; **zoom interpolates in log space** so fast/slow zooms feel natural. Duration auto-scales with travel distance + zoom ratio when not specified.
387
+
388
+ ```ts
389
+ const controller = new CameraController(renderer);
390
+ await controller.fitAll(scene, { padding: 80 });
391
+ await controller.focus(scene, ["db", "cache"], { zoom: 1.7, easing: "spring" });
392
+ controller.zoomBy(1.2, screenPoint); // wheel zoom around cursor
393
+ controller.panByScreen(dx, dy); // drag pan
394
+ ```
395
+
396
+ Mid-flight `animateTo` retargets smoothly from the current interpolated state — no snap.
397
+
398
+ ## Interaction (built into the canvas)
399
+
400
+ - **Wheel** → zoom around the cursor.
401
+ - **Drag** → pan.
402
+ - Any user gesture pauses the timeline player.
403
+
404
+ ## Timeline player
405
+
406
+ `src/engine/timeline/player.ts` plays `scene.steps` (compiled from a `timeline { beat … }` block — see [DSL_LANGUAGE_GUIDE §10](DSL_LANGUAGE_GUIDE.md)). Each beat diffs against the running state:
407
+
408
+ | Channel | Rule |
409
+ |---|---|
410
+ | Camera | **Sticky** — a beat with no `camera` keeps the current one. |
411
+ | Visibility (`show`/`hide`) | **Sticky** until changed. |
412
+ | Annotations | **Beat-scoped** — replaced each beat (always-on `annotate` block persists underneath). |
413
+
414
+ API: `player.load(scene)`, `play()`, `pause()`, `next()`, `prev()`, `restart()`, `goto(i)`. It emits `PlayerState { index, total, caption, stepName, playing }` for the UI. Auto-advance uses each beat's `hold:` (default 3.2s).
415
+
416
+ ```edd
417
+ timeline story {
418
+ beat overview "All" { camera fit-all over 800ms; narrate: "the system" }
419
+ beat focus "Detail" { camera focus [db, queue] zoom 1.7 ease spring; hold: 2s
420
+ annotate { spotlight [db, queue] { dim: 0.7 } } }
421
+ }
422
+ ```
423
+
424
+ In the app, the bottom **player bar** shows Fit / Restart / Prev / Play / Next, the step indicator, and the caption. `⤢ Fit` reframes the whole diagram at any time.
425
+
426
+ ==============================================================================
427
+
428
+ # ═══ Annotations (docs/ANNOTATIONS_GUIDE.md) ═══
429
+
430
+ # Annotations Guide
431
+
432
+ Annotations are the layer of **highlights, underlines, arrows, callouts, spotlights** on top of a diagram — authored in code **or** drawn live, unified under one model (`Annotation` in `src/engine/scene/types.ts`).
433
+
434
+ ## One model, two sources
435
+
436
+ - **Scripted** — from a top-level `annotate { … }` block (always-on) or inside a timeline `beat` (beat-scoped). Compiled into `scene.annotations` / `step.annotations`.
437
+ - **Live** — drawn interactively by the user. Kept by `LiveAnnotationController` in a separate `live` layer.
438
+
439
+ Both are the same `Annotation` record and both render through `AnnotationLayer` (`src/engine/annotate/layer.ts`), which draws hand-drawn (rough.js) marks in **world space** so they track the camera and their anchored element.
440
+
441
+ ### Anchoring
442
+
443
+ `Annotation.target` is a ref to a node/edge/group id (tracks that element), a set (`options.members`), or an absolute world point. When the camera moves or layout changes, the annotation follows because it lives in the transformed world layer and re-resolves its target bbox on render.
444
+
445
+ ## Kinds
446
+
447
+ `highlight` (marker) · `underline` (solid/double/wavy) · `strike` · `box` (over a set, labelled) · `circle-mark` · `point-at` (hand-drawn pointer + label) · `callout` (leader + bubble) · `spotlight` (dims everything but the target via an SVG mask) · `note-marker` · `connector` (free arrow) · `sticky` (note). See the table in [DSL_LANGUAGE_GUIDE §9](DSL_LANGUAGE_GUIDE.md).
448
+
449
+ ## Real-time editing
450
+
451
+ The floating toolbar (left of the canvas) selects a tool; `LiveAnnotationController` (`src/engine/annotate/interact.ts`) handles pointer events:
452
+
453
+ | Tool | Gesture | Result |
454
+ |---|---|---|
455
+ | Select | click a mark → select; drag → move; Delete → remove | edit existing |
456
+ | Highlight / Underline / Box / Circle | click an element | anchored annotation, spring reveal |
457
+ | Arrow | drag; endpoints snap to elements | `point-at` / free connector |
458
+ | Text | click | sticky note (inline text input) |
459
+
460
+ Undo/redo (`⌘Z` / `⇧⌘Z`) is a snapshot stack. All interaction is smooth and reversible; nothing mutates the source until you commit.
461
+
462
+ ## Round-trip: commit to code
463
+
464
+ The **⤓ code** button serializes live annotations back into an `annotate "live" { … }` block and appends it to the editor, then clears the live layer — so what you drew becomes part of the program and re-renders as scripted:
465
+
466
+ ```edd
467
+ annotate "live" {
468
+ highlight idea { color: yellow }
469
+ underline vibe { color: #1971c2 }
470
+ circle-mark edit { color: #e8590c }
471
+ point-at hello { from: s, color: #1971c2 }
472
+ }
473
+ ```
474
+
475
+ Element-anchored annotations serialize exactly; free-form marks (a floating arrow with no element under either end) are emitted as a comment noting they aren't representable by id. See `LiveAnnotationController.commitToCode` and `tests/annotations.test.ts` for the round-trip contract.
476
+
477
+ ## Animated arrows
478
+
479
+ The connector animations (`flow`, `dash-march`, `draw-on`, `comet`, `gradient-flow`, `electric`, `pulse`) are a CSS overlay path over the hand-drawn stroke — see `animationOverlay` in `src/engine/render/edges.ts` and the keyframes in `src/engine/render/theme.css.ts`. Set on any edge with `animate:` or the `~>` glyph.
480
+
481
+ ==============================================================================
482
+
483
+ # ═══ Import & export (docs/IMPORT_AND_EXPORT_GUIDE.md) ═══
484
+
485
+ # Import & Export Guide
486
+
487
+ ## Mermaid import
488
+
489
+ EDodoDraw imports raw Mermaid and re-renders it in the hand-drawn style, **preserving node ids** so you can choreograph and annotate the result.
490
+
491
+ ```edd
492
+ mermaid """
493
+ flowchart LR
494
+ app[App Servers] --> otel[OTel Collector]
495
+ otel --> prom[Prometheus]
496
+ prom --> grafana[Grafana]
497
+ """
498
+
499
+ timeline walkthrough {
500
+ beat collect "Collection" {
501
+ camera focus [app, otel] zoom 1.7 // references the mermaid ids
502
+ annotate { point-at otel "single ingestion point" { from: s } }
503
+ }
504
+ }
505
+ ```
506
+
507
+ ### How it works
508
+
509
+ `src/engine/import/mermaid.ts` wraps `@excalidraw/mermaid-to-excalidraw`. Mermaid rendering needs the browser, so import is **async and app-side**:
510
+
511
+ 1. `extractMermaidBlocks(source)` pulls the `mermaid """ … """` bodies (pure string fn — unit-testable).
512
+ 2. `convertMermaid(body)` runs the Mermaid → Excalidraw-skeleton converter and maps each element to Scene IR: vertices → pinned nodes (mermaid id preserved), arrows → edges bound by `start.id`/`end.id`.
513
+ 3. `injectMermaid(scene, fragment)` merges the fragment into the compiled scene, so `id:::class` re-tags and timeline/annotate references resolve against the imported nodes.
514
+
515
+ Supported: flowcharts (and other mermaid graph types the converter emits as elements). The synchronous DSL compiler stays DOM-free; only the app awaits conversion.
516
+
517
+ ## Export
518
+
519
+ Toolbar (top-right): **SVG · PNG · JSON · Copy**. Implemented in `src/engine/export.ts`.
520
+
521
+ | Format | Notes |
522
+ |---|---|
523
+ | **SVG** | Standalone, framed to the content bbox (independent of the current camera). The hand-drawn font is **embedded as base64**, so the file renders correctly anywhere. |
524
+ | **PNG** | The SVG rasterized to a 2× canvas. |
525
+ | **JSON** | The full `Scene` IR — round-trippable, inspectable. |
526
+ | **Copy** | The `.edd` source to the clipboard. |
527
+
528
+ ```ts
529
+ await downloadSVG(renderer, scene);
530
+ await downloadPNG(renderer, scene);
531
+ downloadJSON(scene);
532
+ ```
533
+
534
+ Screen-space overlays (player bar, selection outlines, hidden elements) are stripped from exports; the background is the scene/theme background.
535
+
536
+ ==============================================================================
537
+
538
+ # ═══ Embed in your app (docs/INTEGRATION_GUIDE.md) ═══
539
+
540
+ # Integrating EDodoDraw
541
+
542
+ How to embed the `edododraw` engine in your own app — vanilla JS, any framework,
543
+ React, or a server. Everything here is checked against the published API in
544
+ `src/lib/` and `src/engine/`.
545
+
546
+ > Want to _extend_ the engine (new shapes, animations, layouts)? See
547
+ > [EXTENDING_GUIDE.md](EXTENDING_GUIDE.md). For the diagram source language, see
548
+ > [DSL_LANGUAGE_GUIDE.md](DSL_LANGUAGE_GUIDE.md).
549
+
550
+ ---
551
+
552
+ ## 1. Install
553
+
554
+ ```bash
555
+ npm i edododraw
556
+ ```
557
+
558
+ - **Runtime dependencies install automatically:** `roughjs` (hand-drawn strokes)
559
+ and `@dagrejs/dagre` (auto-layout).
560
+ - **Mermaid import** (`@excalidraw/mermaid-to-excalidraw`) ships as an
561
+ _optionalDependency_: it's installed by default, but install won't fail if it's
562
+ unavailable, and it's **lazy-loaded** only when a `mermaid` block is actually
563
+ rendered. See [§6](#6-mermaid-note).
564
+ - **React is optional.** It's a _peer_ dependency needed only for the
565
+ `edododraw/react` entry. For the core `edododraw` entry you don't need React at
566
+ all.
567
+
568
+ The package is **ESM-only** (`"type": "module"`, `exports` expose `import` only)
569
+ and targets **Node ≥ 18**. Two entry points:
570
+
571
+ | Import | What you get |
572
+ |---|---|
573
+ | `edododraw` | The core: `EdodoDraw` facade + the whole engine (`compileEdd`, `SvgRenderer`, `registerShape`, Scene IR types, …). |
574
+ | `edododraw/react` | `EdodoDrawView` React component (thin wrapper over the facade). |
575
+
576
+ ---
577
+
578
+ ## 2. Quick start (vanilla / any framework)
579
+
580
+ Give the engine a sized container element and hand it EDodoDraw source. The
581
+ `EdodoDraw` facade mounts an `<svg>`, compiles + renders, and exposes camera,
582
+ timeline, live annotations, and export behind one object.
583
+
584
+ ```html
585
+ <!doctype html>
586
+ <div id="diagram" style="width: 100%; height: 500px;"></div>
587
+ <script type="module">
588
+ import { EdodoDraw } from "edododraw";
589
+
590
+ const el = document.getElementById("diagram");
591
+ const edd = new EdodoDraw(el, { interactive: true });
592
+
593
+ await edd.render(`
594
+ scene {
595
+ a[Client] --> b[API] --> c[(Database)]
596
+ }
597
+ timeline {
598
+ beat intro "Overview" { camera fit-all, reveal { show a } }
599
+ beat call "Trace the call" { camera focus b zoom 1.4, reveal { show b, c } }
600
+ }
601
+ `);
602
+
603
+ edd.play(); // drives the timeline (magic-move). No-op if the source has no timeline.
604
+ </script>
605
+ ```
606
+
607
+ The **container must have a height** — the facade sets `position` and
608
+ `overflow: hidden` for you but never a size, and `autoFit` needs a measurable
609
+ viewport. A `0px`-tall div renders nothing.
610
+
611
+ `render()` is async (a `mermaid` block may need the lazy runtime) and safe to call
612
+ on every keystroke — stale runs are discarded. It resolves to
613
+ `{ scene, diagnostics }`.
614
+
615
+ ---
616
+
617
+ ## 3. `EdodoDraw` API reference
618
+
619
+ ```ts
620
+ import { EdodoDraw } from "edododraw";
621
+ new EdodoDraw(container: HTMLElement, options?: EdodoDrawOptions)
622
+ ```
623
+
624
+ ### Options (`EdodoDrawOptions`)
625
+
626
+ | Option | Type | Default | Effect |
627
+ |---|---|---|---|
628
+ | `interactive` | `boolean` | `true` | Enable pan (drag), zoom (wheel), and live-annotation pointer/keyboard handling. Set `false` for a static embed. |
629
+ | `grid` | `boolean` | `true` | Draw a dotted grid that pans/zooms with the camera (a CSS background on the container). |
630
+ | `autoFit` | `boolean` | `true` | Fit the whole diagram into view after each `render()`. |
631
+ | `padding` | `number` | `80` | Screen-px padding used when fitting. |
632
+
633
+ ### Render / scene
634
+
635
+ | Method | Signature | Notes |
636
+ |---|---|---|
637
+ | `render` | `(source: string) => Promise<RenderResult>` | Compile + render. `RenderResult = { scene: Scene; diagnostics: Diagnostic[] }`. |
638
+ | `getScene` | `() => Scene` | The current rendered scene (Scene IR). |
639
+ | `getSource` | `() => string` | The last source string passed to `render`. |
640
+
641
+ ### Camera
642
+
643
+ | Method | Signature | Notes |
644
+ |---|---|---|
645
+ | `fit` | `(animate?: boolean) => void` | Fit the whole diagram (default `animate = true`). |
646
+ | `focus` | `(ids: string[], opts?: { zoom?: number; padding?: number }) => Promise<void>` | Animate to frame the given node/group/edge ids. |
647
+ | `zoomBy` | `(factor: number) => void` | Multiply zoom around the viewport centre (e.g. `1.2` in, `0.8` out). |
648
+ | `reset` | `() => void` | Animate back to fit-all. |
649
+ | `camera` | `get camera(): CameraController` | The underlying controller for advanced moves (`animateTo`, `panByScreen`, …). |
650
+
651
+ ### Timeline (magic-move presentation)
652
+
653
+ Only meaningful when the source contains a `timeline { … }` — otherwise these
654
+ no-op. State changes emit via `on("state", …)`.
655
+
656
+ | Method | Signature | Notes |
657
+ |---|---|---|
658
+ | `play` | `() => void` | Auto-advance through beats. No-op if the scene has no steps. |
659
+ | `pause` | `() => void` | Stop auto-advance. |
660
+ | `next` / `prev` | `() => void` | Step forward / back one beat. |
661
+ | `goto` | `(i: number) => void` | Jump to beat index `i` (0-based). |
662
+ | `restart` | `() => void` | Go to the first beat. |
663
+ | `timeline` | `get timeline(): TimelinePlayer` | The underlying player (`hasTimeline`, current index, …). |
664
+
665
+ ### Tools — direct editing & annotation
666
+
667
+ `setTool` selects the active pointer tool and switches the engine between its two
668
+ interactive **modes**. All of this requires `interactive: true`.
669
+
670
+ | Method | Signature | Notes |
671
+ |---|---|---|
672
+ | `setTool` | `(tool: string) => void` | **Edit tools** (mode `edit`): `"select"`, `"hand"`, `"rect"`, `"ellipse"`, `"diamond"`, `"text"`, `"arrow"`. **Annotate tools** (mode `annotate`): `"highlight"`, `"underline"`, `"mark-box"`, `"mark-circle"`, `"point"`, `"note"`. |
673
+
674
+ **Direct editing (canvas → code).** In `edit` mode, pointer gestures manipulate the
675
+ diagram and every change is patched back into the source (emitted as the `"edit"`
676
+ event — see below), keeping the code the single source of truth:
677
+
678
+ - **select / move** — click a node to select (8-handle box); drag to move.
679
+ - **resize** — drag a handle. Move/resize write an `overrides { … }` block (see DSL_LANGUAGE_GUIDE §11).
680
+ - **rename** — double-click a node, type, `Enter`.
681
+ - **add** — pick `rect`/`ellipse`/`diamond`/`text` and drag on empty canvas; `arrow` drags between two nodes to connect them.
682
+ - **delete** — `Delete`/`Backspace` on the selection.
683
+
684
+ | Method | Signature | Notes |
685
+ |---|---|---|
686
+ | `applyStyle` | `(id, { fill?, stroke?, shape? }) => void` | Upsert style attrs on a node's declaration (e.g. `{ fill: green }`). |
687
+ | `renameSelected` | `() => void` | Open the rename field for the current selection. |
688
+ | `deleteSelected` | `() => void` | Delete the selection (node decl + its edges + its override). |
689
+ | `fitNext` | `() => void` | Fit the whole diagram on the next render (e.g. after loading a new document). |
690
+ | `editor` | `get editor(): EditController` | The underlying edit controller (`clearSelection`, `getTool`, …). |
691
+
692
+ ### Live annotations
693
+
694
+ | Method | Signature | Notes |
695
+ |---|---|---|
696
+ | `undo` / `redo` | `() => void` | Undo/redo live annotation edits. |
697
+ | `clearAnnotations` | `() => void` | Remove all live annotations. |
698
+ | `annotationsToCode` | `() => string` | Serialize live annotations back to an `annotate { … }` DSL block. |
699
+ | `annotator` | `get annotator(): LiveAnnotationController` | The underlying controller. |
700
+
701
+ ### Export
702
+
703
+ | Method | Signature | Notes |
704
+ |---|---|---|
705
+ | `toSVG` | `() => Promise<string>` | Standalone SVG string with the hand-drawn font **embedded** (renders offline). |
706
+ | `toPNG` | `() => Promise<Blob>` | Rasterised PNG (2× by default). |
707
+ | `toJSON` | `() => Scene` | The Scene IR (the same object `getScene` returns). |
708
+ | `downloadSVG` / `downloadPNG` | `() => Promise<void>` | Trigger a browser download. |
709
+ | `downloadJSON` | `() => void` | Download the Scene IR as JSON. |
710
+
711
+ ### Lifecycle
712
+
713
+ | Method | Signature | Notes |
714
+ |---|---|---|
715
+ | `resize` | `() => void` | Re-measure the container and re-apply the camera. Called automatically on container resize when `interactive: true` (via `ResizeObserver`); call it yourself otherwise. |
716
+ | `destroy` | `() => void` | Remove listeners, the SVG, and the timeline; clears event subscribers. Always call on teardown. |
717
+
718
+ ### Events — `on(event, cb): () => void`
719
+
720
+ `on` returns an **unsubscribe** function. Six events:
721
+
722
+ | Event | Payload | Fires when |
723
+ |---|---|---|
724
+ | `"render"` | `RenderResult` `{ scene, diagnostics }` | After each successful `render()`. |
725
+ | `"diagnostics"` | `Diagnostic[]` | After each `render()`, with compile diagnostics. |
726
+ | `"state"` | `PlayerState` `{ index, total, caption, playing, stepName }` | Timeline position/playing changes. |
727
+ | `"live"` | `LiveState` `{ tool, count, canUndo, canRedo, selected }` | Live-annotation tool/selection/undo state changes. |
728
+ | `"edit"` | `source: string` | A direct-manipulation edit patched the source. Re-render with this new source (and mirror it into your code editor). |
729
+ | `"editstate"` | `EditState` `{ tool, selected }` | Edit-mode tool or node selection changes (drive a property panel / toolbar highlight). |
730
+
731
+ A `Diagnostic` has `{ severity: "error"|"warning"|"info", code, message, line, col,
732
+ start, end, expected?, found?, hint? }` (see `src/engine/dsl/diagnostics.ts`).
733
+
734
+ ```ts
735
+ const off = edd.on("diagnostics", (diags) => {
736
+ const errors = diags.filter((d) => d.severity === "error");
737
+ if (errors.length) console.warn(errors.map((d) => `${d.code}: ${d.message}`).join("\n"));
738
+ });
739
+ // later: off(); // unsubscribe
740
+ edd.on("state", (s) => console.log(`beat ${s.index + 1}/${s.total} — ${s.stepName}`));
741
+
742
+ // Direct-edit round-trip: the diagram is editable, and edits flow back to code.
743
+ let source = "scene { rect a \"A\"\n a --> b }";
744
+ edd.on("edit", (next) => {
745
+ source = next; // keep your source of truth in sync…
746
+ void edd.render(next); // …and re-render (also mirror `next` into your code editor)
747
+ });
748
+ edd.setTool("select"); // enable move/resize/rename/delete on the canvas
749
+ ```
750
+
751
+ ---
752
+
753
+ ## 4. React
754
+
755
+ `edododraw/react` exports `EdodoDrawView`, a thin wrapper that owns an `EdodoDraw`
756
+ instance for the lifetime of the component.
757
+
758
+ ```tsx
759
+ import { EdodoDrawView } from "edododraw/react";
760
+ import type { EdodoDraw } from "edododraw/react";
761
+
762
+ export function Diagram() {
763
+ return (
764
+ <div style={{ height: 500 }}>
765
+ <EdodoDrawView
766
+ source={`scene { a[Hello] --> b[World] }`}
767
+ interactive
768
+ grid={false}
769
+ style={{ borderRadius: 8 }}
770
+ onReady={(edd: EdodoDraw) => edd.on("state", (s) => console.log(s.stepName))}
771
+ onDiagnostics={(diags) => console.log(diags)}
772
+ onState={(state) => console.log(state.index)}
773
+ />
774
+ </div>
775
+ );
776
+ }
777
+ ```
778
+
779
+ ### Props (`EdodoDrawViewProps`)
780
+
781
+ `EdodoDrawViewProps` extends `EdodoDrawOptions` (so `interactive`, `grid`,
782
+ `autoFit`, `padding` are all valid props), plus:
783
+
784
+ | Prop | Type | Notes |
785
+ |---|---|---|
786
+ | `source` | `string` (required) | EDodoDraw source. Re-renders whenever it changes. |
787
+ | `className` | `string` | On the host `<div>`. |
788
+ | `style` | `CSSProperties` | Merged onto the host (which defaults to `width/height: 100%`). |
789
+ | `onReady` | `(edd: EdodoDraw) => void` | Called **once** after mount with the imperative instance — use it to subscribe to events or grab a ref for camera/export. |
790
+ | `onDiagnostics` | `(diags: Diagnostic[]) => void` | Wired to the `"diagnostics"` event. |
791
+ | `onState` | `(state: PlayerState) => void` | Wired to the `"state"` event. |
792
+
793
+ The host `<div>` fills its parent (`width/height: 100%`), so wrap it in a **sized**
794
+ element. Note the `EdodoDrawOptions` props (`interactive`, `grid`, …) are read
795
+ **once at mount**; only `source` is reactive. To change options at runtime, remount
796
+ (e.g. via a React `key`).
797
+
798
+ ---
799
+
800
+ ## 5. Low-level engine usage (SSR / Node-safe)
801
+
802
+ The DSL compiler is **synchronous and DOM-free**, so you can compile and validate a
803
+ diagram anywhere — Node, an edge function, a build step — without a browser:
804
+
805
+ ```ts
806
+ import { compileEdd } from "edododraw";
807
+
808
+ const { scene, diagnostics, report } = compileEdd(`scene { a -> b -> c }`);
809
+ // scene : the Scene IR (nodes/edges/steps/…), JSON-serializable
810
+ // diagnostics : a DiagnosticBag — diagnostics.items is Diagnostic[], .hasErrors, .errors
811
+ // report : string[] — human-readable, source-annotated diagnostic blocks
812
+
813
+ if (diagnostics.hasErrors) throw new Error(report.join("\n\n"));
814
+ ```
815
+
816
+ > `compileEdd` returns `diagnostics` as a **`DiagnosticBag`** (use `.items`,
817
+ > `.hasErrors`, `.errors`). This differs from the facade's `render()`, whose
818
+ > `diagnostics` is already a flat `Diagnostic[]`.
819
+
820
+ The full engine is re-exported from `edododraw` for building your own rendering
821
+ pipeline instead of using the `EdodoDraw` facade:
822
+
823
+ ```ts
824
+ import {
825
+ compileEdd, applyLayout,
826
+ SvgRenderer, CameraController, TimelinePlayer,
827
+ AnnotationLayer, LiveAnnotationController,
828
+ cameraForBBox, sceneBBox,
829
+ exportSVGString, exportPNGBlob,
830
+ registerShape, ensureEngineStyles,
831
+ } from "edododraw";
832
+ ```
833
+
834
+ For example, a minimal custom composition (browser): compile → new `SvgRenderer`
835
+ → `renderer.render(scene)` → drive a `CameraController` yourself. The `EdodoDraw`
836
+ facade in `src/lib/EdodoDraw.ts` is the reference for how these compose.
837
+
838
+ ---
839
+
840
+ ## 6. Mermaid note
841
+
842
+ You can embed raw Mermaid inside EDodoDraw source:
843
+
844
+ ```edd
845
+ scene {
846
+ mermaid """
847
+ flowchart LR
848
+ A[Start] --> B{Choice}
849
+ B -->|yes| C[Do it]
850
+ """
851
+ }
852
+ ```
853
+
854
+ - The Mermaid engine (`@excalidraw/mermaid-to-excalidraw`) is an **optional
855
+ dependency, lazy-loaded** via dynamic `import()` on the **first** `mermaid` block
856
+ rendered — so apps that never use Mermaid don't pay for it (it's heavy).
857
+ - The facade's `render()` extracts `mermaid """ … """` blocks, converts each, and
858
+ injects the resulting nodes/edges into the scene.
859
+ - **If the package isn't installed**, the lazy import fails; the facade catches it
860
+ and reports a diagnostic (code `M-PARSE`) for that block. The rest of the diagram
861
+ still renders — `import { EdodoDraw } from "edododraw"` stays fully functional.
862
+ Install it explicitly if you need Mermaid: `npm i @excalidraw/mermaid-to-excalidraw`.
863
+ - Mermaid conversion runs in the **browser only** (it renders to a hidden SVG). The
864
+ pure `compileEdd` path stays synchronous and ignores Mermaid runtime.
865
+
866
+ Helpers `convertMermaid`, `extractMermaidBlocks`, and `injectMermaid` are also
867
+ exported from `edododraw` if you want to drive the import yourself. See
868
+ [IMPORT_AND_EXPORT_GUIDE.md](IMPORT_AND_EXPORT_GUIDE.md).
869
+
870
+ ---
871
+
872
+ ## 7. SSR / bundler notes
873
+
874
+ - **The facade needs a DOM.** `new EdodoDraw(el)` and its methods use browser globals
875
+ — `document`, `window`, `getComputedStyle`, `ResizeObserver`, `requestAnimationFrame`
876
+ / `cancelAnimationFrame` (camera animation), and `XMLSerializer` (SVG export).
877
+ Construct and use it in the browser only. In React, create it inside `useEffect`
878
+ (which is exactly what `EdodoDrawView` does), never during render or on the server.
879
+ - **`compileEdd` is DOM-free.** Use it for server-side validation, computing a scene
880
+ ahead of time, or CI checks — no browser or jsdom required.
881
+ - **Fonts are self-contained.** The hand-drawn font (Excalifont/Virgil) is embedded
882
+ as a base64 `@font-face` in the engine's injected CSS and in every exported SVG —
883
+ **no external font file or CDN is needed**. The live canvas injects this CSS once
884
+ per document via `ensureEngineStyles()` (the facade calls it automatically on
885
+ mount). The UI/code fonts (Nunito, Cascadia) are best-effort from `/fonts` with
886
+ system fallbacks and only matter inside the playground app.
887
+ - **ESM only.** There is no CommonJS build; use `import` (or dynamic `import()`),
888
+ not `require`. Any modern bundler (Vite, webpack 5, esbuild, Rollup, Next.js)
889
+ resolves the `edododraw` / `edododraw/react` subpath exports directly.
890
+ - **Tree-shaking + built-in shapes.** Built-in plugin shapes (e.g. `star`) are
891
+ registered from the renderer on `mount()` (via `registerBuiltinShapes()`), so they
892
+ survive a tree-shaking library build and are always available once you render.
893
+ `package.json` also lists the `builtins` files under `sideEffects` for the eager
894
+ registration path. Your own `registerShape(...)` calls run whenever their module is
895
+ imported — import that module before you render.
896
+
897
+ ---
898
+
899
+ ## 8. Troubleshooting
900
+
901
+ | Symptom | Likely cause & fix |
902
+ |---|---|
903
+ | Blank canvas, nothing renders | Container has no height. Give it an explicit size (`height: 500px`); the facade sets position/overflow but never a size. |
904
+ | `document is not defined` / crash on the server | The facade needs a DOM. Only construct `EdodoDraw` in the browser; use `compileEdd` for SSR/Node. |
905
+ | `edd.play()` does nothing | The source has no `timeline { … }` — the player no-ops without steps. Add beats, or drive the camera directly with `focus`/`fit`. |
906
+ | Mermaid block shows an `M-PARSE` diagnostic | `@excalidraw/mermaid-to-excalidraw` isn't installed. Run `npm i @excalidraw/mermaid-to-excalidraw`. |
907
+ | Wheel/scroll is hijacked by the diagram | `interactive: true` binds wheel-zoom with `preventDefault`. Use `interactive: false` for a static embed. |
908
+ | React: changing `interactive`/`grid` prop has no effect | Options are read once at mount; only `source` is reactive. Remount (e.g. change the component `key`). |
909
+ | Live-annotation tools do nothing | Live tools require `interactive: true`. |
910
+ | Camera/timeline "jumps" instead of animating | You called an immediate variant (`fit(false)`), or `autoFit` refit after a `render()`. Use `focus(...)`/`fit(true)` for animated moves. |
911
+ | Old instance leaks / duplicate SVGs after re-mount | Call `edd.destroy()` on teardown (the React wrapper does this for you). |
912
+
913
+ ---
914
+
915
+ See also: [ARCHITECTURE.md](ARCHITECTURE.md) ·
916
+ [DSL_LANGUAGE_GUIDE.md](DSL_LANGUAGE_GUIDE.md) ·
917
+ [CAMERA_AND_TIMELINE_GUIDE.md](CAMERA_AND_TIMELINE_GUIDE.md) ·
918
+ [ANNOTATIONS_GUIDE.md](ANNOTATIONS_GUIDE.md) ·
919
+ [IMPORT_AND_EXPORT_GUIDE.md](IMPORT_AND_EXPORT_GUIDE.md) ·
920
+ [EXTENDING_GUIDE.md](EXTENDING_GUIDE.md)
921
+
922
+ ==============================================================================
923
+
924
+ # ═══ Extend & make plugins (docs/EXTENDING_GUIDE.md) ═══
925
+
926
+ # Extending EDodoDraw
927
+
928
+ How to add new capabilities to the engine: custom **shapes**, **animated arrows**,
929
+ **annotation kinds**, **layouts**, and **DSL constructs**. This guide is for people
930
+ working _inside_ the repository (or forking it) — every example is copy-paste
931
+ accurate against the current source.
932
+
933
+ > New to the codebase? Read [ARCHITECTURE.md](ARCHITECTURE.md) first, then
934
+ > [DEVELOPMENT_STANDARDS.md](DEVELOPMENT_STANDARDS.md). To _embed_ EDodoDraw in an
935
+ > app (rather than extend it), see [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md).
936
+
937
+ ---
938
+
939
+ ## 1. The extension philosophy
940
+
941
+ EDodoDraw is designed to be extended without rewriting the core. Two ideas make
942
+ that possible:
943
+
944
+ - **The Scene IR is the contract.** Every producer (the DSL compiler, the Mermaid
945
+ importer, a programmatic caller) emits a `Scene`; the renderer and controllers
946
+ only consume a `Scene`. See `src/engine/scene/types.ts`. If your extension can be
947
+ expressed as data on the `Scene`, everything downstream already works.
948
+ - **Open unions + registries.** The presentation-level enums are _open_ string
949
+ unions: `ShapeKind`, `ArrowheadKind`, `ArrowAnimationKind`, `AnnotationKind`,
950
+ and `LayoutKind` all end in `| (string & {})` (except `LayoutKind`, whose
951
+ dispatch has a grid fallback). That means the parser and compiler already accept
952
+ names they've never seen — an unknown value flows through the pipeline untouched
953
+ and is _resolved at the last moment_ by a renderer switch or a registry lookup.
954
+
955
+ The practical consequence: **most extensions need no grammar change.** You add a
956
+ name and teach one switch (or one registry) how to draw it.
957
+
958
+ There are two flavours of extension:
959
+
960
+ | Flavour | Mechanism | Needs a repo edit? | Example |
961
+ |---|---|---|---|
962
+ | **Registry** (runtime) | `registerShape(name, fn)` | No — callable from any app | Custom shapes |
963
+ | **Switch** (compile-time) | add a `case` to a `switch` | Yes — you edit engine source | Animations, annotations, layouts |
964
+
965
+ Shapes are the only fully-runtime seam today; the rest are small, well-isolated
966
+ `switch` additions.
967
+
968
+ ---
969
+
970
+ ## 2. Add a custom shape
971
+
972
+ Shapes resolve through a **plugin registry** (`src/engine/plugins/registry.ts`).
973
+ You register a function under a name; the renderer calls it whenever a node's
974
+ `shape` matches. No grammar change is needed — `mapShape()` in
975
+ `src/engine/dsl/lower.ts` returns any unknown shape name as-is, and
976
+ `renderShapeBody()` (`src/engine/render/shapes.ts`) falls back to the registry in
977
+ its `default:` case:
978
+
979
+ ```ts
980
+ // src/engine/render/shapes.ts (default branch of renderShapeBody)
981
+ default: {
982
+ const plugin = getShapePlugin(shape);
983
+ if (plugin) return plugin(rc, rect, style);
984
+ // unknown shape -> rounded rectangle so nothing silently disappears
985
+ }
986
+ ```
987
+
988
+ ### The plugin contract
989
+
990
+ ```ts
991
+ export type ShapePluginFn = (rc: RoughSVG, rect: ShapeRect, style: NodeStyle) => SVGGElement;
992
+ ```
993
+
994
+ - **`rc`** — a rough.js SVG generator (`ReturnType<typeof rough.svg>`). Use
995
+ `rc.polygon`, `rc.path`, `rc.ellipse`, `rc.rectangle`, `rc.circle`, `rc.line`,
996
+ `rc.linearPath`, `rc.curve` — each returns an `SVGGElement` (or `SVGPathElement`)
997
+ you append.
998
+ - **`rect`** — the node box as **top-left `x`/`y` plus `w`/`h`** (`ShapeRect`), in
999
+ world coordinates. There is no separate transform: draw directly at these
1000
+ absolute coordinates. The centre is `(rect.x + rect.w/2, rect.y + rect.h/2)`.
1001
+ - **`style`** — the node's resolved `NodeStyle` (stroke, fill, `fillStyle`,
1002
+ `strokeWidth`, `roughness`, `seed`, `fontSize`, …).
1003
+ - **Return** a single `<g>` (`SVGGElement`). **Do not draw the label** — the
1004
+ renderer overlays text itself after calling you.
1005
+
1006
+ `nodeRoughOptions(style, filled)` (exported from `src/engine/render/shapes.ts`)
1007
+ maps a `NodeStyle` onto rough.js `Options` (stroke/fill/roughness/seed, dashes,
1008
+ hachure gap). Pass `filled = true` for closed bodies so the node's fill and
1009
+ `fillStyle` are honoured; `false` for stroke-only accents. Using it keeps the
1010
+ deterministic `seed` (so re-renders don't re-jitter) and the hand-drawn look
1011
+ consistent with the built-ins.
1012
+
1013
+ ### Worked example — a "gauge" shape (add to the builtins)
1014
+
1015
+ The canonical place to register in-repo shapes is
1016
+ `src/engine/plugins/builtins.ts` (the `star` example already lives there). Add your
1017
+ shape inside the exported `registerBuiltinShapes()` function — the renderer calls it
1018
+ on `mount()` (and it is called eagerly on import), so built-ins survive a
1019
+ tree-shaking library build and are always available once you render. Add:
1020
+
1021
+ ```ts
1022
+ // src/engine/plugins/builtins.ts — add this call INSIDE registerBuiltinShapes()
1023
+ // (so it survives the tree-shaking library build, like the star above).
1024
+ // `nodeRoughOptions` and `registerShape` are already imported in that file.
1025
+
1026
+ // A half-circle gauge with a needle — great for "health"/"load" nodes.
1027
+ registerShape("gauge", (rc, rect, style) => {
1028
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g") as SVGGElement;
1029
+ const cx = rect.x + rect.w / 2;
1030
+ const cy = rect.y + rect.h * 0.72; // dial sits low in the box
1031
+ const r = Math.min(rect.w, rect.h * 1.4) / 2;
1032
+
1033
+ // The dial arc (semicircle), stroke-only.
1034
+ const arc = `M${cx - r},${cy} A${r},${r} 0 0 1 ${cx + r},${cy}`;
1035
+ g.appendChild(rc.path(arc, nodeRoughOptions(style, false)));
1036
+
1037
+ // Filled body under the arc so fill/fillStyle read through.
1038
+ const body = `M${cx - r},${cy} A${r},${r} 0 0 1 ${cx + r},${cy} Z`;
1039
+ g.appendChild(rc.path(body, nodeRoughOptions(style, true)));
1040
+
1041
+ // The needle, pointing ~70% of the way across the dial.
1042
+ const angle = Math.PI - Math.PI * 0.7;
1043
+ g.appendChild(rc.line(cx, cy, cx + Math.cos(angle) * r * 0.9, cy - Math.sin(angle) * r * 0.9, {
1044
+ stroke: style.stroke,
1045
+ strokeWidth: style.strokeWidth + 0.6,
1046
+ roughness: Math.min(style.roughness, 1),
1047
+ seed: style.seed,
1048
+ }));
1049
+ return g;
1050
+ });
1051
+ ```
1052
+
1053
+ ### Using it from the DSL
1054
+
1055
+ No grammar change — three equivalent ways to reach a registered shape:
1056
+
1057
+ ```edd
1058
+ scene {
1059
+ g1[CPU] { shape: gauge } // via the `shape:` attribute
1060
+ node g2 "Memory" as gauge // via the explicit `as <name>` form
1061
+ }
1062
+ ```
1063
+
1064
+ Both compile to a node whose `shape` is `"gauge"`, which the renderer resolves via
1065
+ `getShapePlugin("gauge")`. (Verified: `g[Gauge] { shape: gauge }` compiles to a
1066
+ node with `shape === "gauge"`, and `node x as gauge` likewise.)
1067
+
1068
+ To also expose a **short keyword** (so `gauge g1 "CPU"` works as a shape-led node),
1069
+ add the word to two internal tables:
1070
+
1071
+ 1. `SHAPE_KEYWORDS` in `src/engine/dsl/tokens.ts` — so the parser treats
1072
+ `gauge <id>` as a keyword-led node (see `parseKeywordNode` in `parser.ts`).
1073
+ 2. `SHAPE_MAP` in `src/engine/dsl/lower.ts` — map `gauge: "gauge"` (only needed if
1074
+ the surface word differs from the registered name; identical names pass through
1075
+ `mapShape` unchanged).
1076
+
1077
+ ### Registering from _outside_ the repo (runtime)
1078
+
1079
+ `registerShape` is part of the public package export, so an embedding app can add a
1080
+ shape at runtime with no fork:
1081
+
1082
+ ```ts
1083
+ import { registerShape, EdodoDraw } from "edododraw";
1084
+
1085
+ // nodeRoughOptions is engine-internal (not re-exported), so build rough options
1086
+ // inline from the NodeStyle you're handed:
1087
+ registerShape("gauge", (rc, rect, style) => {
1088
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
1089
+ const opts = {
1090
+ stroke: style.stroke,
1091
+ strokeWidth: style.strokeWidth,
1092
+ roughness: style.roughness,
1093
+ seed: style.seed,
1094
+ fill: style.fill ?? undefined,
1095
+ fillStyle: style.fillStyle,
1096
+ };
1097
+ const cx = rect.x + rect.w / 2, cy = rect.y + rect.h / 2, r = Math.min(rect.w, rect.h) / 2;
1098
+ g.appendChild(rc.circle(cx, cy, r * 2, opts));
1099
+ return g;
1100
+ });
1101
+
1102
+ const edd = new EdodoDraw(document.getElementById("app")!);
1103
+ await edd.render(`scene { s[Sensor] { shape: gauge } }`);
1104
+ ```
1105
+
1106
+ Register **before** you call `render()` so the plugin is present when the scene
1107
+ paints.
1108
+
1109
+ ---
1110
+
1111
+ ## 3. Add an animated arrow
1112
+
1113
+ An edge draws a hand-drawn base stroke, arrowheads, and (optionally) a clean
1114
+ **overlay `<path>`** that carries a CSS animation along the exact same centerline.
1115
+ Adding a new animation kind is **exactly three edits** plus (optionally) widening
1116
+ the type union. We'll add a kind called `surge` (a fast, short dash sweep).
1117
+
1118
+ **Edit 1 — the overlay case** in `animationOverlay()`
1119
+ (`src/engine/render/edges.ts`). This configures the SVG path for the new kind; the
1120
+ element already has class `edd-anim edd-anim-surge`, and `--edd-len` / `--edd-speed`
1121
+ CSS variables are set for you:
1122
+
1123
+ ```ts
1124
+ // src/engine/render/edges.ts — inside animationOverlay's switch (kind)
1125
+ case "surge": {
1126
+ p.setAttribute("stroke", stroke);
1127
+ p.setAttribute("stroke-width", String(sw * 1.5));
1128
+ p.setAttribute("stroke-linecap", "round");
1129
+ p.setAttribute("stroke-dasharray", "4 14");
1130
+ style.animationDuration = `${0.7 / speed}s`;
1131
+ break;
1132
+ }
1133
+ ```
1134
+
1135
+ **Edit 2 — the keyframe + class** in `src/engine/render/theme.css.ts` (the `CSS`
1136
+ string). The class name must be `edd-anim-<kind>` to match the attribute the
1137
+ overlay sets:
1138
+
1139
+ ```css
1140
+ @keyframes edd-surge {
1141
+ to { stroke-dashoffset: -18; }
1142
+ }
1143
+ .edd-anim-surge {
1144
+ animation-name: edd-surge;
1145
+ animation-timing-function: ease-in;
1146
+ animation-iteration-count: infinite;
1147
+ }
1148
+ ```
1149
+
1150
+ `.edd-anim-surge` is already covered by the blanket `.edd-anim` selector in the
1151
+ `prefers-reduced-motion` block at the end of the CSS, so motion-sensitive users
1152
+ are handled automatically — no extra edit needed there.
1153
+
1154
+ **Edit 3 — accept the name in the compiler.** In `mapAnimation()`
1155
+ (`src/engine/dsl/lower.ts`), add `surge` to the `known` set so the DSL keeps it
1156
+ instead of falling back to `flow`:
1157
+
1158
+ ```ts
1159
+ const known = new Set([
1160
+ "none", "flow", "dash-march", "draw-on", "pulse", "comet",
1161
+ "gradient-flow", "electric", "glow", "caravan", "wiggle",
1162
+ "surge", // <-- new
1163
+ ]);
1164
+ ```
1165
+
1166
+ **Optional — widen the type.** Add `| "surge"` to the `ArrowAnimationKind` union in
1167
+ `src/engine/scene/types.ts`. The union is open (`| (string & {})`), so this is only
1168
+ for editor autocomplete and exhaustiveness, not correctness.
1169
+
1170
+ ### Using it
1171
+
1172
+ ```edd
1173
+ scene {
1174
+ a[Client] ~> b[Server] { animate: surge }
1175
+ a -> b { animation: surge { speed: 1.6 } }
1176
+ }
1177
+ ```
1178
+
1179
+ Both `animate:` and `animation:` are accepted (see `buildEdgeStyle` in
1180
+ `compile.ts`); the `{ speed: N }` styled form sets `animationSpeed`, which your
1181
+ `animationDuration` divides by. The `~>` glyph is a convenient default that already
1182
+ sets `animation: flow` + curved routing — override with the trailing block.
1183
+
1184
+ ---
1185
+
1186
+ ## 4. Add an annotation kind
1187
+
1188
+ Annotations (scripted _and_ live) are `Annotation` records rendered by
1189
+ `AnnotationLayer.draw()` (`src/engine/annotate/layer.ts`). The `kind` field is an
1190
+ open union, so adding one is a single `case` plus a private draw method. We'll add
1191
+ `cross` (a big hand-drawn X over an element — distinct from `strike`, which is one
1192
+ horizontal line).
1193
+
1194
+ **Edit 1 — the `draw` switch** in `AnnotationLayer` (`layer.ts`). `box` is the
1195
+ target's world bounding box (already resolved for you from `an.target`):
1196
+
1197
+ ```ts
1198
+ // src/engine/annotate/layer.ts — inside draw()'s switch (an.kind)
1199
+ case "cross":
1200
+ if (box) this.drawCross(g, box, an);
1201
+ break;
1202
+ ```
1203
+
1204
+ **Edit 2 — the draw method** (same file), using rough.js with a stable `seed` so it
1205
+ doesn't re-jitter:
1206
+
1207
+ ```ts
1208
+ private drawCross(g: SVGGElement, box: BBox, an: Annotation): void {
1209
+ const color = an.color || "#e03131";
1210
+ const opts = { stroke: color, strokeWidth: 3, roughness: 1.6, seed: 33 };
1211
+ g.appendChild(this.rc.line(box.minX, box.minY, box.maxX, box.maxY, opts));
1212
+ g.appendChild(this.rc.line(box.minX, box.maxY, box.maxX, box.minY, opts));
1213
+ if (an.text) this.label(g, an.text, { x: box.minX, y: box.minY - 8 }, color, "start");
1214
+ }
1215
+ ```
1216
+
1217
+ That's enough to use it from a scripted `annotate { … }` block — `parseAnnotationCmd`
1218
+ reads any leading ident as the `kind`, so no parser change is required:
1219
+
1220
+ ```edd
1221
+ scene { a[Deprecated] }
1222
+ annotate {
1223
+ cross a { color: red }
1224
+ }
1225
+ ```
1226
+
1227
+ > Note: to use a **bare** annotation command inside a `timeline` beat or a
1228
+ > `stagger { … }` block (e.g. `beat { cross a }`), also add `"cross"` to the
1229
+ > `ANNOT_KINDS` set at the bottom of `src/engine/dsl/parser.ts` — that set gates
1230
+ > which idents are recognised as annotation verbs in beat context. Top-level
1231
+ > `annotate { … }` blocks don't consult it.
1232
+
1233
+ ### Optional — a live tool
1234
+
1235
+ To let users draw it interactively, teach `LiveAnnotationController`
1236
+ (`src/engine/annotate/interact.ts`):
1237
+
1238
+ 1. **Widen the `Tool` union:** `export type Tool = "select" | … | "cross";`
1239
+ 2. **Add a default** to the `DEFAULTS` map — the element-anchored tool path in
1240
+ `pointerDown()` reads it (click an element → annotation created):
1241
+ ```ts
1242
+ const DEFAULTS = {
1243
+ // …existing…
1244
+ cross: { kind: "cross", color: "#e03131", options: {} },
1245
+ };
1246
+ ```
1247
+ 3. **(Optional) commit-to-code:** add a `case "cross"` to `serialize()` so
1248
+ `annotationsToCode()` round-trips it back to DSL.
1249
+ 4. **Add a toolbar button** in the playground: `src/app/App.tsx`, the `TOOLS`
1250
+ array — `{ tool: "cross", icon: "✕", label: "Cross out element" }`.
1251
+
1252
+ The live layer already handles selection, undo/redo, and camera tracking generically,
1253
+ so those come for free.
1254
+
1255
+ ---
1256
+
1257
+ ## 5. Add a layout
1258
+
1259
+ Auto-layout assigns world positions to non-pinned nodes based on
1260
+ `scene.meta.layout` (a `LayoutKind`). See `src/engine/layout/index.ts`. Adding a
1261
+ mode is three edits; we'll add `column` (a single vertical stack).
1262
+
1263
+ **Edit 1 — the algorithm.** New file `src/engine/layout/column.ts`, mutating each
1264
+ node's top-left `x`/`y` in place (mirror the shape of `grid.ts`/`radial.ts`):
1265
+
1266
+ ```ts
1267
+ // src/engine/layout/column.ts
1268
+ import type { SceneNode } from "../scene/types.js";
1269
+
1270
+ /** Stack nodes in one centred vertical column, top to bottom. */
1271
+ export function layoutColumn(nodes: SceneNode[], gap: number): void {
1272
+ let y = 0;
1273
+ for (const n of nodes) {
1274
+ n.x = -n.w / 2; // centre each node on x = 0
1275
+ n.y = y;
1276
+ y += n.h + gap;
1277
+ }
1278
+ }
1279
+ ```
1280
+
1281
+ **Edit 2 — the dispatch** in `runLayout()` (`layout/index.ts`). Any unknown kind
1282
+ already falls back to grid, so this just makes `column` explicit:
1283
+
1284
+ ```ts
1285
+ import { layoutColumn } from "./column.js";
1286
+ // …
1287
+ switch (kind) {
1288
+ // …existing cases…
1289
+ case "column":
1290
+ layoutColumn(movable, gridGap(scene));
1291
+ return;
1292
+ }
1293
+ ```
1294
+
1295
+ **Edit 3 — the type + DSL mapping.**
1296
+
1297
+ - Add `"column"` to the `LayoutKind` union in `src/engine/scene/types.ts`.
1298
+ - Map the DSL keyword in `resolveLayoutKind()` (`src/engine/dsl/compile.ts`) — this
1299
+ is where `layout <word>` is translated to a `LayoutKind`:
1300
+ ```ts
1301
+ const map: Record<string, LayoutKind> = {
1302
+ dag: "dag", tree: "dag", flow: "dag", force: "dag",
1303
+ grid: "grid", radial: "radial", free: "manual", manual: "manual",
1304
+ column: "column", // <-- new
1305
+ };
1306
+ ```
1307
+
1308
+ ### How a layout is chosen
1309
+
1310
+ `resolveLayoutKind()` runs at compile time: an explicit `layout <kind>` maps through
1311
+ the table above; with no `layout` statement, the compiler defaults to `"dag"` when
1312
+ any node is unpinned, else `"manual"`. `applyLayout(scene)` then reads
1313
+ `scene.meta.layout` and dispatches. Cross-cutting rules that come for free:
1314
+ `pinned` nodes are never moved, the function never throws (falls back to grid), and
1315
+ the final result is normalised so the bounding box starts near `(40, 40)`.
1316
+
1317
+ ### Using it
1318
+
1319
+ ```edd
1320
+ scene {
1321
+ layout column
1322
+ a[One] --> b[Two] --> c[Three]
1323
+ }
1324
+ ```
1325
+
1326
+ ---
1327
+
1328
+ ## 6. Add a DSL construct
1329
+
1330
+ The DSL is a hand-written recursive-descent parser (`src/engine/dsl/parser.ts`)
1331
+ lowered to Scene IR by the compiler (`src/engine/dsl/compile.ts`). Grammar changes
1332
+ touch three files — know where each concern lives:
1333
+
1334
+ | File | Responsibility | Where to hook in |
1335
+ |---|---|---|
1336
+ | `dsl/ast.ts` | AST node types | Add your statement's interface + to the relevant union (`TopStmt`, `SceneStmt`, `BeatItem`, …). |
1337
+ | `dsl/parser.ts` | tokens → AST | Add a keyword case to `parseTop()` (top level) or `parseSceneStmt()` (inside `scene { }`); write a `parseX()` that returns your AST node. |
1338
+ | `dsl/compile.ts` | AST → Scene IR | Handle your node in `compileProgram`'s top-level loop or in `walk()` (scene statements), writing onto the `Scene`. |
1339
+ | `dsl/lower.ts` | enum/glyph → IR value tables | Add any surface-word → IR-value mapping here (e.g. new shape/animation/routing names). |
1340
+
1341
+ Concretely:
1342
+
1343
+ - **A new node/edge attribute** (e.g. `blur: 4`) is the smallest change: no parser
1344
+ edit at all. Attributes are parsed generically by `parseAttrEntry`, so you only add
1345
+ a `case "blur":` to `buildNodeStyle` (or `buildEdgeStyle`) in `compile.ts` and a
1346
+ field to `NodeStyle`/`EdgeStyle` in `scene/types.ts` (then honour it in the
1347
+ renderer). This is the recommended path for most "new knob" requests.
1348
+ - **A new top-level keyword** (e.g. `legend { … }`): add `case "legend": return
1349
+ this.parseLegend();` to `parseTop()`, define `parseLegend()` (use
1350
+ `parseAttrBlock()` / `skipBlock()` helpers), add the AST type, and consume it in
1351
+ `compileProgram`'s statement loop. Note `parseTop` already _tolerates_ unknown
1352
+ top-level keywords like `plugin`/`define` by skipping their block — a good template
1353
+ for "parse but ignore for now."
1354
+ - **A new scene-level statement** (e.g. `ruler …`): add a `case` to
1355
+ `parseSceneStmt()`'s keyword switch and handle it in `walk()`.
1356
+
1357
+ Keep the parser **permissive and recoverable**: report problems via
1358
+ `this.error(code, msg, token, …)` (which pushes a `Diagnostic`) and call
1359
+ `recoverStmt()` / `skipBlock()` rather than throwing. The compiler accumulates
1360
+ `Diagnostic`s and always returns a best-effort `Scene` — see
1361
+ [DEVELOPMENT_STANDARDS.md](DEVELOPMENT_STANDARDS.md) ("Diagnostics, not
1362
+ exceptions").
1363
+
1364
+ For the full user-facing grammar, see
1365
+ [DSL_LANGUAGE_GUIDE.md](DSL_LANGUAGE_GUIDE.md).
1366
+
1367
+ ---
1368
+
1369
+ ## 7. Testing your extension
1370
+
1371
+ Follow the two-stage rule from [DEVELOPMENT_STANDARDS.md](DEVELOPMENT_STANDARDS.md).
1372
+
1373
+ **Stage 1 — code-level (fast, Node/jsdom).** The compiler is synchronous and
1374
+ DOM-free, so most extensions are unit-testable without a browser. Add a spec under
1375
+ `tests/` (vitest) and run:
1376
+
1377
+ ```bash
1378
+ npm test # vitest run
1379
+ npm run typecheck # tsc -b --noEmit
1380
+ ```
1381
+
1382
+ A shape/animation/annotation test typically compiles a snippet and asserts on the
1383
+ resulting `Scene`:
1384
+
1385
+ ```ts
1386
+ import { describe, it, expect } from "vitest";
1387
+ import { compileEdd } from "../src/engine/dsl/index.js";
1388
+
1389
+ describe("gauge shape", () => {
1390
+ it("passes an unknown shape name through unchanged", () => {
1391
+ const { scene } = compileEdd(`scene { g[CPU] { shape: gauge } }`);
1392
+ expect(scene.nodes[0].shape).toBe("gauge");
1393
+ });
1394
+ });
1395
+
1396
+ describe("surge animation", () => {
1397
+ it("keeps a registered animation kind", () => {
1398
+ const { scene } = compileEdd(`scene { a -> b { animate: surge } }`);
1399
+ expect(scene.edges[0].style.animation).toBe("surge");
1400
+ });
1401
+ });
1402
+ ```
1403
+
1404
+ Existing suites to mirror: `tests/parser.test.ts`, `tests/lowering.test.ts`,
1405
+ `tests/layout.test.ts`, `tests/annotations.test.ts`.
1406
+
1407
+ **Stage 2 — end-user visual check.** A green unit test does not prove the
1408
+ hand-drawn render is correct. Run the app and drive it with `playwright-cli`, then
1409
+ **read the screenshots**:
1410
+
1411
+ ```bash
1412
+ npm run dev # dev server (see DEVELOPMENT_STANDARDS.md)
1413
+ scripts/qa/smoke.sh # loads every example, screenshots, fails on console errors
1414
+ ```
1415
+
1416
+ Add an example that exercises your extension (an `.edd` file wired into the
1417
+ playground's example list) so the smoke test covers it, and confirm no
1418
+ per-element render warnings appear in the console — the renderer wraps each
1419
+ node/edge so one bad element can't blank the diagram, but it _will_ log a warning
1420
+ you should catch.
1421
+
1422
+ ==============================================================================
1423
+
1424
+ # ═══ Architecture (docs/ARCHITECTURE.md) ═══
1425
+
1426
+ # EDodoDraw Architecture
1427
+
1428
+ EDodoDraw is a **100% code-to-diagram engine** with the Excalidraw hand-drawn aesthetic, a magic-move camera, and scriptable + real-time annotations. It is built from the ground up (no Excalidraw runtime) on three reused primitives: **rough.js** (hand-drawn strokes), **dagre** (auto-layout), and **@excalidraw/mermaid-to-excalidraw** (Mermaid import). The hand-drawn font is the OFL-licensed **Virgil/Excalifont**.
1429
+
1430
+ ## The pipeline
1431
+
1432
+ ```mermaid
1433
+ flowchart LR
1434
+ src["EDodoDraw code (.edd)"] --> lex[Lexer]
1435
+ lex --> parse[Parser → AST]
1436
+ parse --> compile[Compiler]
1437
+ compile --> scene["Scene IR"]
1438
+ merm["mermaid ' … '"] -. import .-> scene
1439
+ scene --> layout["Layout (dagre/grid/radial)"]
1440
+ layout --> render["SVG Renderer (rough.js)"]
1441
+ render --> cam[Camera controller]
1442
+ render --> anno[Annotation layer]
1443
+ render --> tl[Timeline player]
1444
+ ```
1445
+
1446
+ Everything flows through the **Scene IR** (`src/engine/scene/types.ts`) — a flat, serializable model of nodes, edges, groups, annotations, and timeline steps. The DSL, the Mermaid importer, and any programmatic caller all produce a `Scene`; the renderer and controllers only consume one. This is the single contract that keeps the system decoupled.
1447
+
1448
+ ## Modules
1449
+
1450
+ | Area | Path | Responsibility | Guide |
1451
+ |---|---|---|---|
1452
+ | Scene IR | `src/engine/scene/` | Types, palette, factories, anchors, queries | — |
1453
+ | DSL | `src/engine/dsl/` | `lexer → parser → ast → compile` + diagnostics | [DSL_LANGUAGE_GUIDE](DSL_LANGUAGE_GUIDE.md) |
1454
+ | Layout | `src/engine/layout/` | dagre/grid/radial → node positions | — |
1455
+ | Renderer | `src/engine/render/` | SVG + rough.js, shapes, edges, arrowheads, fonts | — |
1456
+ | Camera | `src/engine/camera/` | fit math, easing, animated controller | [CAMERA_AND_TIMELINE_GUIDE](CAMERA_AND_TIMELINE_GUIDE.md) |
1457
+ | Timeline | `src/engine/timeline/` | beat player (magic-move) | [CAMERA_AND_TIMELINE_GUIDE](CAMERA_AND_TIMELINE_GUIDE.md) |
1458
+ | Annotations | `src/engine/annotate/` | render layer + live interactive editor | [ANNOTATIONS_GUIDE](ANNOTATIONS_GUIDE.md) |
1459
+ | Import/Export | `src/engine/import/`, `src/engine/export.ts` | Mermaid in; SVG/PNG/JSON out | [IMPORT_AND_EXPORT_GUIDE](IMPORT_AND_EXPORT_GUIDE.md) |
1460
+ | Plugins | `src/engine/plugins/` | shape registry (extension seam) | [DEVELOPMENT_STANDARDS](DEVELOPMENT_STANDARDS.md) |
1461
+ | App | `src/app/` | React playground: editor, canvas, toolbar, player | [DEVELOPMENT_STANDARDS](DEVELOPMENT_STANDARDS.md) |
1462
+
1463
+ `src/engine/index.ts` is the public barrel; the app imports only from `@engine/*`.
1464
+
1465
+ ## Key decisions
1466
+
1467
+ - **SVG, not Canvas.** The world layer is one `<g>` whose `transform` attribute is the camera. This makes the magic-move camera a single attribute update per frame, GPU-friendly, and lets annotations/animated arrows be DOM elements with CSS animations. Trade-off: not ideal for tens of thousands of elements — fine for diagrams.
1468
+ - **Imperative renderer outside React.** `SvgRenderer` owns the SVG DOM directly (like Excalidraw owns its canvas). React only mounts the container and drives high-level state. Full re-render on scene change; camera/animation mutate transforms only.
1469
+ - **Deterministic hand-drawn strokes.** Every element gets a stable rough.js `seed` (hash of its id) so re-rendering never re-jitters strokes.
1470
+ - **Open unions + registries.** `ShapeKind`, `ArrowAnimationKind`, and `AnnotationKind` are open string unions; unknown values resolve through registries (`src/engine/plugins/`). New shapes/animations need no grammar or core change.
1471
+ - **Sync compiler, async Mermaid.** The DSL compiler is synchronous and DOM-free (unit-testable in Node). Mermaid rendering needs the browser, so the app awaits `convertMermaid` and injects the fragment — the compiler itself stays pure.
1472
+
1473
+ ## Rendering layers (bottom → top)
1474
+
1475
+ `bg (screen)` · `grid` · `groups` · `edges` · `nodes` · `annotations` (scripted) · `live` (interactive) — the last two sit in the camera-transformed world layer so annotations track the diagram. See `src/engine/render/svgRenderer.ts`.
1476
+
1477
+ ## Testing
1478
+
1479
+ - Unit tests (vitest, jsdom): `tests/` — lexer, parser, compiler, lowering, layout, camera math, anchors, annotations round-trip. Run `npm test`.
1480
+ - Visual smoke: `scripts/qa/smoke.sh` drives every example with `playwright-cli` and fails on console errors. Two-stage testing per `docs`-referenced FE guide: code-level first, end-user browser interaction last.
1481
+
1482
+ ==============================================================================
1483
+
1484
+ # ═══ Development standards (docs/DEVELOPMENT_STANDARDS.md) ═══
1485
+
1486
+ # Development Standards
1487
+
1488
+ ## Setup
1489
+
1490
+ ```bash
1491
+ npm install
1492
+ npm run dev # http://localhost:5273
1493
+ npm test # vitest
1494
+ npm run typecheck # tsc -b --noEmit
1495
+ npm run build # tsc + vite build → dist/
1496
+ ```
1497
+
1498
+ Node ≥ 18 (matches `engines`). The `reference/` directory (Excalidraw + mermaid-to-excalidraw clones, studied during design) is gitignored and excluded from Vite (`vite.config.ts → optimizeDeps.entries` + `server.watch.ignored`).
1499
+
1500
+ ## Project structure
1501
+
1502
+ ```
1503
+ src/
1504
+ engine/ framework-agnostic engine (no React)
1505
+ scene/ Scene IR: types, palette, defaults, anchors, query
1506
+ dsl/ lexer, tokens, ast, parser, lower, compile, diagnostics
1507
+ layout/ dagre / grid / radial → node positions
1508
+ render/ svgRenderer, shapes, edges, theme.css, fonts
1509
+ camera/ fit math, easing, controller
1510
+ timeline/ beat player
1511
+ annotate/ layer (render) + interact (live editor)
1512
+ import/ mermaid adapter
1513
+ plugins/ registry + builtins
1514
+ export.ts SVG / PNG / JSON
1515
+ index.ts public barrel (import from "@engine/…")
1516
+ app/ React playground (App, CanvasView, examples)
1517
+ examples/ *.edd sample programs (imported ?raw)
1518
+ docs/ this documentation
1519
+ tests/ vitest suites
1520
+ scripts/qa/ playwright-cli smoke test
1521
+ public/fonts/ OFL hand-drawn fonts (Virgil, Excalifont)
1522
+ ```
1523
+
1524
+ The engine is pure TypeScript with no React dependency; the app is a thin shell over it. Import engine code via the `@engine/*` alias with `.js` extensions (bundler resolution maps to `.ts`).
1525
+
1526
+ ## Conventions
1527
+
1528
+ - **Scene IR is the contract.** Anything that produces diagrams emits a `Scene`; anything that draws consumes one. Don't reach around it.
1529
+ - **Deterministic seeds.** Element factories (`makeNode`/`makeEdge`) assign a rough.js seed from the id hash. Preserve that when adding element kinds.
1530
+ - **Never throw in the lexer/renderer per-element.** The lexer is permissive; the renderer wraps each node/edge so one bad element can't blank the diagram.
1531
+ - **Diagnostics, not exceptions.** The compiler accumulates `Diagnostic`s and recovers at statement/block boundaries.
1532
+
1533
+ ## Extending the engine
1534
+
1535
+ ### Add a shape
1536
+
1537
+ Register a renderer — no grammar change needed (the DSL already accepts any shape name):
1538
+
1539
+ ```ts
1540
+ import { registerShape } from "@engine/index.js";
1541
+ import { nodeRoughOptions } from "@engine/render/shapes.js";
1542
+
1543
+ registerShape("hexstar", (rc, rect, style) => {
1544
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
1545
+ g.appendChild(rc.polygon(myPoints(rect), nodeRoughOptions(style, true)));
1546
+ return g;
1547
+ });
1548
+ ```
1549
+
1550
+ Then `shape: hexstar` (or a keyword mapped in `src/engine/dsl/lower.ts → SHAPE_MAP`) works. See `src/engine/plugins/builtins.ts` for the built-in `star`.
1551
+
1552
+ ### Add an animated arrow
1553
+
1554
+ Add a `case` in `animationOverlay` (`src/engine/render/edges.ts`) and a keyframe in `src/engine/render/theme.css.ts`, then list it in `mapAnimation` (`src/engine/dsl/lower.ts`).
1555
+
1556
+ ### Add an annotation kind
1557
+
1558
+ Add a `case` in `AnnotationLayer.draw` (`src/engine/annotate/layer.ts`) and, for live editing, a tool in `LiveAnnotationController` (`src/engine/annotate/interact.ts`) + the toolbar in `src/app/App.tsx`.
1559
+
1560
+ ### Add a DSL construct
1561
+
1562
+ Grammar-level changes live in `src/engine/dsl/parser.ts` (recursive descent, keyword-dispatched) and are lowered to Scene IR in `src/engine/dsl/compile.ts`. Add tests in `tests/parser.test.ts`.
1563
+
1564
+ ## Testing standard
1565
+
1566
+ Two stages (mandatory for anything user-facing):
1567
+
1568
+ 1. **Code-level** — `npm test` (vitest) + `npm run typecheck`.
1569
+ 2. **End-user** — drive the running app with `playwright-cli` (`scripts/qa/smoke.sh`), screenshot every state, and **read every screenshot**. A route returning the right JSON does not prove the hand-drawn render is correct.
1570
+
1571
+ ==============================================================================
1572
+
1573
+ # ▸ Runnable examples (examples/*.edd)
1574
+
1575
+ Each is a complete EDodoDraw program. Paste any into the playground
1576
+ (https://vivmagarwal.github.io/edododraw/#/playground) or compile it with `compileEdd(source)`.
1577
+
1578
+ ## examples/animated-arrows.edd
1579
+
1580
+ ```edd
1581
+ edd 1.0
1582
+ meta { title: "Animated Arrow Gallery" }
1583
+
1584
+ // EDodoDraw ships far more animated connectors than Excalidraw.
1585
+ scene {
1586
+ layout dag { direction: right, gap: 120 }
1587
+
1588
+ circle a "A" { fill: blue }
1589
+ circle b "B" { fill: green }
1590
+
1591
+ a -> b "flow" { animate: flow }
1592
+ a -> b "dash-march" { animate: dash-march }
1593
+ a -> b "draw-on" { animate: draw-on }
1594
+ a -> b "comet" { animate: comet, color: red }
1595
+ a -> b "gradient-flow" { animate: gradient-flow }
1596
+ a -> b "electric" { animate: electric, color: violet }
1597
+ a -> b "pulse" { animate: pulse }
1598
+ }
1599
+ ```
1600
+
1601
+ ## examples/architecture.edd
1602
+
1603
+ ```edd
1604
+ edd 1.0
1605
+ meta { title: "WebShop Reference Architecture" }
1606
+
1607
+ theme light {
1608
+ tokens { $accent: #2563eb, $muted: #9ca3af, $danger: #e03131 }
1609
+ }
1610
+
1611
+ style .card { shape: round-rect, fill: blue }
1612
+ style .critical { stroke: $danger, strokeWidth: bold }
1613
+
1614
+ defaults {
1615
+ edge { endArrow: arrow }
1616
+ }
1617
+
1618
+ scene {
1619
+ use theme light
1620
+ layout dag { direction: down, gap: 64, rankGap: 110 }
1621
+
1622
+ actor client "User"
1623
+ cloud cdn "CDN / Edge" { fill: cyan }
1624
+ hexagon gw "API Gateway" { fill: violet, strokeWidth: bold }
1625
+
1626
+ group services "Application Services" {
1627
+ round-rect auth "Auth Service" :::card
1628
+ round-rect api "API Service" :::card :::critical
1629
+ subroutine worker "Async Worker"
1630
+ }
1631
+
1632
+ group data "Data Layer" {
1633
+ cylinder db "Postgres" { fill: teal }
1634
+ cylinder cache "Redis" { fill: red }
1635
+ parallelogram queue "Message Queue" { fill: orange }
1636
+ }
1637
+
1638
+ note legacy "Legacy Billing\n(deprecate)" { at: (760, 40), pin: true, strokeStyle: dashed }
1639
+
1640
+ // Arrowhead + animation variety
1641
+ client -> cdn "https"
1642
+ cdn ==> gw "cache miss" { color: $accent }
1643
+ gw -> auth "authZ" { startArrow: dot }
1644
+ gw -> api "REST" { animate: dash-march }
1645
+ api ~> worker "enqueue" { animate: flow }
1646
+ api -> db "write" { endArrow: triangle, animate: draw-on }
1647
+ api -.-> cache "read-through"
1648
+ worker -> queue "publish" { animate: comet, color: $accent }
1649
+ queue -> db "persist"
1650
+ api -x legacy "deprecated" { color: $muted, strokeStyle: dashed }
1651
+ }
1652
+
1653
+ annotate "persistent" {
1654
+ callout db "source of truth" { placement: bottom }
1655
+ }
1656
+
1657
+ timeline story {
1658
+ beat overview "The whole system" {
1659
+ camera fit-all over 800ms
1660
+ reveal { show all with fade-in }
1661
+ narrate: "End-to-end request path."
1662
+ }
1663
+
1664
+ beat ingress "Traffic enters at the edge" {
1665
+ camera focus [client, cdn, gw] zoom 1.5 ease spring
1666
+ annotate {
1667
+ point-at gw "single entry point" { from: ne, color: $accent }
1668
+ highlight cdn { color: yellow }
1669
+ }
1670
+ }
1671
+
1672
+ beat services "Inside the app tier" {
1673
+ camera focus services zoom 1.6
1674
+ annotate {
1675
+ spotlight services { dim: 0.72 }
1676
+ underline api "the hot path" { color: $accent }
1677
+ }
1678
+ narrate: "Auth fronts the API; the worker drains async jobs."
1679
+ }
1680
+
1681
+ beat data "Where state lives" {
1682
+ camera focus data zoom 1.7 ease ease-in-out
1683
+ annotate {
1684
+ callout db "primary of record" { placement: right }
1685
+ circle-mark queue "retry here"
1686
+ }
1687
+ narrate: "All writes funnel through Postgres; Redis is read-through."
1688
+ hold: 2s
1689
+ }
1690
+ }
1691
+ ```
1692
+
1693
+ ## examples/flowchart.edd
1694
+
1695
+ ```edd
1696
+ edd 1.0
1697
+ meta { title: "Signup Flow" }
1698
+
1699
+ scene {
1700
+ layout dag { direction: down, gap: 60 }
1701
+
1702
+ start([Start])
1703
+ form[Signup Form]
1704
+ valid{Valid?}
1705
+ ok(Create Account):::good
1706
+ err(Show Errors):::bad
1707
+
1708
+ start --> form
1709
+ form --> valid
1710
+ valid -->|yes| ok
1711
+ valid -->|no| err
1712
+ err --> form
1713
+ }
1714
+
1715
+ style .good { stroke: green, fill: green }
1716
+ style .bad { stroke: red, fill: red }
1717
+ ```
1718
+
1719
+ ## examples/mermaid-import.edd
1720
+
1721
+ ```edd
1722
+ edd 1.0
1723
+ meta { title: "Imported from Mermaid" }
1724
+
1725
+ // Paste raw Mermaid — EDodoDraw imports it, keeps the node ids,
1726
+ // and lets you choreograph a walkthrough on top.
1727
+ mermaid """
1728
+ flowchart LR
1729
+ app[App Servers] --> otel[OTel Collector]
1730
+ otel --> prom[Prometheus]
1731
+ otel --> loki[Loki]
1732
+ prom --> grafana[Grafana]
1733
+ loki --> grafana
1734
+ prom --> alertmgr[Alertmanager]
1735
+ """
1736
+
1737
+ timeline walkthrough {
1738
+ beat all "Pipeline overview" {
1739
+ camera fit-all over 700ms
1740
+ narrate: "Telemetry flows left to right into Grafana."
1741
+ }
1742
+ beat collect "Collection" {
1743
+ camera focus [app, otel] zoom 1.7
1744
+ annotate {
1745
+ highlight otel { color: yellow }
1746
+ point-at otel "single ingestion point" { from: s }
1747
+ }
1748
+ }
1749
+ beat visualize "Dashboards & alerts" {
1750
+ camera focus [grafana, alertmgr] zoom 1.6
1751
+ annotate {
1752
+ circle-mark grafana "the pane of glass"
1753
+ callout alertmgr "pages on-call" { placement: top }
1754
+ }
1755
+ }
1756
+ }
1757
+ ```
1758
+
1759
+ ## examples/welcome.edd
1760
+
1761
+ ```edd
1762
+ edd 1.0
1763
+ meta { title: "Welcome to EDodoDraw" }
1764
+
1765
+ // Everything you see is generated from THIS code.
1766
+ // Edit anything on the left and the diagram updates live.
1767
+
1768
+ scene {
1769
+ layout dag { direction: down, gap: 72 }
1770
+
1771
+ ellipse hello "Hello 👋" { fill: yellow }
1772
+ rect idea "Diagrams as CODE" { fill: blue }
1773
+ round-rect vibe "…with the Excalidraw vibe" { fill: green }
1774
+ diamond edit "Try editing me!" { fill: violet }
1775
+
1776
+ hello --> idea "type"
1777
+ idea --> vibe "render"
1778
+ vibe --> edit "enjoy"
1779
+ edit ~> hello "loop" // ~> == a flowing animated arrow
1780
+ }
1781
+ ```
1782
+