@grida/svg-editor 1.0.0-alpha.2 → 1.0.0-alpha.21

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 CHANGED
@@ -45,6 +45,22 @@ The defaults are inverted from how most editor SDKs grow. Default is **core, not
45
45
 
46
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
47
 
48
+ ### Element IR (internal)
49
+
50
+ Internally, the editor wraps the parsed SVG in a **typed element IR**: a per-node typed view with element-typed capabilities (`is_resizable`, `is_rotatable`, `accepts_paint`, …), typed geometry mutators, and an explicit `RefusalReason` enum for unsupported operations. Commands dispatch on capability, not on element tag. Round-trip invariants the bytes alone cannot enforce — for example, "an editor-authored `rotate(θ cx cy)` recomposes its pivot when the local box changes" — are IR invariants enforced inside the mutator methods.
51
+
52
+ The IR is a **typed view, not alternative storage**. The parsed AST remains the in-memory store; file bytes remain the source of truth; the parse-side source-position trivia store carries whitespace, attribute order, and unknown-namespace content. The IR is rebuilt from the AST on every load and discarded on `dispose`. P1 round-trip stands.
53
+
54
+ This is consistent with the "Not a private IR" anti-goal below — that anti-goal rejects alternative on-disk format and bytes-projected-from-IR storage, neither of which this is. Design: [`docs/wg/feat-svg-editor/element-ir.md`](https://grida.co/docs/wg/feat-svg-editor/element-ir). The phased migration sketch lands with the implementation slice.
55
+
56
+ ### Defined terms
57
+
58
+ The editor's design docs use capitalised terms with precise meanings. The one most often referenced in everyday review:
59
+
60
+ - **[Policy Class](https://grida.co/docs/wg/feat-svg-editor/glossary/policy-class)** — the minimal partition of editable elements such that every editing intent admits the same set of legal solutions within a class. `<circle>` and `<ellipse>` are different Policy Classes because their resize solution spaces fork differently, even though both are conics. The unit at which a host's policy decision (refuse / native / promote / via-transform) maps onto. When a design discussion asks "should X and Y be treated the same?" — apply the Policy Class fork test, not authoring intuition.
61
+
62
+ Full glossary: [`docs/wg/feat-svg-editor/glossary/`](https://github.com/gridaco/grida/tree/main/docs/wg/feat-svg-editor/glossary/).
63
+
48
64
  ## Principles
49
65
 
50
66
  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.
@@ -63,11 +79,11 @@ A small, named set of concerns belongs to the embedding product: clipboard, file
63
79
 
64
80
  ### P4. Subscribe to outcomes, not events.
65
81
 
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.
82
+ The public observation surface is **designed**, not raw. It exposes purpose-built views — `selection`, `properties(names)`, `mode`, `tree`, `dirty`, `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
83
 
68
84
  ### P5. A separate layer earns its separateness by reuse or isolated testability.
69
85
 
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.
86
+ 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/mixed-properties` pass. A hypothetical `@grida/svg-selection-model` (one caller, untestable without an editor) doesn't.
71
87
 
72
88
  ### P6. Public only after dogfooding.
73
89
 
@@ -89,11 +105,13 @@ When a new design decision lands, walk these in order. The first match wins.
89
105
 
90
106
  These are the design principles guiding the implementation.
91
107
 
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.
108
+ - Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core. Plug into any rendering surface.
93
109
  - The IR carries source-position trivia, so the serializer can rewrite only the bytes that actually changed.
94
110
  - 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
111
  - 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.**
112
+ - A separate, explicit **Tidy** command performs structural cleanup deduplicate defs, strip dead resources, normalize generated class and id names, recognize geometric patterns. Never silent, never automatic.
113
+
114
+ The per-element-module and `(element type, gesture, mode)` bullets above describe today's code. The proposed model groups by edit-shape and dispatches on capability — see [Paradigm § Element IR (internal)](#element-ir-internal) and `docs/wg/feat-svg-editor/element-ir.md`. These bullets will be revised when that model lands.
97
115
 
98
116
  ## Examples
99
117
 
@@ -107,13 +125,13 @@ A few scenarios this is designed to handle well.
107
125
  >
108
126
  > 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
127
 
110
- > _"I translated a rect 10px to the right."_
128
+ > _"I rotated a rect by 12 degrees."_
111
129
  >
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`.
130
+ > The editor writes `transform="rotate(12 cx cy)"` on the rect itself. It does not wrap it in a `<g>`, does not collapse to a matrix, and does not touch the rect's `x` / `y` / `width` / `height`.
113
131
 
114
132
  > _"This SVG has a `<style>` block with `.brand { fill: var(--brand) }`. I want to change the fill of one element to red."_
115
133
  >
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)).
134
+ > 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.
117
135
 
118
136
  > _"This file has an Inkscape `<sodipodi:namedview>` block and `inkscape:label` attributes."_
119
137
  >
@@ -131,7 +149,7 @@ npm install @grida/svg-editor
131
149
 
132
150
  ## API
133
151
 
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.
152
+ > **Status of this section.** Names and shapes are a v0 proposal subject to P6 dogfooding before semver stability. The headings (`commands`, `state`, `properties`, `paint`, `defs`, `tree`, `modes`, `subscribe`, providers, style, surface) are committed; their member names are not. The **multi-selection ("mixed values")** section is explicitly deferred — its shape is sketched as a placeholder, not designed. Signatures shown as TypeScript-ish pseudo-code; some types are simplified for readability.
135
153
 
136
154
  ### Construction
137
155
 
@@ -153,13 +171,34 @@ const editor = createSvgEditor({
153
171
  });
154
172
  ```
155
173
 
156
- `createSvgEditor` is the only constructor. The returned `SvgEditor` is the only object consumers ever hold.
174
+ `createSvgEditor` is the only **editor** constructor. The returned `SvgEditor` is the only editor instance consumers ever hold. A small set of Layer-A geometry primitives (see [Geometry primitives](#geometry-primitives) below) is also exported for callers that need canonical SVG geometry without mounting an editor — those are not editor instances and have no lifecycle.
175
+
176
+ 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).
177
+
178
+ ### Geometry primitives
179
+
180
+ A small set of Layer-A primitives is exported for callers that want canonical SVG geometry without mounting an editor. These are not part of the editor lifecycle, do not subscribe, and do not produce diffs against an `SvgDocument` — they are pure value classes over the bytes you hand them.
181
+
182
+ #### `PathModel`
183
+
184
+ Models a single SVG path's vector network for callers that want path geometry without an editor. Construct from a `d` string, observe vertex/segment shape, compute a bbox, serialize back to `d`. No editor, no document, no DOM.
185
+
186
+ ```ts
187
+ import { PathModel } from "@grida/svg-editor";
188
+
189
+ const m = PathModel.fromSvgPathD("M 10 10 L 100 10 L 100 100 Z");
190
+ m.vertexCount(); // 3
191
+ m.segmentCount(); // 3
192
+ m.snapshot(); // { vertices, segments } — POJO
193
+ m.bbox(); // { x, y, width, height }
194
+ m.toSvgPathD(); // canonical d
195
+ ```
157
196
 
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).
197
+ `@experimental` the externally-stable contract for v0 is construction (`fromSvgPathD`) plus `snapshot()` / `bbox()` / `vertexCount()` / `segmentCount()` / `toSvgPathD()`. Mutation methods on the class exist for the editor's internal use and are not part of the documented public surface.
159
198
 
160
199
  ### Surface
161
200
 
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.
201
+ A `Surface` is the host-provided rendering and input boundary. The shipped `domSurface` is the reference implementation used by the React layer; non-DOM hosts (React Native, worker-side renderer, headless test harness) would implement the same interface — though only one implementation exists today (P6: public only after dogfooding).
163
202
 
164
203
  ```ts
165
204
  import { attach_dom_surface } from "@grida/svg-editor/dom";
@@ -169,7 +208,41 @@ const handle = attach_dom_surface(editor, { container });
169
208
  handle.detach();
170
209
  ```
171
210
 
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.
211
+ The v0 contract is pure lifecycle:
212
+
213
+ ```ts
214
+ interface Surface {
215
+ /** Teardown: detach listeners, drop retained refs. Called from
216
+ * `editor.detach()` and `editor.dispose()`. */
217
+ dispose(): void;
218
+ }
219
+ ```
220
+
221
+ What's deliberately **not** part of the contract yet:
222
+
223
+ - **Paint push.** There is no `paint(snapshot)` channel. The surface re-serializes the document by subscribing to the editor and writing to its own rendering target.
224
+ - **Normalized input events.** Input routing is surface-private — the DOM surface attaches pointer/keyboard listeners on its own container and reaches the editor through the in-package `_internal` channel.
225
+ - **Hit-testing.** Picking is surface-private: the DOM surface owns its own pointer → node-id resolver against its rendered scene. World-space geometry queries (`bounds_of`, `node_at_point` for non-pointer callers) route through `editor.geometry` instead. A cross-surface `hit_test` contract is deferred until a second surface needs one — its shape (screen vs. world units, z-order tie-breaks, hit-record vs. id) isn't pinned.
226
+
227
+ Each will become a public seam when a second surface implementation arrives and pins its shape. Until then, exporting `paint(snapshot: unknown)` / `on_input(event: unknown)` / `hit_test(x, y)` would be contracts a foreign implementer cannot honestly satisfy (P6 — public only after dogfooding).
228
+
229
+ Geometry (world-space bboxes, screen ↔ local projection) is exposed via `editor.geometry`, not the `Surface` itself — the DOM surface registers a `MemoizedGeometryProvider` with the editor on attach so headless callers can query bounds without going through the surface.
230
+
231
+ `@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `AttentionScope`, `MemoizedGeometryProvider`, `DomComputedResolver`) that callers writing alternative surfaces or advanced integrations may need. It mounts the SVG into the container, wires pointer / keyboard listeners scoped to that container, and uses native `getBBox` / `getScreenCTM` for geometry. It is the only place in this package that imports DOM types.
232
+
233
+ The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty. Sibling chrome that drives editor commands should be registered into the attention scope (`handle.attention`, below) so the keymap stays live while the user works in it.
234
+
235
+ **Attention gate.** The DOM surface installs document- and window-level keydown listeners (so a user with focus on a side-panel button can still hit editor shortcuts while the pointer is on the canvas). Those listeners are gated by an internal attention predicate: a key is claimed (and `preventDefault()`-ed) only when **focus is inside the attention scope OR the pointer is over it**. Body-focus alone — the natural state when the surface is embedded as a block in a longer document — is not attended, so the editor stays out of the way of page-level shortcuts (Space / arrows to scroll, Cmd+= to zoom, etc.). Passive observation listeners (modifier mirrors, blur resets) are not gated — they don't call `preventDefault()` and need to stay live across focus boundaries.
236
+
237
+ The scope starts as the container alone. Editor-adjacent host chrome — an inspector, a toolbar, a zoom menu; anything that drives `commands.*` — is a DOM sibling of the container (per the exclusive-ownership rule above), so by default the gate cannot tell it from unrelated app surface: clicking its buttons moves focus out of the container, hovering it leaves the container, and the whole keymap (undo / delete / tool keys) goes dark until the user re-attends the canvas. Register such chrome into the scope via the handle:
238
+
239
+ ```ts
240
+ const handle = attach_dom_surface(editor, { container });
241
+ handle.attention.add(inspectorEl); // counts for focus-within + pointer-over
242
+ handle.attention.remove(inspectorEl); // e.g. on unmount
243
+ ```
244
+
245
+ Registered elements get the full keymap — with text inputs inside them still excluded by the keymap's own guard. The native clipboard gate (deliberately stricter: focus-only, never pointer-over) honors the registered set's focus arm the same way. `add` is idempotent; registrations live until removed or the surface detaches.
173
246
 
174
247
  ### Lifecycle
175
248
 
@@ -179,16 +252,39 @@ editor.detach(): void; // detach current surface, keep editor state
179
252
  editor.dispose(): void; // permanent teardown
180
253
  ```
181
254
 
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.
255
+ `load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached.
183
256
 
184
257
  ### External control
185
258
 
186
259
  ```ts
187
260
  editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
188
261
  editor.serialize(): string; // emit clean SVG — guaranteed round-trip per P1
262
+ editor.serialize_node(id: NodeId): string; // emit ONE element's subtree — a fragment, see below
189
263
  editor.reset(): void; // back to last load() input, clears history
190
264
  ```
191
265
 
266
+ `serialize_node(id)` exports the markup of a single element — the bridge from
267
+ "what the user selected" (a `NodeId`) to "the SVG for that element," e.g. to
268
+ hand a downstream consumer (an AI agent) the selected subtree without
269
+ re-serializing the whole document. It reuses `serialize()`'s trivia-preserving
270
+ rules (attribute order, quotes, whitespace, comments — emitted as authored).
271
+
272
+ It is deliberately **weaker** than `serialize()`, and the two must not be
273
+ conflated: `serialize()` emits the whole document and carries the P1
274
+ round-trip guarantee; `serialize_node()` emits a **fragment** and does not.
275
+ Namespace declarations that live on an ancestor (`xmlns:xlink` and friends,
276
+ normally on the root `<svg>`) are **not** inlined into the fragment — a node
277
+ using `xlink:href` serializes without `xmlns:xlink`. The fragment is the
278
+ element's markup as authored, not a standalone parseable document. Throws on
279
+ an unknown id or a non-element node (selections are always elements).
280
+
281
+ > A stable reference to a node that survives a `load()` — and survives an
282
+ > external rewrite of the file — is a separate, unsolved problem (`NodeId`
283
+ > regenerates on each parse). Positional child-index paths address only the
284
+ > deterministic-re-parse case, not structural edits; durable node identity is
285
+ > under design — see
286
+ > [durable node identity](https://grida.co/docs/wg/feat-svg-editor/durable-node-identity).
287
+
192
288
  ### Observation — state
193
289
 
194
290
  ```ts
@@ -196,12 +292,14 @@ editor.state: {
196
292
  readonly selection: ReadonlyArray<NodeId>;
197
293
  readonly scope: NodeId | null; // active isolation (group entered via dblclick)
198
294
  readonly mode: Mode; // "select" | "edit-content"
295
+ readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode
199
296
  readonly dirty: boolean; // unsaved changes since load() / serialize()
200
297
  readonly can_undo: boolean;
201
298
  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
299
+ readonly version: number; // bumps on any emission — drag, history, mutation
300
+ readonly structure_version: number; // bumps only when tree shape or display-label inputs change
301
+ readonly geometry_version: number; // bumps only when something that could shift world bounds changes
302
+ readonly load_version: number; // bumps once per `editor.load()` call (constructor doesn't count)
205
303
  };
206
304
 
207
305
  editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
@@ -212,13 +310,32 @@ editor.subscribe_with_selector<T>(
212
310
  ): Unsubscribe;
213
311
  ```
214
312
 
313
+ `version` fires on every emission and is the right key for "anything could have changed" reads. Use the narrower companions (`structure_version`, `geometry_version`, `load_version`) as cache keys when the data only depends on the corresponding slice — e.g. a hierarchy panel snapshots once per `structure_version` so a drag doesn't invalidate the tree view.
314
+
215
315
  `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
316
 
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.
317
+ ### Observationpick (tap)
318
+
319
+ A **pick** is a discrete tap on the canvas — a press and release within the drag threshold, no drag. It is observe-only and deliberately **separate from selection**: selection answers "what do commands target," a pick answers "what did the user just click, and where." A primary tap on a node both selects it _and_ emits a pick; a tap on empty canvas emits a pick with `node_id: null` (distinguishable from "nothing is selected," which selection alone cannot express); a secondary (right-button) tap emits a pick and does **not** change selection. This is the seam a click-driven host tool needs — a comment / annotation tool anchors UI at `point` and scopes its action to `node_id`, or to the whole document when `null`.
320
+
321
+ ```ts
322
+ type PickEvent = {
323
+ point: Vec2; // document-space — the pointer-DOWN point the tap resolved against
324
+ node_id: NodeId | null; // topmost node under point; null = empty canvas
325
+ button: "primary" | "secondary"; // middle is pan, never taps
326
+ mods: { shift: boolean; alt: boolean; meta: boolean; ctrl: boolean };
327
+ };
328
+
329
+ editor.subscribe_pick(fn: (e: PickEvent) => void): Unsubscribe;
330
+ ```
331
+
332
+ The point is document-space and always the pointer-**down** point — so it stays correct even for a tap on an already-selected node (whose selection commits on pointer-up). The channel does **not** bump `state.version`. In React, wire it with `useEditorPick(handler)`.
333
+
334
+ > **Status:** `@unstable` — shipped against one consumer; the shape is open until a second click-driven tool exercises it (P6).
218
335
 
219
336
  ### Observation — properties
220
337
 
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).
338
+ This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection ("mixed values") is a separate concern; see the [Multi-selection](#multi-selection-mixed-values) section below. The two are kept apart on purpose: property semantics is defined by the spec; mixed semantics is an aggregation layer the editor adds because it supports multi-select.
222
339
 
223
340
  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
341
 
@@ -227,9 +344,9 @@ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: *
227
344
 
228
345
  Plus the editor's own metadata, marked as such:
229
346
 
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.
347
+ - **`provenance`** — _editor metadata, not a CSS spec term_. Reports which document carrier won the cascade for this node (presentation attribute, inline style, stylesheet rule, inherited from parent, defaulted). The cascade itself collapses these into the "author" origin; we surface them because we parsed them.
231
348
 
232
- Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
349
+ Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed. `specified` differs from `declared` only when CSS-wide keywords are present and is rarely useful to a panel UI. `used` and `actual` are surface-bound and out of scope for the headless editor.
233
350
 
234
351
  ```ts
235
352
  type Provenance = {
@@ -247,30 +364,13 @@ type InvalidComputedValue = {
247
364
  reason: string; // e.g. "var(--brand) is not defined and has no fallback"
248
365
  };
249
366
 
250
- type PropertyValue<T = string | number> = {
367
+ type PropertyValue<T = string> = {
251
368
  declared: string | null;
252
369
  computed: T | InvalidComputedValue | null;
253
370
  provenance: Provenance;
254
371
  };
255
372
  ```
256
373
 
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
374
  Read — per node:
275
375
 
276
376
  ```ts
@@ -282,12 +382,15 @@ editor.node_properties(
282
382
 
283
383
  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
384
 
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:
385
+ Resolving `computed` requires a cascade engine. The editor implements only the subset needed for clean editor DX: [presentation attributes](https://www.w3.org/TR/SVG2/styling.html#PresentationAttributes), inline `style=""`, and rules from `<style>` blocks within the document. External stylesheets are out of scope; declarations from them would fall through to `defaulted` / `inherited` with the inspector surfacing that honestly.
386
+
387
+ 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 the multi-selection layer:
286
388
 
287
389
  ```ts
288
390
  editor.commands.set_property(name: string, value: string | null): void;
289
391
 
290
392
  editor.commands.preview_property(name: string): {
393
+ readonly live: boolean; // false once the session has ended, for any reason
291
394
  update(value: string): void;
292
395
  commit(): void;
293
396
  discard(): void;
@@ -296,16 +399,22 @@ editor.commands.preview_property(name: string): {
296
399
 
297
400
  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
401
 
402
+ A preview session ends as soon as its result can no longer become the document's next state — and after it ends, every method is a no-op and `session.live` is `false`. A discrete write to the same property (`set_property(name, …)`, or `set_paint` / `set_paint_from_gradient` on the same channel) **supersedes** an open session on that name, so a UI that mixes a picker drag with preset buttons cannot replay the stale dragged value when it commits on close; sessions on other property names are unaffected by discrete writes. Operations that detach the session's targets (`remove` / `cut`, `ungroup`), a document swap (`load` / `reset`), and `undo` / `redo` end open sessions on every name. Hosts that cache a session should consult `live` and lazily reopen — the bundled React hooks do.
403
+
299
404
  ### Observation — paint (`fill` / `stroke`)
300
405
 
301
406
  `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
407
 
408
+ This section, like Properties above, is **per-node and spec-aligned**. Multi-selection aggregation is in the [Multi-selection](#multi-selection-mixed-values) section.
409
+
303
410
  The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
304
411
 
305
412
  ```
306
413
  <paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
307
414
  ```
308
415
 
416
+ `<color>` includes `currentColor` per [CSS Color 4 §4](https://www.w3.org/TR/css-color-4/#typedef-color). `inherit` and `var()` are _not_ paint values — they are defaulting / substitution mechanisms that are resolved before the computed value exists (see the property stages above). They appear in `declared` strings but never in a parsed `Paint`.
417
+
309
418
  ```ts
310
419
  type Paint =
311
420
  | { kind: "none" } // fill="none"
@@ -316,13 +425,17 @@ type Paint =
316
425
 
317
426
  type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
318
427
 
428
+ // Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
429
+ // rgb resolution happens at *used* value, which requires the surface's painting context.
319
430
  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
431
+ | { kind: "rgb"; value: string } // canonical lowercase hex (#rrggbb / #rrggbbaa) for any
432
+ // literal resolvable without a rendering context (named / hex / rgb() / hsl() / hwb());
433
+ // unresolved spaces (lab() / oklch() / color()) pass through as authored
434
+ | { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
322
435
 
323
436
  type PaintValue = {
324
437
  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
438
+ computed: Paint | InvalidComputedValue | null; // post-defaulting, post-var
326
439
  provenance: Provenance;
327
440
  };
328
441
  ```
@@ -333,11 +446,11 @@ Read — per node:
333
446
  editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
334
447
  ```
335
448
 
336
- Notes per spec:
449
+ Notes on the `<url>` reference, per spec:
337
450
 
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.
451
+ - The fallback (`<url> <color>` or `<url> none`) kicks in only when the URL resolves to a missing or invalid paint server. A valid-but-empty gradient is still valid; the fallback does not apply ([SVG 2 §13.2.1](https://www.w3.org/TR/SVG2/painting.html#FillStrokePaintServer)).
452
+ - A reference to a non-existent id with no fallback paints nothing for that layer (silently skipped, not an error). The editor surfaces this via a warning in the `defs` registry's `subscribe()`.
453
+ - `context-fill` / `context-stroke` are only meaningful inside `<marker>` content or a `<use>` shadow tree ([SVG 2 §13.2.2](https://www.w3.org/TR/SVG2/painting.html#TermContextElement)). Outside those contexts, the editor treats them as no-paint and surfaces a warning.
341
454
 
342
455
  Write — selection-scoped (same reasoning as for generic properties):
343
456
 
@@ -345,21 +458,50 @@ Write — selection-scoped (same reasoning as for generic properties):
345
458
  editor.commands.set_paint(channel: "fill" | "stroke", paint: Paint): void;
346
459
 
347
460
  editor.commands.preview_paint(channel: "fill" | "stroke"): {
461
+ readonly live: boolean; // false once the session has ended, for any reason
348
462
  update(paint: Paint): void;
349
463
  commit(): void;
350
464
  discard(): void;
351
465
  };
352
466
  ```
353
467
 
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.
468
+ 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 by the resource API below.
469
+
470
+ ### Multi-selection (mixed values)
471
+
472
+ When more than one node is selected, **reading** a property no longer has a single answer — values may agree across the selection, or they may differ ("mixed"). This is its own concept, layered on top of the per-node property and paint APIs above. It is the typical reading mode for a property panel.
473
+
474
+ This layer is **not deeply designed yet**. The shape will likely look something like:
475
+
476
+ ```ts
477
+ // Provisional — names, contract, and ergonomics subject to design before v0.
478
+ type MixedView<V> =
479
+ | { status: "single"; value: V } // every selected node agrees
480
+ | { status: "mixed"; per_node: ReadonlyMap<NodeId, V> } // values differ
481
+ | { status: "unsupported" } // no selected node has this property
482
+ | { status: "empty" }; // no selection
483
+
484
+ editor.selection_properties(names): { readonly [name: string]: MixedView<PropertyValue> };
485
+ editor.selection_paint(channel): MixedView<PaintValue>;
486
+ ```
487
+
488
+ `@grida/mixed-properties` already exists in the monorepo and is the likely starting point, but whether it covers SVG paint and the spec-aligned `PropertyValue` shape cleanly is an open question. Writes do not engage this layer — `set_property` and `set_paint` apply the same value to every selected node and are well-defined as-is.
489
+
490
+ For v0, the per-node APIs (`node_properties`, `node_paint`) are the stable primitives. Consumers who need to render a property panel today can iterate over `state.selection` and aggregate themselves; the goal of the mixed layer is to give them an ergonomic alternative once its shape is settled.
355
491
 
356
492
  ### Observation — defs (resources)
357
493
 
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)).
494
+ SVG forces gradients, patterns, symbols, markers, clip-paths, masks, and filters to live as named entries in `<defs>` and be referenced by `url(#id)`. The editor exposes a typed registry per resource kind. Consumers reading `editor.paint("fill").computed` may encounter a `{ kind: "ref", id }`; they look up the actual gradient via `editor.defs.gradients.get(id)`.
359
495
 
360
496
  ```ts
361
497
  editor.defs: {
362
498
  gradients: GradientsApi;
499
+ patterns: PatternsApi;
500
+ symbols: SymbolsApi;
501
+ markers: MarkersApi;
502
+ clip_paths: ClipPathsApi;
503
+ masks: MasksApi;
504
+ filters: FiltersApi;
363
505
  };
364
506
 
365
507
  interface GradientsApi {
@@ -422,6 +564,10 @@ editor.commands.set_paint_from_gradient(
422
564
 
423
565
  This is one undo step.
424
566
 
567
+ The same shape (`list / get / upsert / remove / subscribe`) repeats for `patterns`, `symbols`, `markers`, `clip_paths`, `masks`, `filters`. Each carries its own `*Definition` type that mirrors the SVG spec. None of them are renamed or renormalized — `<linearGradient>` stays `<linearGradient>`, `<marker>` stays `<marker>`.
568
+
569
+ Markers are referenced via `marker-start` / `marker-mid` / `marker-end` (and the shorthand `marker`), not via `fill`/`stroke`. They appear in the property API the same way any presentation attribute does — read `editor.node_properties(id, ["marker-end"])`, dereference any `{ kind: "ref", id }` via `editor.defs.markers.get(id)`.
570
+
425
571
  ### Observation — tree
426
572
 
427
573
  ```ts
@@ -440,45 +586,29 @@ editor.tree(): {
440
586
  };
441
587
  ```
442
588
 
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).
589
+ Returns a shallow snapshot. Cheap to call after a `version` bump.
444
590
 
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.
591
+ ### Modes and tools
465
592
 
466
- ### Modes
593
+ "What does a click do" is governed by **two orthogonal axes**, both editor-internal — consumers observe them and flip them via commands, but cannot define new values for either.
467
594
 
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.
595
+ - **`Mode`** — what the editor is _doing_. Two values: `select` (normal interaction pick / marquee / drag) and `edit-content` (inline text edit, or vector content edit on a path).
596
+ - **`Tool`** — what pointer-down _means_ within the current mode. `cursor` (the default — select / marquee / drag), `insert` (a tag — pointer-down draws a new element of that tag, drag-to-size), `insert-text` (click-only — places a single-line `<text>` and enters content-edit immediately; `<text>` has no intrinsic size so it doesn't drag-to-size), and the content-edit-only `lasso` / `bend` (valid only while `mode === "edit-content"` on a path).
469
597
 
470
598
  ```ts
471
- editor.modes: ReadonlyArray<Mode>; // frozen after construction
472
- // v1: ["select", "edit-content"]
599
+ editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction — ["select", "edit-content"]
600
+ editor.state.mode: Mode;
601
+ editor.state.tool: Tool;
473
602
 
474
603
  editor.commands.set_mode(mode: Mode): void;
604
+ editor.set_tool(tool: Tool): void; // also dispatchable as the `tool.set` command (keymap V/R/O/L/T)
475
605
  ```
476
606
 
477
- Adding new modes (insertion tools, etc.) requires a PR to this package; per the anti-goals, this is not a vector authoring tool.
607
+ When a tool-driven gesture completes (a shape is drawn, a text element placed), the tool reverts to `cursor` automatically. Modifier keys can override this (e.g. hold to stay in the insert tool); that behavior is bundled, not customizable.
478
608
 
479
609
  ### Commands
480
610
 
481
- The full closed set for v1. Adding a command requires a PR.
611
+ The full closed set. Adding a command requires a PR to this package.
482
612
 
483
613
  ```ts
484
614
  editor.commands.{
@@ -488,8 +618,10 @@ editor.commands.{
488
618
  enter_scope(group: NodeId): void;
489
619
  exit_scope(): void;
490
620
 
491
- // mode
621
+ // mode + tool
492
622
  set_mode(mode: Mode): void;
623
+ // `set_tool` is also accessible as `editor.set_tool(...)`; the command form
624
+ // is provided so keymap bindings (V/R/O/L) can dispatch via the registry.
493
625
 
494
626
  // generic property (any SVG/CSS attribute)
495
627
  set_property(name: string, value: string | null): void;
@@ -504,20 +636,87 @@ editor.commands.{
504
636
  opts?: { reuse_existing?: boolean },
505
637
  ): { gradient_id: string };
506
638
 
507
- // transforms
639
+ // transforms (atomic — the bundled HUD drives drag-resize-rotate internally)
508
640
  translate(delta: { dx: number; dy: number }): void;
641
+ nudge(direction: "left" | "right" | "up" | "down", step?: number): void;
642
+ resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void;
643
+ resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
644
+ rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
645
+ rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
646
+ // `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
647
+ transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
648
+ flatten_transform(): void; // bake `transform=` into native attrs where possible
649
+
650
+ // alignment (operates on selection of ≥2 nodes against their union bbox)
651
+ align(direction: AlignDirection): void;
509
652
 
510
653
  // structure
511
654
  reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
655
+ group(): void; // wrap selection in a new <g>
656
+ ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
657
+ // (clean-structural subset only; refuses
658
+ // groups with visual state — see TODO §10)
512
659
  remove(): void;
513
660
 
661
+ // clipboard — the payload is a STANDALONE SVG DOCUMENT, not a private
662
+ // format (the file is the IR, so the clipboard is the file format).
663
+ // Copy carries the outbound url(#…)/href reference closure in one
664
+ // <defs> block and declares borrowed xmlns prefixes on the payload
665
+ // shell; ancestor transforms / inherited presentation / viewport are
666
+ // deliberately NOT carried (verbatim policy). Cut = copy + remove as
667
+ // ONE history step labeled "cut"; undo restores the document and the
668
+ // clipboard keeps the payload (cut → undo → paste = move). Paste is
669
+ // synchronous over delivered text (`text ?? internal buffer`) and has
670
+ // a gesture-grade refusal table: non-parseable environment input is a
671
+ // no-op `[]`, never a throw (insert_fragment keeps strict semantics).
672
+ // System-clipboard wiring is the DOM surface's native ClipboardEvent
673
+ // transport (text/plain = the markup itself) plus the optional
674
+ // ClipboardProvider seam. Full contract:
675
+ // https://grida.co/docs/wg/feat-svg-editor/clipboard
676
+ copy(): string | null; // payload | null on empty selection; no history
677
+ cut(): string | null; // one undoable step; buffer secured before delete
678
+ paste(text?: string): NodeId[]; // inserted roots (selected); [] = refusal
679
+
680
+ // duplicate — the clipboard FRD's SECOND extraction operation
681
+ // (subtree clone): in-document, so NO defs closure and NO xmlns
682
+ // shell are carried; subtrees and authored ids clone verbatim
683
+ // (colliding ids resolve first-in-document-order; Tidy dedups).
684
+ // Each clone lands as its origin's next sibling (paints above it);
685
+ // selection moves to the clones; ONE history step. Alt-drag
686
+ // translate-with-clone consumes the same operation. Repeating
687
+ // offset: duplicate → move the copy → duplicate repeats the
688
+ // translate delta (an Alt-drag clone commit arms the same memory);
689
+ // still one undo step, degrades to in-place when the preconditions
690
+ // don't hold. Contract:
691
+ // https://grida.co/docs/wg/feat-svg-editor/subtree-clone
692
+ duplicate(): NodeId[]; // clone ids (selected); [] = refusal
693
+
694
+ // insertion — `tag` is an open string (so paste / RPC can create any element,
695
+ // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
696
+ // draw gesture and default paint.
697
+ insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
698
+ // markup-shaped sibling of `insert` — one or more sibling elements, or a
699
+ // full `<svg>` doc (the shell is discarded; its children are the content).
700
+ // Subtrees adopted verbatim; ONE history step; returns root ids in
701
+ // document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
702
+ // undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
703
+ // in the same step. Position is authored content: wrap the fragment in
704
+ // `<g transform="translate(x y)">` to land it at a point — same single
705
+ // undo step, no placement opt.
706
+ insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
707
+ insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
708
+
514
709
  // content
515
710
  set_text(value: string): void;
711
+ enter_content_edit(target?: NodeId): boolean;
516
712
 
517
713
  // file
518
714
  load_svg(svg: string): void;
519
715
  serialize_svg(): string;
520
716
 
717
+ // cleanup — never silent, never automatic
718
+ tidy(opts?: TidyOptions): void;
719
+
521
720
  // history
522
721
  undo(): void;
523
722
  redo(): void;
@@ -526,19 +725,9 @@ editor.commands.{
526
725
 
527
726
  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
727
 
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
728
+ `transform` composes a general 2×3 affine onto the selection **relative** and **in world space about a pivot** (default: the selection union-bbox center) — `E = T(pivot) · matrix · T(-pivot)` — so a bare `[-1, 0, 0, 1, 0, 0]` is an in-place horizontal flip and `[1, 0, 0, -1, 0, 0]` a vertical one. The editor owns the round-trip: `E` is folded onto each member's transform list as a **single leading `matrix` op** (existing `rotate`/`translate` tokens are preserved after it; repeated applies collapse into one matrix; a net-identity leading matrix is dropped). It refuses (returns `false`, no history) on empty selection, no attached surface, or any member that isn't rotatable (matrix / scale / skew / `<text rotate>` / CSS-property / animated transforms — same gate as `rotate`; Flatten Transform is the recovery path). Flat-doc only: nested transformed ancestors are out of scope.
540
729
 
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.
730
+ (Naming convention for the API surface is `snake_case` to match the SVG / CSS property naming the editor already echoes `set_property("stroke-width", …)` reads cleanly next to `set_paint("fill", …)`. JavaScript identifiers use `snake_case`; user-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them.)
542
731
 
543
732
  ### Providers
544
733
 
@@ -563,8 +752,6 @@ type FileIOProvider = {
563
752
  };
564
753
  ```
565
754
 
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
755
  ### Style
569
756
 
570
757
  `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.
@@ -577,18 +764,18 @@ type EditorStyle = {
577
764
  handle_stroke: string;
578
765
  endpoint_dot_radius: number;
579
766
  selection_outline_width: number;
580
- measurement_color: string; // measurement guide lines + numeric pills
767
+ // ...
581
768
  };
582
769
 
583
770
  editor.style: Readonly<EditorStyle>;
584
771
  editor.set_style(partial: Partial<EditorStyle>): void;
585
772
  ```
586
773
 
587
- `set_style` is the legitimate runtime path — wire it up for theme switching.
588
-
589
774
  ### React API (thin wrapper)
590
775
 
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.
776
+ The React layer is intentionally thin. We ship a provider, a canvas component, two core subscription primitives (`useEditorState` + `useCommands`), and a small set of bundled hooks for the patterns that turned out the same across every consumer. Hooks for **per-node** observation patterns (paint, properties, gradients list, document tree) are not exported — those are 5-line recipes consumers write against the editor's own API, tailored to their re-render needs.
777
+
778
+ #### Core (the primitives)
592
779
 
593
780
  ```tsx
594
781
  import {
@@ -600,14 +787,43 @@ import {
600
787
  } from "@grida/svg-editor/react";
601
788
  ```
602
789
 
603
- That's the whole public surface.
604
-
605
790
  - `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.
791
+ - `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container })` on mount and `handle.detach()` on unmount. Receives the `DomSurfaceHandle` via an `onAttach` callback so consumers can thread `handle.camera` / `handle.gestures` into surrounding chrome.
607
792
  - `useSvgEditor()` — returns the editor instance from context.
608
793
  - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
609
794
  - `useCommands()` — sugar for `useSvgEditor().commands`.
610
795
 
796
+ #### Bundled hooks (state-slice convenience + lifecycle-aware sessions)
797
+
798
+ These are not internals to be replaced — they're documented sugar over `useEditorState` and the imperative APIs, with stable contracts. They exist because every consumer wrote the same recipe; per P6, they earned promotion.
799
+
800
+ ```tsx
801
+ import {
802
+ // state slices (one-line wrappers over useEditorState)
803
+ useSelection, // → readonly NodeId[]
804
+ useTool, // → Tool
805
+ useMode, // → Mode
806
+ useCanUndo, // → boolean
807
+ useCanRedo, // → boolean
808
+
809
+ // lifecycle-aware preview sessions — unmount = discard (never commit)
810
+ usePaintPreview, // (channel) → PaintPreviewSession
811
+ usePropertyPreview, // (name) → PreviewSession
812
+
813
+ // bound imperative actions, stable identity across renders
814
+ useEditorLoad, // → (svg: string) => void
815
+ useEditorSerialize, // → () => string
816
+
817
+ // RAII hover override (clears on unmount if this hook set the override)
818
+ useHoverOverride, // → (id: NodeId | null) => void
819
+
820
+ // camera bridge (subscribe to a slice of handle.camera without bumping state.version)
821
+ useCameraSnapshot, // (handle, selector, fallback) → T
822
+ } from "@grida/svg-editor/react";
823
+ ```
824
+
825
+ The preview hooks (`usePaintPreview` / `usePropertyPreview`) wrap `commands.preview_*` with a React-lifecycle-aware shell whose contract is: **unmount discards, the host commits**. The session returned is reference-stable across renders within one key — `picker open → commit → reopen` works without remounting.
826
+
611
827
  Top-level wiring:
612
828
 
613
829
  ```tsx
@@ -631,19 +847,27 @@ Everything else is consumer-built against the editor's API. The two patterns:
631
847
 
632
848
  ```tsx
633
849
  function Toolbar() {
634
- const mode = useEditorState((s) => s.mode);
635
- const cmd = useCommands();
850
+ // Insertion is the `Tool` axis, not `Mode` — `Mode` is only
851
+ // "select" / "edit-content". Flip tools via `editor.set_tool(...)`.
852
+ const tool = useEditorState((s) => s.tool);
853
+ const editor = useSvgEditor();
636
854
  return (
637
855
  <>
638
856
  <ToolButton
639
- active={mode === "select"}
640
- onClick={() => cmd.set_mode("select")}
857
+ active={tool.type === "cursor"}
858
+ onClick={() => editor.set_tool({ type: "cursor" })}
641
859
  >
642
860
 
643
861
  </ToolButton>
644
862
  <ToolButton
645
- active={mode === "edit-content"}
646
- onClick={() => cmd.set_mode("edit-content")}
863
+ active={tool.type === "insert" && tool.tag === "rect"}
864
+ onClick={() => editor.set_tool({ type: "insert", tag: "rect" })}
865
+ >
866
+
867
+ </ToolButton>
868
+ <ToolButton
869
+ active={tool.type === "insert-text"}
870
+ onClick={() => editor.set_tool({ type: "insert-text" })}
647
871
  >
648
872
  T
649
873
  </ToolButton>
@@ -684,7 +908,7 @@ function PropertyPanel() {
684
908
  const selection = useEditorState((s) => s.selection);
685
909
  const cmd = useCommands();
686
910
 
687
- // v1: single-selection path. Multi-selection aggregation is deferred.
911
+ // v0: single-selection path. Multi-selection arrives with the mixed-values layer.
688
912
  if (selection.length !== 1)
689
913
  return <MultiSelectionPlaceholder count={selection.length} />;
690
914
  const id = selection[0];
@@ -696,7 +920,8 @@ function PropertyPanel() {
696
920
  return (
697
921
  <>
698
922
  {/* PaintInput is consumer-built. `provenance` tells the user whether this
699
- value came from an attribute, inline style, was inherited, or defaulted. */}
923
+ value came from an attribute, inline style, a stylesheet rule, or was
924
+ inherited / defaulted. */}
700
925
  <PaintInput
701
926
  label="Fill"
702
927
  declared={fill.declared}
@@ -720,111 +945,40 @@ function PropertyPanel() {
720
945
  }
721
946
  ```
722
947
 
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.
948
+ The pattern is the same for `useDocumentTree`, `useNodeProperties`, `useMarkers`, `useSymbols`, 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
949
 
725
950
  What this means in practice:
726
951
 
727
- - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.gradients.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
952
+ - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.*.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
728
953
  - A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
729
954
  - Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
730
955
 
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
956
  ## Anti-goals
808
957
 
809
958
  What this editor will never be. Each one is a defensive perimeter for the principles above.
810
959
 
811
960
  - **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
812
961
  - **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.)
962
+ - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
814
963
  - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
815
964
  - **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.
965
+ - **Not a customizable selection policy.** What a pick or a marquee selects (the marquee shadow rule, meta routing, additive, and the group-first targeting that lifts a click to its container) is a fixed product decision, owned by the labeled policy layer (`src/selection/`, specs [`docs/marquee-selection.md`](./docs/marquee-selection.md) and [`docs/group-first-targeting.md`](./docs/group-first-targeting.md)). No host hook, provider, or registry swaps it; the opinion lives above the engine, never inside it, and never as a public knob.
966
+ - **Not a private IR.** SVG is the source of truth. The editor does not maintain an alternative on-disk format, and the bytes are not projected from any in-memory canonical store. (The internal typed element IR described under [Paradigm § Element IR (internal)](#element-ir-internal) is a typed view over the parsed AST, not a store the file is derived from — the AST and the file are the source of truth, and the IR is rebuilt from them on each load.)
817
967
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
968
+ - **Not an input-interception hook.** The pick/tap observation (`subscribe_pick`) reports a click that already happened; it cannot prevent, delay, or replace the editor's own selection and gesture handling. A host that needs to intercept input owns the container and splices its own layer in (the DOM escape hatch) — it does not get a veto through the observation surface.
818
969
 
819
970
  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
971
 
821
972
  ## Status
822
973
 
823
- `v1.0.0-alpha.1` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts.
974
+ - `v0.x` — selection, transform, insert (rect / ellipse / line), inline text
975
+ edit, and the click-to-place text tool. Experimental.
976
+
977
+ The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable — public types still in flux include the `Tool` union (a planned axis split, see `TODO.md` F2). Do not depend on it from production code.
824
978
 
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.
979
+ ## Contributing
826
980
 
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.
981
+ - [`TODO.md`](./TODO.md) open questions and deferred work, grouped by area.
828
982
 
829
983
  ## License
830
984