edododraw 0.1.0 → 0.1.1

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