@grida/svg-editor 1.0.0-alpha.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.
package/README.md ADDED
@@ -0,0 +1,831 @@
1
+ # @grida/svg-editor
2
+
3
+ Grida SVG Editor is a **clean** SVG editor. Experimental.
4
+
5
+ ## What "clean" means
6
+
7
+ Open an SVG file, edit it, save it. The diff should be exactly the change you made — nothing more.
8
+
9
+ Most editors don't do this:
10
+
11
+ - **Adobe Illustrator** saves files wrapped in its own `<switch>` / `<foreignObject>` / `<i:pgfRef>` scaffolding, converts circles to four-cubic-Bézier paths, stamps elements with generated classes (`.st0`, `.st1`) and ids (`SVGID_1_`), and emits coordinates at eight decimals of precision.
12
+ - **Inkscape** injects its own namespace metadata (`sodipodi:namedview`, `inkscape:version`, `inkscape:groupmode`, ...) on every save, and reformats whitespace, attribute order, and `<defs>` ordering even when nothing was changed.
13
+ - **AI agents** that read and rewrite SVG produce wildly variable output and have no shared discipline for what to preserve.
14
+
15
+ These tools all _render_ the file correctly. They just leave the markup in a state where the next editor — or the next AI pass, or `git diff` — can't tell what actually changed.
16
+
17
+ A clean editor:
18
+
19
+ 1. **Round-trips by default.** Open + save without edits → byte-equal output. Comments, whitespace, attribute order, and even legacy or unknown-namespace attributes survive verbatim.
20
+ 2. **Mutates minimally.** Change one attribute on one element → one attribute's worth of diff. Nothing else moves.
21
+ 3. **Adds no proprietary noise.** No editor-specific namespaces. No invented ids or classes. No reflow of unrelated nodes.
22
+ 4. **Is honest about its scope.** SVG is full of constructs that can't be edited cleanly in a graphical UI (cascading CSS, SMIL animation, `<switch>` language branches, foreign-namespace content). The editor preserves them, surfaces them, and refuses to mutate them rather than silently mishandling them.
23
+
24
+ ## Why this matters
25
+
26
+ The world is moving toward AI-native authoring formats — HTML, CSS, SVG — because they're what large language models read and write fluently. As that shift accelerates, the bottleneck isn't generation. It's _collaboration_: humans and AI editing the same files, in the same repos, taking turns.
27
+
28
+ Real collaboration requires editors that don't fight the file. Today, none of the obvious options do:
29
+
30
+ - Illustrator and Inkscape were built as authoring tools, not collaboration tools. They round-trip badly because nothing in their design rewards round-trip fidelity.
31
+ - AI agents produce text but have no stable, shared notion of "minimal change."
32
+ - Figma, Sketch, and Grida-native treat SVG as import/export. The native IR is private; SVG is a foreign format on both ends.
33
+
34
+ The result is that every round trip damages the file, every commit produces a noisy diff, and AI's next pass conflicts with the editor's last save. Trust erodes. Co-editing becomes unworkable.
35
+
36
+ This problem is hard for a real reason. SVG was designed to be hand-authored. It supports CSS cascades, scripted animation, foreign-namespace metadata, embedded fonts, and dozens of features that are coherent for a human writer but actively hostile to a canonical editor IR. Most editors solve this by ignoring or normalizing those features — at which point they're no longer editing SVG, they're editing their interpretation of it.
37
+
38
+ Grida SVG Editor takes the opposite stance: **the file is the source of truth, and the editor's job is to honor it.**
39
+
40
+ ## Paradigm
41
+
42
+ The public surface is **narrow and stable**: one editor object, a finite command vocabulary, a designed observation API, and a small set of provider hooks. The internal architecture is **composite by necessity** — SVG has ~20 element types with genuinely distinct edit semantics, so per-element capability modules exist, but they are not exported.
43
+
44
+ The defaults are inverted from how most editor SDKs grow. Default is **core, not customizable**. A new capability is core unless three things are true: customizing it doesn't violate the round-trip invariant, it is genuinely a host concern, and there is no view of the editor's state that would let a consumer build it themselves.
45
+
46
+ The consumer is expected to bring their own UI for everything outside the canvas: toolbar, property panel, layer list, inspector, contextual menus, modals. The editor's job is to be a legible source of state and a legible sink for commands — not to render those surfaces.
47
+
48
+ ## Principles
49
+
50
+ These are decision rules, not aspirations. Each one points to a verdict when "is this core, customizable, or its own layer?" comes up in review.
51
+
52
+ ### P1. The file format is sovereign.
53
+
54
+ Any decision that, if customized, would let a consumer corrupt round-trip fidelity, emit proprietary noise, or silently lose preserved metadata is core. Customization is not an option.
55
+
56
+ ### P2. Customize only what the host genuinely owns.
57
+
58
+ A small, named set of concerns belongs to the embedding product: clipboard, file IO, font resolution, the rendering surface, the HUD chrome style, locale. These are provider hooks at construction (or attached afterwards, in the surface's case). Everything else is editor-decided.
59
+
60
+ ### P3. Per-element semantics are internal architecture, not extension points.
61
+
62
+ `<rect>`, `<path>`, `<text>`, `<use>`, etc. require modular code organization because they share no edit semantics. They do not require a public registry. The SVG spec is the registry; we implement against it.
63
+
64
+ ### P4. Subscribe to outcomes, not events.
65
+
66
+ The public observation surface is **designed**, not raw. It exposes purpose-built views — `selection`, `node_properties`, `node_paint`, `tree`, `surface_hover`, `dirty`, `version`, `structure_version` — each of which handles multi-selection, capability variance, and history bookkeeping internally. Consumers never receive raw pointer events, reducer actions, or gesture frames. If a needed view doesn't exist, that's an API gap to close, not an internals hatch to open.
67
+
68
+ ### P5. A separate layer earns its separateness by reuse or isolated testability.
69
+
70
+ Code becomes its own package or layer when it has ≥2 callers, or can be meaningfully tested without mounting the editor. `@grida/history`, `@grida/cmath`, `@grida/text-editor`, `@grida/keybinding`, `@grida/hud` pass. A hypothetical `@grida/svg-selection-model` (one caller, untestable without an editor) doesn't.
71
+
72
+ ### P6. Public only after dogfooding.
73
+
74
+ Internal seams stay internal until ≥2 internal consumers have shaped the contract. The default direction of pressure is inward, not outward.
75
+
76
+ ### The deciding table
77
+
78
+ When a new design decision lands, walk these in order. The first match wins.
79
+
80
+ | Question | If yes → | Why |
81
+ | -------------------------------------------------- | --------------------------------------- | --------------------------- |
82
+ | Would customization violate P1 (file sovereignty)? | **Core**, non-customizable | Editor owns the invariant |
83
+ | Is this a host-owned concern per P2? | **Customizable** via provider | Host knows what we can't |
84
+ | Is this per-element edit semantics? | **Internal seam** (P3) | Code organization, not API |
85
+ | Does it pass P5 (reuse or isolated tests)? | **Separate layer** | Earned its separation |
86
+ | Otherwise | **Core**, internally modular if complex | Default-in, not default-out |
87
+
88
+ ## Approach
89
+
90
+ These are the design principles guiding the implementation.
91
+
92
+ - Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core. v1 ships a DOM surface; non-DOM surfaces are out of scope.
93
+ - The IR carries source-position trivia, so the serializer can rewrite only the bytes that actually changed.
94
+ - Per-element-type capability modules (rect, circle, path, text, group, use, ...) contribute intent handlers, inspector controls, and direct-manipulation overlays into a shared editor shell — internally. The shared shell is what's public.
95
+ - Edit intents are dispatched per `(element type, gesture, mode)`, so each mutation chooses the cleanest in-place representation: rewrite native attributes when the gesture allows it, fall back to `transform=` otherwise.
96
+ - A separate, explicit **Tidy** command is planned for structural cleanup (deduplicate defs, strip dead resources, normalize generated class and id names, recognize geometric patterns). Never silent, never automatic. **Deferred for v1.**
97
+
98
+ ## Examples
99
+
100
+ A few scenarios this is designed to handle well.
101
+
102
+ > _"I opened an SVG exported from Illustrator five years ago and nudged a circle 10px to the right."_
103
+ >
104
+ > Diff: one attribute. The Adobe wrapper, the `.st0` classes, the dead gradients, and the wrapping identity-matrix `<g>` layers are all untouched.
105
+
106
+ > _"My AI agent rewrote a logo file. I want to tweak two colors and commit."_
107
+ >
108
+ > The AI's structural changes round-trip cleanly through the editor. The two color tweaks are the only added lines in the diff. The next AI pass reads a file that still looks like its own work.
109
+
110
+ > _"I translated a rect 10px to the right."_
111
+ >
112
+ > The editor rewrites the rect's `x` in place. It does not wrap it in a `<g>`, does not collapse to a matrix, and does not touch the rect's `y` / `width` / `height`.
113
+
114
+ > _"This SVG has a `<style>` block with `.brand { fill: var(--brand) }`. I want to change the fill of one element to red."_
115
+ >
116
+ > The editor adds `style="fill: red"` inline on that element, which wins the cascade against the class rule. The stylesheet is untouched. Other `.brand` elements are untouched. **Note for v1:** the headless cascade engine does **not** match `<style>` rules — it covers presentation attribute + inline style + inheritance + initial only. To see what the `.brand` rule actually resolves to for the inspector, attach the DOM surface and use `editor.dom_computed_property(id, "fill")`, which delegates to `getComputedStyle()` (see [Low-level API](#low-level-api)).
117
+
118
+ > _"This file has an Inkscape `<sodipodi:namedview>` block and `inkscape:label` attributes."_
119
+ >
120
+ > Preserved verbatim. Surfaced in a "preserved metadata" panel so the user knows they exist. Never edited; never silently dropped.
121
+
122
+ > _"This file has a SMIL `<animate>` on a circle's `cx`. I want to move the circle."_
123
+ >
124
+ > The editor freezes the animation at `t=0` for editing, applies the position change to the static `cx` attribute, and preserves the `<animate>` block verbatim. The inspector shows a clear "animated property" badge so the user understands what they're editing.
125
+
126
+ ## Install
127
+
128
+ ```sh
129
+ npm install @grida/svg-editor
130
+ ```
131
+
132
+ ## API
133
+
134
+ > **v1 status.** Names and shapes are stabilizing toward `1.0.0`. The high-level API documented here is the durable contract; the [Low-level API](#low-level-api) section is where escape hatches live for things v1 doesn't yet cover. The list of pieces not in v1 is documented honestly under [Deferred for v1](#deferred-for-v1) — every promise we're not yet keeping. Signatures shown as TypeScript-ish pseudo-code; some types are simplified for readability.
135
+
136
+ ### Construction
137
+
138
+ ```ts
139
+ import { createSvgEditor } from "@grida/svg-editor";
140
+
141
+ const editor = createSvgEditor({
142
+ svg: "<svg ...>...</svg>",
143
+ providers: {
144
+ clipboard, // optional
145
+ fonts, // optional — font availability + metrics resolver
146
+ file_io, // optional — for "open" / "save as" commands
147
+ },
148
+ style: {
149
+ chrome_color: "#2563eb",
150
+ handle_size: 8,
151
+ // ...style spec, snake_case (see "Style" below)
152
+ },
153
+ });
154
+ ```
155
+
156
+ `createSvgEditor` is the only constructor. The returned `SvgEditor` is the only object consumers ever hold.
157
+
158
+ The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a surface (next section).
159
+
160
+ ### Surface
161
+
162
+ A surface is the editor's attachment seam — it mounts the SVG into a host environment, listens for input, and renders HUD overlays. **v1 ships one surface, for the browser DOM.** Non-DOM surfaces (worker-side renderer, React Native, headless test harness) are out of scope.
163
+
164
+ ```ts
165
+ import { attach_dom_surface } from "@grida/svg-editor/dom";
166
+
167
+ const handle = attach_dom_surface(editor, { container });
168
+ // later:
169
+ handle.detach();
170
+ ```
171
+
172
+ `@grida/svg-editor/dom` is the only place in this package that imports DOM types. The internal `Surface` type exists for tests and future work; it is not a documented extension point at v1.
173
+
174
+ ### Lifecycle
175
+
176
+ ```ts
177
+ editor.attach(surface: Surface): SurfaceHandle; // returns { detach() }
178
+ editor.detach(): void; // detach current surface, keep editor state
179
+ editor.dispose(): void; // permanent teardown
180
+ ```
181
+
182
+ `load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached. The lone DOM-dependent reads — `dom_computed_property` / `dom_computed_paint` — return `null` when no DOM surface is attached.
183
+
184
+ ### External control
185
+
186
+ ```ts
187
+ editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
188
+ editor.serialize(): string; // emit clean SVG — guaranteed round-trip per P1
189
+ editor.reset(): void; // back to last load() input, clears history
190
+ ```
191
+
192
+ ### Observation — state
193
+
194
+ ```ts
195
+ editor.state: {
196
+ readonly selection: ReadonlyArray<NodeId>;
197
+ readonly scope: NodeId | null; // active isolation (group entered via dblclick)
198
+ readonly mode: Mode; // "select" | "edit-content"
199
+ readonly dirty: boolean; // unsaved changes since load() / serialize()
200
+ readonly can_undo: boolean;
201
+ readonly can_redo: boolean;
202
+ readonly version: number; // bumps on every emit (selection, mutation, history)
203
+ readonly structure_version: number; // bumps only on tree-shape / display-label changes;
204
+ // stable across pure attribute writes
205
+ };
206
+
207
+ editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
208
+ editor.subscribe_with_selector<T>(
209
+ selector: (state: EditorState) => T,
210
+ fn: (value: T, prev: T) => void,
211
+ equals?: (a: T, b: T) => boolean,
212
+ ): Unsubscribe;
213
+ ```
214
+
215
+ `state` is a frozen snapshot. Consumers never destructure into internals; if a view they need isn't here or in the purpose-built views below, that's an API gap.
216
+
217
+ **`version` vs `structure_version`.** `version` ticks on every emission — selection, mutation, history. `structure_version` ticks only when something a hierarchy / layers view cares about changes (a node is added / removed / reordered, text-node content changes, an `id` attribute changes). During a drag, `version` ticks repeatedly while `structure_version` stays put, so a layers panel keyed on `structure_version` doesn't re-render at gesture rate.
218
+
219
+ ### Observation — properties
220
+
221
+ This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection aggregation ("mixed values") is deferred for v1 — see [Deferred for v1](#deferred-for-v1).
222
+
223
+ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: **declared → cascaded → specified → computed → used → actual** ([css-cascade-5 §4](https://www.w3.org/TR/css-cascade-5/#value-stages)). For an SVG property panel, two stages are useful:
224
+
225
+ - **`declared`** — the literal source string as authored, with CSS-wide keywords (`inherit`, `initial`, `unset`) already resolved per [css-cascade-5 §7.3](https://www.w3.org/TR/css-cascade-5/#defaulting-keywords), but with `var()` and `url(#…)` references preserved verbatim. This is what the file says; this is what round-trips.
226
+ - **`computed`** — the value after [`var()` substitution at computed-value time](https://www.w3.org/TR/css-variables-1/#substitute-a-var) and type-parsing per the property's definition. For paint with `url(#id)`, the reference itself is the computed value (paint servers are not dereferenced at computed time, per [SVG 2 §13.2](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint)). If a `var()` cannot resolve, `computed` is a distinct error state ("invalid at computed-value time"), not silently absent.
227
+
228
+ Plus the editor's own metadata, marked as such:
229
+
230
+ - **`provenance`** — _editor metadata, not a CSS spec term_. Reports which document carrier won the cascade for this node (presentation attribute, inline style, inherited from parent, defaulted). The cascade itself collapses these into the "author" origin; we surface them because we parsed them.
231
+
232
+ Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
233
+
234
+ ```ts
235
+ type Provenance = {
236
+ origin: "author" | "user_agent"; // cascade origin (css-cascade-5 §6.2)
237
+ carrier: // editor metadata — where in the file the winning declaration lives
238
+ | "presentation_attribute" // <rect fill="red">
239
+ | "inline_style" // <rect style="fill: red">
240
+ | "stylesheet" // matched a <style> block rule
241
+ | "inherited" // no winning declaration; took parent's computed value
242
+ | "defaulted"; // no winning declaration; took the property's initial value
243
+ };
244
+
245
+ type InvalidComputedValue = {
246
+ error: "invalid_at_computed_value_time";
247
+ reason: string; // e.g. "var(--brand) is not defined and has no fallback"
248
+ };
249
+
250
+ type PropertyValue<T = string | number> = {
251
+ declared: string | null;
252
+ computed: T | InvalidComputedValue | null;
253
+ provenance: Provenance;
254
+ };
255
+ ```
256
+
257
+ #### What v1's cascade engine covers
258
+
259
+ The headless cascade engine resolves values synchronously without a surface, covering:
260
+
261
+ - Presentation attributes (`<rect fill="red">`)
262
+ - Inline `style="..."`
263
+ - Inheritance from parent elements
264
+ - The property's initial value
265
+
266
+ #### What v1's cascade engine does **not** cover
267
+
268
+ - `<style>` block matching
269
+ - `var()` (custom-property) substitution
270
+ - `currentColor` resolution
271
+
272
+ For these three, attach a DOM surface and use `editor.dom_computed_property(id, name)` or `editor.dom_computed_paint(id, channel)` (see [Low-level API](#low-level-api)). Those methods delegate to `getComputedStyle()` against the mounted SVG, which the browser implements end-to-end — `<style>` block matching, `var()`, inheritance, the full pipeline. Treat them as the v1 path for "what does the browser actually paint?".
273
+
274
+ Read — per node:
275
+
276
+ ```ts
277
+ editor.node_properties(
278
+ id: NodeId,
279
+ names: ReadonlyArray<string>,
280
+ ): { readonly [name: string]: PropertyValue };
281
+ ```
282
+
283
+ Property names mirror SVG attribute / CSS property names exactly. No invented schema. Names the editor knows return type-parsed `computed` values (e.g. `opacity` → `number`); unknown names return generic `string`.
284
+
285
+ Write — selection-scoped. Writing the same value to every selected node has no mixed-value ambiguity, so the write API is selection-scoped without engaging any multi-selection layer:
286
+
287
+ ```ts
288
+ editor.commands.set_property(name: string, value: string | null): void;
289
+
290
+ editor.commands.preview_property(name: string): {
291
+ update(value: string): void;
292
+ commit(): void;
293
+ discard(): void;
294
+ };
295
+ ```
296
+
297
+ The editor decides whether to write a presentation attribute vs. inline style for each selected node based on whichever wins the cascade for that element (P1). The preview session is what a number-input scrub or color-picker drag uses: many `update()` calls during drag, one `commit()` on pointer-up.
298
+
299
+ ### Observation — paint (`fill` / `stroke`)
300
+
301
+ `fill` and `stroke` are common enough — and shape-different enough from a plain string — that they get a dedicated typed API. A solid color, a paint-server reference, and `currentColor` are not interchangeable strings; pretending they are is what produces editors that round-trip badly.
302
+
303
+ The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
304
+
305
+ ```
306
+ <paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
307
+ ```
308
+
309
+ ```ts
310
+ type Paint =
311
+ | { kind: "none" } // fill="none"
312
+ | { kind: "color"; value: Color } // fill="#f00" | fill="red" | fill="currentColor"
313
+ | { kind: "ref"; id: string; fallback?: PaintFallback } // fill="url(#g1) red"
314
+ | { kind: "context_fill" } // fill="context-fill" — meaningful in <marker> / <use>
315
+ | { kind: "context_stroke" };
316
+
317
+ type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
318
+
319
+ type Color =
320
+ | { kind: "rgb"; value: string } // any resolvable CSS color, normalized to rgb-ish
321
+ | { kind: "current_color" }; // unresolved keyword; resolve via dom_computed_paint when DOM-attached
322
+
323
+ type PaintValue = {
324
+ declared: string | null; // raw, e.g. "var(--brand, currentColor)" or "url(#g1) red"
325
+ computed: Paint | InvalidComputedValue | null; // post-defaulting; var() reported as invalid in v1
326
+ provenance: Provenance;
327
+ };
328
+ ```
329
+
330
+ Read — per node:
331
+
332
+ ```ts
333
+ editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
334
+ ```
335
+
336
+ Notes per spec:
337
+
338
+ - A reference to a non-existent id with no fallback paints nothing for that layer. Surfacing this as a warning is **deferred for v1**.
339
+ - `context-fill` / `context-stroke` are only meaningful inside `<marker>` content or a `<use>` shadow tree.
340
+ - Same v1 cascade caveat as for `node_properties`: a `fill` declared in a `<style>` block is **not** resolved by `node_paint`. Use `dom_computed_paint(id, "fill")` when DOM-attached.
341
+
342
+ Write — selection-scoped (same reasoning as for generic properties):
343
+
344
+ ```ts
345
+ editor.commands.set_paint(channel: "fill" | "stroke", paint: Paint): void;
346
+
347
+ editor.commands.preview_paint(channel: "fill" | "stroke"): {
348
+ update(paint: Paint): void;
349
+ commit(): void;
350
+ discard(): void;
351
+ };
352
+ ```
353
+
354
+ Assigning a gradient as fill is a two-step operation by design — the gradient lives in `<defs>` (per SVG), the paint references it. The editor does not auto-inline. Sugar for the common "create new gradient and set as fill in one undo step" case is provided below.
355
+
356
+ ### Observation — defs (resources)
357
+
358
+ SVG forces gradients, patterns, symbols, markers, clip-paths, masks, and filters to live as named entries in `<defs>` and be referenced by `url(#id)`. **v1 ships only the `gradients` registry as a typed view.** The other resource kinds (patterns, symbols, markers, clip-paths, masks, filters) are deferred — to read or walk them at v1, use `editor.document` (see [Low-level API](#low-level-api)).
359
+
360
+ ```ts
361
+ editor.defs: {
362
+ gradients: GradientsApi;
363
+ };
364
+
365
+ interface GradientsApi {
366
+ list(): ReadonlyArray<GradientEntry>;
367
+ get(id: string): GradientEntry | null;
368
+ upsert(definition: GradientDefinition, opts?: { id?: string }): string; // returns assigned id
369
+ remove(id: string): void;
370
+ subscribe(fn: (entries: ReadonlyArray<GradientEntry>) => void): Unsubscribe;
371
+ }
372
+
373
+ type GradientDefinition =
374
+ | {
375
+ kind: "linear";
376
+ stops: GradientStop[];
377
+ x1?: number; y1?: number; x2?: number; y2?: number;
378
+ gradient_units?: "user_space_on_use" | "object_bounding_box";
379
+ spread_method?: "pad" | "reflect" | "repeat";
380
+ }
381
+ | {
382
+ kind: "radial";
383
+ stops: GradientStop[];
384
+ cx?: number; cy?: number; r?: number; fx?: number; fy?: number;
385
+ gradient_units?: "user_space_on_use" | "object_bounding_box";
386
+ spread_method?: "pad" | "reflect" | "repeat";
387
+ };
388
+
389
+ type GradientStop = { offset: number; color: string; opacity?: number };
390
+
391
+ type GradientEntry = {
392
+ id: string;
393
+ definition: GradientDefinition;
394
+ ref_count: number; // how many nodes currently reference this gradient
395
+ };
396
+ ```
397
+
398
+ `upsert(definition)` creates a new `<linearGradient>` / `<radialGradient>` (and `<defs>` if absent) and returns its id. If `opts.id` matches an existing entry, the definition is replaced in place. `remove(id)` is rejected if `ref_count > 0` (the editor refuses to leave dangling `url(#id)` references — surface a confirmation in your UI and clear references first).
399
+
400
+ Assigning a freshly-authored gradient as fill, end-to-end:
401
+
402
+ ```ts
403
+ const id = editor.defs.gradients.upsert({
404
+ kind: "linear",
405
+ stops: [
406
+ { offset: 0, color: "#ff6b35" },
407
+ { offset: 1, color: "#7fb8e0" },
408
+ ],
409
+ });
410
+ editor.commands.set_paint("fill", { kind: "ref", id });
411
+ ```
412
+
413
+ For the very common "set fill from picker that just produced a gradient" path, a sugar command exists:
414
+
415
+ ```ts
416
+ editor.commands.set_paint_from_gradient(
417
+ channel: "fill" | "stroke",
418
+ definition: GradientDefinition,
419
+ opts?: { reuse_existing?: boolean }, // dedupe by definition equality
420
+ ): { gradient_id: string };
421
+ ```
422
+
423
+ This is one undo step.
424
+
425
+ ### Observation — tree
426
+
427
+ ```ts
428
+ editor.tree(): {
429
+ readonly root: NodeId;
430
+ readonly nodes: ReadonlyMap<
431
+ NodeId,
432
+ {
433
+ id: NodeId;
434
+ tag: string; // "rect" | "g" | "path" | ...
435
+ name?: string; // from id= or inkscape:label, if present (preserved)
436
+ parent: NodeId | null;
437
+ children: ReadonlyArray<NodeId>;
438
+ }
439
+ >;
440
+ };
441
+ ```
442
+
443
+ Returns a shallow snapshot. Cheap to call after a `structure_version` bump (and stable across pure attribute writes, so layer-list panels keyed on it don't re-render at gesture rate).
444
+
445
+ ```ts
446
+ editor.display_label(
447
+ id: NodeId,
448
+ opts?: { tagLabel?: (tag: string) => string }
449
+ ): string;
450
+ ```
451
+
452
+ Single source of truth for "what to call this node in a panel." For `<text>` nodes the label is the (collapsed, truncated) text content; for everything else it's `"tag #id"` when the `id` attribute is present and just `"tag"` otherwise. The optional `tagLabel` resolver lets you substitute friendlier or localized terms ("rect" → "Rectangle") without losing the structural rule. Consumers should call this rather than synthesize their own.
453
+
454
+ ### Observation — surface hover
455
+
456
+ Out-of-canvas UI (layers panel, breadcrumbs) often wants to mirror the canvas's pointer hover. This is a transient channel — it does **not** bump `state.version`, so listeners don't get full snapshot re-renders.
457
+
458
+ ```ts
459
+ editor.surface_hover(): NodeId | null; // current effective hover (pointer or override)
460
+ editor.set_surface_hover_override(id: NodeId | null): void; // push hover from out-of-canvas UI
461
+ editor.subscribe_surface_hover(cb: () => void): Unsubscribe;
462
+ ```
463
+
464
+ When a layers-panel row is hovered, call `set_surface_hover_override(id)` to push the highlight into the HUD (drives measurement guides, outline, etc.). Pass `null` to release and let the pointer pick take over again. The subscribe channel fires for both pointer-driven and override-driven changes.
465
+
466
+ ### Modes
467
+
468
+ Modes are the editor's internal state machine for "what does a click do." Consumers observe `state.mode`, flip it via commands, but cannot define new modes.
469
+
470
+ ```ts
471
+ editor.modes: ReadonlyArray<Mode>; // frozen after construction
472
+ // v1: ["select", "edit-content"]
473
+
474
+ editor.commands.set_mode(mode: Mode): void;
475
+ ```
476
+
477
+ Adding new modes (insertion tools, etc.) requires a PR to this package; per the anti-goals, this is not a vector authoring tool.
478
+
479
+ ### Commands
480
+
481
+ The full closed set for v1. Adding a command requires a PR.
482
+
483
+ ```ts
484
+ editor.commands.{
485
+ // selection
486
+ select(target: NodeId | ReadonlyArray<NodeId>, opts?: { additive?: boolean }): void;
487
+ deselect(): void;
488
+ enter_scope(group: NodeId): void;
489
+ exit_scope(): void;
490
+
491
+ // mode
492
+ set_mode(mode: Mode): void;
493
+
494
+ // generic property (any SVG/CSS attribute)
495
+ set_property(name: string, value: string | null): void;
496
+ preview_property(name: string): PreviewSession;
497
+
498
+ // paint — typed sugar for fill / stroke
499
+ set_paint(channel: "fill" | "stroke", paint: Paint): void;
500
+ preview_paint(channel: "fill" | "stroke"): PaintPreviewSession;
501
+ set_paint_from_gradient(
502
+ channel: "fill" | "stroke",
503
+ definition: GradientDefinition,
504
+ opts?: { reuse_existing?: boolean },
505
+ ): { gradient_id: string };
506
+
507
+ // transforms
508
+ translate(delta: { dx: number; dy: number }): void;
509
+
510
+ // structure
511
+ reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
512
+ remove(): void;
513
+
514
+ // content
515
+ set_text(value: string): void;
516
+
517
+ // file
518
+ load_svg(svg: string): void;
519
+ serialize_svg(): string;
520
+
521
+ // history
522
+ undo(): void;
523
+ redo(): void;
524
+ }
525
+ ```
526
+
527
+ All commands operate on `state.selection` unless they take an explicit target. Commands that can't apply (e.g. `set_text` with no text node selected) are no-ops, not errors.
528
+
529
+ **`translate` is a single-step displacement command.** Resize gestures are HUD-driven via a preview session (not via a top-level command — there is no `commands.resize` at v1). Rotate, tidy, and multi-selection aggregation are [Deferred for v1](#deferred-for-v1).
530
+
531
+ #### Content edit
532
+
533
+ ```ts
534
+ editor.enter_content_edit(target?: NodeId): boolean;
535
+ ```
536
+
537
+ Surface-dependent. With no DOM surface attached, returns `false` and does nothing — the headless editor cannot mount a `<textarea>` / DOM input. With a DOM surface attached, mounts an in-place text editor on the target `<text>` node.
538
+
539
+ #### Naming convention
540
+
541
+ Public API uses `snake_case` throughout (`set_property`, `node_paint`, `subscribe_with_selector`). User-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them (`set_property("stroke-width", …)`). The exceptions are `FileIOProvider.openSvg` / `saveSvg` and `FontResolver.resolve(...).metrics.unitsPerEm` — these match CSS Font-Metrics / file-API ergonomic naming and stay camelCase.
542
+
543
+ ### Providers
544
+
545
+ Three host-owned seams, all optional.
546
+
547
+ ```ts
548
+ type ClipboardProvider = {
549
+ read(): Promise<string | null>;
550
+ write(text: string): Promise<void>;
551
+ };
552
+
553
+ type FontResolver = {
554
+ resolve(family: string): Promise<{
555
+ available: boolean;
556
+ metrics?: { ascent: number; descent: number; unitsPerEm: number };
557
+ }>;
558
+ };
559
+
560
+ type FileIOProvider = {
561
+ openSvg(): Promise<string | null>; // "open" dialog
562
+ saveSvg(svg: string, suggestedName?: string): Promise<void>;
563
+ };
564
+ ```
565
+
566
+ Once constructed, the provider bag is readable as `editor.providers` for code that wants to share the same clipboard / font / file-io plumbing (a Save toolbar, an Open menu item, etc.).
567
+
568
+ ### Style
569
+
570
+ `style` is the HUD chrome's appearance spec. It is **values, not slots** — consumers cannot replace the chrome, they restyle it. Field names are `snake_case`. The spec is small and additive.
571
+
572
+ ```ts
573
+ type EditorStyle = {
574
+ chrome_color: string; // selection border + handle stroke
575
+ handle_size: number; // pixels
576
+ handle_fill: string;
577
+ handle_stroke: string;
578
+ endpoint_dot_radius: number;
579
+ selection_outline_width: number;
580
+ measurement_color: string; // measurement guide lines + numeric pills
581
+ };
582
+
583
+ editor.style: Readonly<EditorStyle>;
584
+ editor.set_style(partial: Partial<EditorStyle>): void;
585
+ ```
586
+
587
+ `set_style` is the legitimate runtime path — wire it up for theme switching.
588
+
589
+ ### React API (thin wrapper)
590
+
591
+ The React layer is intentionally thin. We ship a provider, a canvas component, and the **minimum set of hooks needed to bridge React's subscription model to the editor's API**. Hooks for specific observation patterns (per-node properties, gradients list, document tree, etc.) are not exported — they're 5-line recipes consumers write against the editor's own API, tailored to their re-render needs.
592
+
593
+ ```tsx
594
+ import {
595
+ SvgEditorProvider,
596
+ SvgEditorCanvas,
597
+ useSvgEditor,
598
+ useEditorState,
599
+ useCommands,
600
+ } from "@grida/svg-editor/react";
601
+ ```
602
+
603
+ That's the whole public surface.
604
+
605
+ - `SvgEditorProvider` — owns the headless editor, puts it in context.
606
+ - `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container: div })` on mount and `detach()` on unmount.
607
+ - `useSvgEditor()` — returns the editor instance from context.
608
+ - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
609
+ - `useCommands()` — sugar for `useSvgEditor().commands`.
610
+
611
+ Top-level wiring:
612
+
613
+ ```tsx
614
+ <SvgEditorProvider
615
+ svg={initial_svg}
616
+ providers={{ clipboard, fonts, file_io }}
617
+ style={{ chrome_color: "#2563eb" }}
618
+ >
619
+ <Layout>
620
+ <Toolbar />
621
+ <SvgEditorCanvas className="flex-1" />
622
+ <PropertyPanel />
623
+ <LayerList />
624
+ </Layout>
625
+ </SvgEditorProvider>
626
+ ```
627
+
628
+ Everything else is consumer-built against the editor's API. The two patterns:
629
+
630
+ **Pattern A — state slice via the built-in hook.**
631
+
632
+ ```tsx
633
+ function Toolbar() {
634
+ const mode = useEditorState((s) => s.mode);
635
+ const cmd = useCommands();
636
+ return (
637
+ <>
638
+ <ToolButton
639
+ active={mode === "select"}
640
+ onClick={() => cmd.set_mode("select")}
641
+ >
642
+
643
+ </ToolButton>
644
+ <ToolButton
645
+ active={mode === "edit-content"}
646
+ onClick={() => cmd.set_mode("edit-content")}
647
+ >
648
+ T
649
+ </ToolButton>
650
+ </>
651
+ );
652
+ }
653
+ ```
654
+
655
+ **Pattern B — anything else (per-node reads, resource lists, tree, paint) via a custom hook over `useSyncExternalStore`.** The recipe is the same shape every time:
656
+
657
+ ```tsx
658
+ import { useSyncExternalStore } from "react";
659
+
660
+ // For per-node property reads, subscribe to the whole editor and re-snapshot.
661
+ // useSyncExternalStore handles reference-equality bailouts.
662
+ function useNodePaint(id: NodeId, channel: "fill" | "stroke") {
663
+ const editor = useSvgEditor();
664
+ return useSyncExternalStore(
665
+ (cb) => editor.subscribe(cb),
666
+ () => editor.node_paint(id, channel)
667
+ );
668
+ }
669
+
670
+ // For defs registries, subscribe to the registry directly — more granular.
671
+ function useGradients() {
672
+ const editor = useSvgEditor();
673
+ return useSyncExternalStore(
674
+ (cb) => editor.defs.gradients.subscribe(cb),
675
+ () => editor.defs.gradients.list()
676
+ );
677
+ }
678
+ ```
679
+
680
+ The property panel composes those custom hooks with the built-in ones:
681
+
682
+ ```tsx
683
+ function PropertyPanel() {
684
+ const selection = useEditorState((s) => s.selection);
685
+ const cmd = useCommands();
686
+
687
+ // v1: single-selection path. Multi-selection aggregation is deferred.
688
+ if (selection.length !== 1)
689
+ return <MultiSelectionPlaceholder count={selection.length} />;
690
+ const id = selection[0];
691
+
692
+ const fill = useNodePaint(id, "fill");
693
+ const stroke = useNodePaint(id, "stroke");
694
+ const gradients = useGradients();
695
+
696
+ return (
697
+ <>
698
+ {/* PaintInput is consumer-built. `provenance` tells the user whether this
699
+ value came from an attribute, inline style, was inherited, or defaulted. */}
700
+ <PaintInput
701
+ label="Fill"
702
+ declared={fill.declared}
703
+ computed={fill.computed}
704
+ provenance={fill.provenance}
705
+ available_gradients={gradients}
706
+ onPreview={(p) => cmd.preview_paint("fill").update(p)}
707
+ onCommit={(p) => cmd.set_paint("fill", p)}
708
+ onCreateGradient={(def) => cmd.set_paint_from_gradient("fill", def)}
709
+ />
710
+ <PaintInput
711
+ label="Stroke"
712
+ declared={stroke.declared}
713
+ computed={stroke.computed}
714
+ provenance={stroke.provenance}
715
+ available_gradients={gradients}
716
+ onCommit={(p) => cmd.set_paint("stroke", p)}
717
+ />
718
+ </>
719
+ );
720
+ }
721
+ ```
722
+
723
+ The pattern is the same for `useDocumentTree`, `useNodeProperties`, etc. — consumers compose against the editor's existing `subscribe()` / `.list()` / `.get()` methods. The package does not ship those hooks because every consumer's re-render needs are slightly different (which IDs to watch, which equality function to use, how to memoize the snapshot), and a one-size-fits-all hook is the wrong layer.
724
+
725
+ What this means in practice:
726
+
727
+ - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.gradients.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
728
+ - A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
729
+ - Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
730
+
731
+ ## Low-level API
732
+
733
+ Semver-stable from v1, deliberately minimal. These are the escape hatches consumers use when the high-level API doesn't yet cover what they need — typically because v1's scope is intentionally narrow.
734
+
735
+ ```ts
736
+ editor.document; // raw IR handle
737
+ editor.dom_computed_property(id, name); // getComputedStyle when DOM-attached
738
+ editor.dom_computed_paint(id, channel);
739
+ editor.commands.register(id, handler);
740
+ editor.commands.invoke(id, args);
741
+ editor.commands.has(id);
742
+ editor.keymap; // host-controlled bindings
743
+ ```
744
+
745
+ ### `editor.document`
746
+
747
+ The raw in-memory IR handle. Use this for things the high-level API doesn't cover — walking `<defs>` for non-gradient resources, reading attributes the property cascade doesn't surface, inspecting comment / CDATA nodes. **Mutating the IR directly bypasses history.** For app code, prefer the high-level `editor.commands.*`; reach for `editor.document` for tooling, instrumentation, and one-off inspections.
748
+
749
+ ### `editor.dom_computed_property` / `editor.dom_computed_paint`
750
+
751
+ ```ts
752
+ editor.dom_computed_property(id: NodeId, name: string): string | null;
753
+ editor.dom_computed_paint(
754
+ id: NodeId,
755
+ channel: "fill" | "stroke"
756
+ ): { computed: string; resolved_paint: Paint | null } | null;
757
+ ```
758
+
759
+ DOM-only computed reads. Returns `null` when no DOM surface is attached. When a DOM surface is attached, delegates to `getComputedStyle()` against the mounted SVG element — which honors `<style>` block matching, `var()` substitution, `currentColor`, and the rest of the CSS cascade the headless engine doesn't implement.
760
+
761
+ These exist because v1 doesn't ship a full headless cascade engine, and we'd rather expose an honest escape hatch than silently lie about `computed` values. As the headless cascade engine grows (post-v1), these methods stay — they remain the only way to ask "what does the browser actually paint?" for cases that depend on user-agent stylesheet, font fallbacks, or rendering-context decisions.
762
+
763
+ ### `editor.commands.register / invoke / has`
764
+
765
+ ```ts
766
+ editor.commands.register(id: CommandId, handler: CommandHandler): () => void;
767
+ editor.commands.invoke(id: CommandId, args?: unknown): boolean;
768
+ editor.commands.has(id: CommandId): boolean;
769
+
770
+ type CommandId = string; // dotted: "history.undo", "selection.remove", "host.copy"
771
+ type CommandHandler = (args?: unknown) => boolean | void;
772
+ ```
773
+
774
+ Id-keyed command surface for host-registered commands. The package's built-in commands are pre-registered under stable ids (`history.undo`, `selection.deselect`, `transform.nudge`, …); hosts can register their own and address them from the keymap. Handlers return `true` if they consumed the invocation, `false` / `undefined` if they didn't (the keymap will then try the next candidate registered for the same key — ProseMirror-style chain semantics).
775
+
776
+ ### `editor.keymap`
777
+
778
+ ```ts
779
+ editor.keymap.bind(binding: KeymapBinding): () => void;
780
+ editor.keymap.unbind(spec: { keybinding?: Keybinding; command?: CommandId }): void;
781
+ editor.keymap.bindings(): readonly KeymapBinding[];
782
+ editor.keymap.dispatch(event: KeyboardEvent): boolean;
783
+ ```
784
+
785
+ Declarative bindings of `Keybinding` → command id with chain-style dispatch. The package's own keybindings are registered at construction (Undo/Redo, arrow-nudge, bracket-reorder, etc.); host bindings layer on top and chain ahead of defaults when their priority is higher. The DOM surface calls `dispatch(event)` automatically; for non-DOM hosts or custom input loops, call it yourself.
786
+
787
+ The `@grida/keybinding` package provides the `Keybinding` type, `kb()` / `seq()` / `platformKb()` builders, and the `KeyCode` enum.
788
+
789
+ ---
790
+
791
+ Anything that earns enough use to be a stable primitive graduates out of this section into the high-level API. Anything we add but later regret stays here under a deprecation marker until it can be removed.
792
+
793
+ ## Deferred for v1
794
+
795
+ What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workaround.
796
+
797
+ - **`<style>` block matching, `var()` substitution, `currentColor` resolution** in the headless cascade. Use `dom_computed_property` / `dom_computed_paint` when DOM-attached (see [Low-level API](#low-level-api)).
798
+ - **Defs registries other than gradients** (patterns, symbols, markers, clip-paths, masks, filters). Walk `editor.document` directly.
799
+ - **`commands.resize` (top-level), `commands.rotate`, `commands.tidy`**. Resize gestures are HUD-driven via a preview session; rotate and tidy are unimplemented.
800
+ - **Multi-selection aggregation** (mixed-value property/paint views).
801
+ - **Insertion modes / shape tools.** Anti-goal — "Not a vector authoring tool."
802
+ - **Copy/paste, alt-drag, snap-to-geometry, snap-to-pixel-grid, alignment hotkeys, duplicate (Cmd+D), select-all.**
803
+ - **Non-DOM surfaces** (worker, React Native, headless test harness).
804
+ - **Paint-server validity warnings** (dangling `url(#id)` references).
805
+ - **Rotation gesture in the HUD.**
806
+
807
+ ## Anti-goals
808
+
809
+ What this editor will never be. Each one is a defensive perimeter for the principles above.
810
+
811
+ - **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
812
+ - **Not an animation editor.** SMIL is preserved verbatim, never authored or mutated.
813
+ - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. The keymap and the command-id registry are host-controlled seams (P2: bindings are a host concern) — see [Low-level API](#low-level-api). (P1, P6.)
814
+ - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
815
+ - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
816
+ - **Not a private IR.** SVG is the source of truth; there is no canonical Grida representation behind it.
817
+ - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
818
+
819
+ If a consumer needs any of the above, the right answer is "this is the wrong tool." Saying yes to any one is the path that turned the Grida main editor into a 6,800-line god-class.
820
+
821
+ ## Status
822
+
823
+ `v1.0.0-alpha.1` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts.
824
+
825
+ Round-trip fidelity (P1) is the design invariant; the v1 surface is deliberately minimal and the [Low-level API](#low-level-api) is the escape hatch when the high-level API doesn't yet cover what you need.
826
+
827
+ The shape may still change between alpha and `1.0.0` based on dogfooding feedback. Don't depend on it from production code yet.
828
+
829
+ ## License
830
+
831
+ MIT