@grida/svg-editor 1.0.0-alpha.4 → 1.0.0

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.
Files changed (41) hide show
  1. package/README.md +365 -384
  2. package/dist/dom-BEjG2bSw.d.mts +320 -0
  3. package/dist/dom-DYD1BHTo.js +6050 -0
  4. package/dist/dom-Y2QR7QSi.d.ts +318 -0
  5. package/dist/dom-tM3Dr1EK.mjs +5971 -0
  6. package/dist/dom.d.mts +3 -2
  7. package/dist/dom.d.ts +3 -2
  8. package/dist/dom.js +13 -1
  9. package/dist/dom.mjs +2 -2
  10. package/dist/editor-CWNtt1vu.mjs +2998 -0
  11. package/dist/editor-CZgwg8BK.d.mts +2537 -0
  12. package/dist/editor-CesShk9o.js +3004 -0
  13. package/dist/editor-kFTYSb_8.d.ts +2537 -0
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +5 -2
  17. package/dist/index.mjs +3 -2
  18. package/dist/model-D0iEDcg3.mjs +5451 -0
  19. package/dist/model-tywvTGZv.js +5640 -0
  20. package/dist/presets.d.mts +17 -2
  21. package/dist/presets.d.ts +17 -2
  22. package/dist/presets.js +20 -15
  23. package/dist/presets.mjs +19 -15
  24. package/dist/react.d.mts +88 -10
  25. package/dist/react.d.ts +88 -10
  26. package/dist/react.js +169 -14
  27. package/dist/react.mjs +159 -16
  28. package/package.json +35 -9
  29. package/dist/dom-CmOu0HvI.mjs +0 -1623
  30. package/dist/dom-Cn-RtjRL.d.ts +0 -48
  31. package/dist/dom-CoVZzFqy.js +0 -1672
  32. package/dist/dom-DJnZhtOd.d.mts +0 -48
  33. package/dist/editor-CjK56cgb.mjs +0 -1823
  34. package/dist/editor-D2l_CDr0.d.ts +0 -818
  35. package/dist/editor-D2zZAyny.js +0 -1835
  36. package/dist/editor-Uu6dZX4y.d.mts +0 -818
  37. package/dist/index-CHiXYO9-.d.ts +0 -1
  38. package/dist/index-ThDLM4Am.d.mts +0 -1
  39. package/dist/paint-Cfiw4g_J.mjs +0 -477
  40. package/dist/paint-dDV-Trt9.js +0 -531
  41. /package/dist/{chunk-CfYAbeIz.mjs → chunk-D7D4PA-g.mjs} +0 -0
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
  >
@@ -123,108 +141,6 @@ A few scenarios this is designed to handle well.
123
141
  >
124
142
  > 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
143
 
126
- ### Free-form canvas (Figma-style infinite canvas)
127
-
128
- Identity camera, default gestures, no auto-fit. Pan with the wheel, Cmd+wheel to zoom at cursor, space-drag for hand-tool, `Shift+1` to fit, `Shift+0` to reset.
129
-
130
- ```tsx
131
- import {
132
- SvgEditorCanvas,
133
- SvgEditorProvider,
134
- useCameraSnapshot,
135
- } from "@grida/svg-editor/react";
136
- import type { DomSurfaceHandle } from "@grida/svg-editor/dom";
137
- import { useState } from "react";
138
-
139
- export function FreeFormCanvas({ svg }: { svg: string }) {
140
- const [handle, setHandle] = useState<DomSurfaceHandle | null>(null);
141
- const zoom = useCameraSnapshot(handle, (c) => c.zoom, 1);
142
- return (
143
- <SvgEditorProvider svg={svg}>
144
- <div style={{ position: "relative", width: "100%", height: "100vh" }}>
145
- <SvgEditorCanvas
146
- onAttach={setHandle}
147
- style={{ width: "100%", height: "100%" }}
148
- />
149
- <div style={{ position: "absolute", bottom: 12, right: 12 }}>
150
- <button onClick={() => handle?.camera.fit("<root>")}>Fit</button>
151
- <button onClick={() => handle?.camera.reset()}>100%</button>
152
- <span>{Math.round(zoom * 100)}%</span>
153
- </div>
154
- </div>
155
- </SvgEditorProvider>
156
- );
157
- }
158
- ```
159
-
160
- ### Keynote-like canvas (fixed-viewBox slide with bounded camera)
161
-
162
- The keynote case wants more than "fit on load" — it wants **"the slide is the world"**: no zooming out past fit, no panning when the slide already fills the viewport, and clamped panning when zoomed in so the slide always covers the viewport edge-to-edge.
163
-
164
- This bundle ships as a one-import preset at the **`@grida/svg-editor/presets`** subpath. The preset composes the public primitives:
165
-
166
- 1. `attach_dom_surface` with `fit: true` — slide visible on first frame.
167
- 2. `camera.constraints = { type: 'cover', bounds: '<root>', padding }` — typed [camera constraint](#camera-constraints) that clamps zoom + pan.
168
- 3. `editor.subscribe_with_selector(s => s.load_version, () => camera.fit('<root>'))` — refit only on fresh document loads, not on edits.
169
-
170
- The preset is opt-in by import: the main `@grida/svg-editor` entry never touches `presets/`, so hosts that don't want the bundle aren't paying for it.
171
-
172
- ```tsx
173
- import { useEffect, useRef, useState } from "react";
174
- import { SvgEditorProvider, useSvgEditor } from "@grida/svg-editor/react";
175
- import { keynote, type KeynoteSurfaceHandle } from "@grida/svg-editor/presets";
176
-
177
- export function KeynoteCanvas({ svg }: { svg: string }) {
178
- return (
179
- <SvgEditorProvider svg={svg}>
180
- <KeynoteHost />
181
- </SvgEditorProvider>
182
- );
183
- }
184
-
185
- function KeynoteHost() {
186
- const editor = useSvgEditor();
187
- const container_ref = useRef<HTMLDivElement | null>(null);
188
- const [handle, setHandle] = useState<KeynoteSurfaceHandle | null>(null);
189
-
190
- // Mount the preset on first render; dispose on unmount. The preset
191
- // owns fit-on-attach, the cover constraint, and the load-version refit
192
- // subscription — the host writes ~10 lines of React lifecycle, no math.
193
- useEffect(() => {
194
- const container = container_ref.current;
195
- if (!container) return;
196
- const h = keynote.attach(editor, { container, padding: 80 });
197
- setHandle(h);
198
- return () => {
199
- setHandle(null);
200
- h.detach();
201
- };
202
- }, [editor]);
203
-
204
- return (
205
- <div style={{ padding: 24, background: "#1f2937", height: "100vh" }}>
206
- <div
207
- ref={container_ref}
208
- style={{
209
- width: "100%",
210
- height: "100%",
211
- background: "#fff",
212
- boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
213
- }}
214
- />
215
- {/* Use `handle` for chrome — `useCameraSnapshot(handle, c => c.zoom, 1)`
216
- for a zoom badge, `handle.set_padding(0)` for a Present-mode toggle, etc. */}
217
- </div>
218
- );
219
- }
220
- ```
221
-
222
- The handle is `KeynoteSurfaceHandle = DomSurfaceHandle & { set_padding(p: number): void }`. `set_padding` is preset-specific sugar for "I want a present-mode toggle that varies padding at runtime"; it mutates the live constraint and refits in one call.
223
-
224
- Both examples ship as live demos at `/canvas/svg` (free-form, no preset) and `/canvas/svg/keynote` (preset) in the dogfood app. Their diff is the spec for what the SDK owes its users: same editor, same camera, same gestures — the keynote demo opts into the preset; the free-form demo doesn't.
225
-
226
- > **Going deeper.** If you need to compose the building blocks differently — different constraint shape, conditional refit, host-managed bounds — read [`src/presets/keynote.ts`](https://github.com/gridaco/grida/blob/main/packages/grida-svg-editor/src/presets/keynote.ts) (≤ 70 lines of pure composition over public API) as the canonical reference.
227
-
228
144
  ## Install
229
145
 
230
146
  ```sh
@@ -233,7 +149,7 @@ npm install @grida/svg-editor
233
149
 
234
150
  ## API
235
151
 
236
- > **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.
237
153
 
238
154
  ### Construction
239
155
 
@@ -255,100 +171,78 @@ const editor = createSvgEditor({
255
171
  });
256
172
  ```
257
173
 
258
- `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.
259
175
 
260
- 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).
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).
261
177
 
262
- ### Surface
178
+ ### Geometry primitives
263
179
 
264
- 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.
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.
265
181
 
266
- ```ts
267
- import { attach_dom_surface } from "@grida/svg-editor/dom";
182
+ #### `PathModel`
268
183
 
269
- const handle = attach_dom_surface(editor, {
270
- container,
271
- gestures: true, // default — install the bundled gesture set (see "Camera")
272
- fit: false, // default — start with identity camera (no auto-fit)
273
- });
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.
274
185
 
275
- handle.camera; // surface-scoped pan/zoom (see "Camera")
276
- handle.gestures; // surface-scoped wheel/pointer/keyboard layer
277
- handle.detach();
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
278
195
  ```
279
196
 
280
- `@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.
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.
281
198
 
282
- ### Camera
199
+ ### Surface
283
200
 
284
- The camera is a **surface-scoped** pan/zoom: it lives on `handle.camera`, never on `editor.state`. View state and document state are disjoint pan/zoom doesn't bump `state.version`, doesn't serialize, and isn't undoable. A second `attach_dom_surface(...)` gets its own camera.
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).
285
202
 
286
203
  ```ts
287
- handle.camera.center; // world-space point at viewport center (Vec2)
288
- handle.camera.zoom; // uniform scale; 1 = 100 %
289
- handle.camera.bounds; // world Rect visible in the viewport
290
- handle.camera.viewport_size; // { width, height } in screen px
291
- handle.camera.transform; // underlying cmath.Transform (advanced read)
292
-
293
- handle.camera.pan({ x, y }); // translate by a screen-space delta
294
- handle.camera.zoom_at(factor, origin_screen); // pinch / wheel-at-cursor
295
- handle.camera.set_center({ x, y });
296
- handle.camera.set_zoom(z, pivot_screen?);
297
- handle.camera.set_transform(t); // idempotent — no notify when t === current
298
-
299
- handle.camera.fit("<root>"); // fit the document into the viewport
300
- handle.camera.fit("<selection>"); // fit current selection
301
- handle.camera.fit(nodeId);
302
- handle.camera.fit(rect, { margin: 64 });
303
- handle.camera.reset(); // identity
304
-
305
- handle.camera.subscribe(cb): Unsubscribe; // transient channel — no state.version bump
306
- ```
204
+ import { attach_dom_surface } from "@grida/svg-editor/dom";
307
205
 
308
- `set_transform` is **idempotent by contract**: when the new transform is element-wise equal to the current, the call is a no-op (no notification fires). This is what makes constraints terminate in finite steps and what lets host-side "subscribe → derive → set" loops converge without recursion.
206
+ const handle = attach_dom_surface(editor, { container });
207
+ // later:
208
+ handle.detach();
209
+ ```
309
210
 
310
- Default behavior: **identity on attach**. Pass `fit: true` to `attach_dom_surface(...)` and the camera fits `"<root>"` on the first frame — Excalidraw's `initialData.scrollToContent: true`, but on the surface. Subsequent `editor.load()` calls do **not** re-fit; that's a host concern. Use `editor.state.load_version` (the "fresh document" signal, [see State](#observation--state)) as the trigger — it bumps once per `editor.load()` and is stable across edits:
211
+ The v0 contract is pure lifecycle:
311
212
 
312
213
  ```ts
313
- editor.subscribe_with_selector(
314
- (s) => s.load_version,
315
- () => handle.camera.fit("<root>")
316
- );
214
+ interface Surface {
215
+ /** Teardown: detach listeners, drop retained refs. Called from
216
+ * `editor.detach()` and `editor.dispose()`. */
217
+ dispose(): void;
218
+ }
317
219
  ```
318
220
 
319
- #### Constraints
221
+ What's deliberately **not** part of the contract yet:
320
222
 
321
- ```ts
322
- handle.camera.constraints = {
323
- type: "cover", // bounds cover viewport (keynote / slide UX)
324
- bounds: "<root>", // or an explicit Rect
325
- padding: 80, // screen-pixel breathing room
326
- };
327
- handle.camera.constraints = null; // clear
328
- ```
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.
329
226
 
330
- A typed viewport clamp, evaluated synchronously inside every `set_transform` call. v1.1 ships one variant, `type: 'cover'`: bounds cover the viewport edge-to-edge zoom lower-bounded at fit-with-padding, pan clamped so the user can't see past the bounds. CSS analogy: `object-fit: cover` applied to the bounds rect.
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).
331
228
 
332
- The discriminator (`type`) is a tagged union for forward compatibilityfuture variants (`'contain'`, `'pan-region'`) will be addable without breaking existing call sites. The [keynote preset](#keynote-like-canvas-fixed-viewbox-slide-with-bounded-camera) is built on this primitive.
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.
333
230
 
334
- #### Gestures
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.
335
232
 
336
- `handle.gestures` is sibling to `editor.keymap` a layer of installable bindings. The default set is bundled on attach:
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.
337
234
 
338
- | `id` | trigger | effect |
339
- | ------------------ | --------------------------------- | -------------------------------- |
340
- | `wheel-pan-zoom` | plain wheel | `camera.pan(delta)` |
341
- | `wheel-pan-zoom` | Ctrl/Cmd + wheel, native pinch | `camera.zoom_at(...)` at cursor |
342
- | `space-drag-pan` | Space-down + drag | hand-tool pan |
343
- | `middle-mouse-pan` | middle-button drag | pan |
344
- | `keyboard-zoom` | `Shift+0` / `Shift+1` / `Shift+2` | reset / fit root / fit selection |
345
- | `keyboard-zoom` | `Cmd/Ctrl + =` / `Cmd/Ctrl + -` | zoom in / out |
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.
346
236
 
347
- Swap as a unit (`attach_dom_surface(editor, { container, gestures: false })`) then call `handle.gestures.bind({ id, install: ctx => uninstall })` à la carte. **No per-gesture options bag** if you disagree with a default, unbind it and bind your own. Same discipline as `editor.keymap`.
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:
348
238
 
349
- #### `svg-boundary ≠ editor boundary`
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
+ ```
350
244
 
351
- The SVG's intrinsic `width` / `height` / `viewBox` are _content_, not viewport. The camera frames space; the SVG sits in that space. A 1920×1080 attachment is fully serviced by `camera.fit("<root>")` + container CSS for the unbounded case, or by `camera.constraints = { type: 'cover', bounds: '<root>', padding }` for the slide case. There is no first-class "stage" concept; the constraint is the layer where bounded-canvas UX is expressed.
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.
352
246
 
353
247
  ### Lifecycle
354
248
 
@@ -358,16 +252,39 @@ editor.detach(): void; // detach current surface, keep editor state
358
252
  editor.dispose(): void; // permanent teardown
359
253
  ```
360
254
 
361
- `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.
362
256
 
363
257
  ### External control
364
258
 
365
259
  ```ts
366
260
  editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
367
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
368
263
  editor.reset(): void; // back to last load() input, clears history
369
264
  ```
370
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
+
371
288
  ### Observation — state
372
289
 
373
290
  ```ts
@@ -375,14 +292,14 @@ editor.state: {
375
292
  readonly selection: ReadonlyArray<NodeId>;
376
293
  readonly scope: NodeId | null; // active isolation (group entered via dblclick)
377
294
  readonly mode: Mode; // "select" | "edit-content"
295
+ readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode
378
296
  readonly dirty: boolean; // unsaved changes since load() / serialize()
379
297
  readonly can_undo: boolean;
380
298
  readonly can_redo: boolean;
381
- readonly version: number; // bumps on every emit (selection, mutation, history)
382
- readonly structure_version: number; // bumps only on tree-shape / display-label changes;
383
- // stable across pure attribute writes
384
- readonly load_version: number; // bumps once per editor.load(svg) call; starts at 0;
385
- // the right "fresh document" signal — stable across edits
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)
386
303
  };
387
304
 
388
305
  editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
@@ -393,13 +310,52 @@ editor.subscribe_with_selector<T>(
393
310
  ): Unsubscribe;
394
311
  ```
395
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
+
396
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.
397
316
 
398
- **`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).
335
+
336
+ ### Observation — vector sub-selection
337
+
338
+ While a path is open in vector content-edit, the **sub-selection** (which vertices / segments / tangents are selected) is its own piece of state, distinct from `state.selection` (which names the whole node under edit). It is exposed as a designed read view — the read half of [#790](https://github.com/gridaco/grida/issues/790), paired with the `set_vector_selection` command.
339
+
340
+ ```ts
341
+ type VectorSubSelection = {
342
+ node_id: NodeId;
343
+ vertices: readonly number[]; // ordinal vertex indices (PathModel order)
344
+ segments: readonly number[]; // ordinal segment indices
345
+ tangents: readonly (readonly [number, 0 | 1])[]; // [vertexIndex, 0=ta | 1=tb]
346
+ };
347
+
348
+ editor.vector_subselection(): VectorSubSelection | null; // null = no session
349
+ editor.subscribe_vector_subselection(
350
+ fn: (sel: VectorSubSelection | null) => void,
351
+ ): Unsubscribe;
352
+ ```
353
+
354
+ The channel fires on every sub-selection change (click, marquee, lasso, programmatic `set_vector_selection`, undo/redo) and on session enter/exit (`null` on exit). Like pick and surface-hover it is kept **off `state.version`** — sub-selection changes at pointer rate during marquee/lasso, so folding it into the version stream would re-render the whole app per knob (P4). Subscribe to this channel instead of polling `state`.
399
355
 
400
356
  ### Observation — properties
401
357
 
402
- 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).
358
+ 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.
403
359
 
404
360
  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:
405
361
 
@@ -408,9 +364,9 @@ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: *
408
364
 
409
365
  Plus the editor's own metadata, marked as such:
410
366
 
411
- - **`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.
367
+ - **`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.
412
368
 
413
- Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
369
+ 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.
414
370
 
415
371
  ```ts
416
372
  type Provenance = {
@@ -428,30 +384,13 @@ type InvalidComputedValue = {
428
384
  reason: string; // e.g. "var(--brand) is not defined and has no fallback"
429
385
  };
430
386
 
431
- type PropertyValue<T = string | number> = {
387
+ type PropertyValue<T = string> = {
432
388
  declared: string | null;
433
389
  computed: T | InvalidComputedValue | null;
434
390
  provenance: Provenance;
435
391
  };
436
392
  ```
437
393
 
438
- #### What v1's cascade engine covers
439
-
440
- The headless cascade engine resolves values synchronously without a surface, covering:
441
-
442
- - Presentation attributes (`<rect fill="red">`)
443
- - Inline `style="..."`
444
- - Inheritance from parent elements
445
- - The property's initial value
446
-
447
- #### What v1's cascade engine does **not** cover
448
-
449
- - `<style>` block matching
450
- - `var()` (custom-property) substitution
451
- - `currentColor` resolution
452
-
453
- 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?".
454
-
455
394
  Read — per node:
456
395
 
457
396
  ```ts
@@ -463,12 +402,15 @@ editor.node_properties(
463
402
 
464
403
  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`.
465
404
 
466
- 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:
405
+ 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.
406
+
407
+ 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:
467
408
 
468
409
  ```ts
469
410
  editor.commands.set_property(name: string, value: string | null): void;
470
411
 
471
412
  editor.commands.preview_property(name: string): {
413
+ readonly live: boolean; // false once the session has ended, for any reason
472
414
  update(value: string): void;
473
415
  commit(): void;
474
416
  discard(): void;
@@ -477,16 +419,22 @@ editor.commands.preview_property(name: string): {
477
419
 
478
420
  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.
479
421
 
422
+ 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.
423
+
480
424
  ### Observation — paint (`fill` / `stroke`)
481
425
 
482
426
  `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.
483
427
 
428
+ This section, like Properties above, is **per-node and spec-aligned**. Multi-selection aggregation is in the [Multi-selection](#multi-selection-mixed-values) section.
429
+
484
430
  The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
485
431
 
486
432
  ```
487
433
  <paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
488
434
  ```
489
435
 
436
+ `<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`.
437
+
490
438
  ```ts
491
439
  type Paint =
492
440
  | { kind: "none" } // fill="none"
@@ -497,13 +445,17 @@ type Paint =
497
445
 
498
446
  type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
499
447
 
448
+ // Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
449
+ // rgb resolution happens at *used* value, which requires the surface's painting context.
500
450
  type Color =
501
- | { kind: "rgb"; value: string } // any resolvable CSS color, normalized to rgb-ish
502
- | { kind: "current_color" }; // unresolved keyword; resolve via dom_computed_paint when DOM-attached
451
+ | { kind: "rgb"; value: string } // canonical lowercase hex (#rrggbb / #rrggbbaa) for any
452
+ // literal resolvable without a rendering context (named / hex / rgb() / hsl() / hwb());
453
+ // unresolved spaces (lab() / oklch() / color()) pass through as authored
454
+ | { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
503
455
 
504
456
  type PaintValue = {
505
457
  declared: string | null; // raw, e.g. "var(--brand, currentColor)" or "url(#g1) red"
506
- computed: Paint | InvalidComputedValue | null; // post-defaulting; var() reported as invalid in v1
458
+ computed: Paint | InvalidComputedValue | null; // post-defaulting, post-var
507
459
  provenance: Provenance;
508
460
  };
509
461
  ```
@@ -514,11 +466,11 @@ Read — per node:
514
466
  editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
515
467
  ```
516
468
 
517
- Notes per spec:
469
+ Notes on the `<url>` reference, per spec:
518
470
 
519
- - A reference to a non-existent id with no fallback paints nothing for that layer. Surfacing this as a warning is **deferred for v1**.
520
- - `context-fill` / `context-stroke` are only meaningful inside `<marker>` content or a `<use>` shadow tree.
521
- - 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.
471
+ - 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)).
472
+ - 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()`.
473
+ - `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.
522
474
 
523
475
  Write — selection-scoped (same reasoning as for generic properties):
524
476
 
@@ -526,21 +478,50 @@ Write — selection-scoped (same reasoning as for generic properties):
526
478
  editor.commands.set_paint(channel: "fill" | "stroke", paint: Paint): void;
527
479
 
528
480
  editor.commands.preview_paint(channel: "fill" | "stroke"): {
481
+ readonly live: boolean; // false once the session has ended, for any reason
529
482
  update(paint: Paint): void;
530
483
  commit(): void;
531
484
  discard(): void;
532
485
  };
533
486
  ```
534
487
 
535
- 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.
488
+ 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.
489
+
490
+ ### Multi-selection (mixed values)
491
+
492
+ 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.
493
+
494
+ This layer is **not deeply designed yet**. The shape will likely look something like:
495
+
496
+ ```ts
497
+ // Provisional — names, contract, and ergonomics subject to design before v0.
498
+ type MixedView<V> =
499
+ | { status: "single"; value: V } // every selected node agrees
500
+ | { status: "mixed"; per_node: ReadonlyMap<NodeId, V> } // values differ
501
+ | { status: "unsupported" } // no selected node has this property
502
+ | { status: "empty" }; // no selection
503
+
504
+ editor.selection_properties(names): { readonly [name: string]: MixedView<PropertyValue> };
505
+ editor.selection_paint(channel): MixedView<PaintValue>;
506
+ ```
507
+
508
+ `@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.
509
+
510
+ 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.
536
511
 
537
512
  ### Observation — defs (resources)
538
513
 
539
- 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)).
514
+ 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)`.
540
515
 
541
516
  ```ts
542
517
  editor.defs: {
543
518
  gradients: GradientsApi;
519
+ patterns: PatternsApi;
520
+ symbols: SymbolsApi;
521
+ markers: MarkersApi;
522
+ clip_paths: ClipPathsApi;
523
+ masks: MasksApi;
524
+ filters: FiltersApi;
544
525
  };
545
526
 
546
527
  interface GradientsApi {
@@ -603,6 +584,10 @@ editor.commands.set_paint_from_gradient(
603
584
 
604
585
  This is one undo step.
605
586
 
587
+ 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>`.
588
+
589
+ 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)`.
590
+
606
591
  ### Observation — tree
607
592
 
608
593
  ```ts
@@ -621,45 +606,29 @@ editor.tree(): {
621
606
  };
622
607
  ```
623
608
 
624
- 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).
609
+ Returns a shallow snapshot. Cheap to call after a `version` bump.
625
610
 
626
- ```ts
627
- editor.display_label(
628
- id: NodeId,
629
- opts?: { tagLabel?: (tag: string) => string }
630
- ): string;
631
- ```
611
+ ### Modes and tools
632
612
 
633
- 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.
613
+ "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.
634
614
 
635
- ### Observationsurface hover
636
-
637
- 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.
615
+ - **`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).
616
+ - **`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).
638
617
 
639
618
  ```ts
640
- editor.surface_hover(): NodeId | null; // current effective hover (pointer or override)
641
- editor.set_surface_hover_override(id: NodeId | null): void; // push hover from out-of-canvas UI
642
- editor.subscribe_surface_hover(cb: () => void): Unsubscribe;
643
- ```
644
-
645
- 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.
646
-
647
- ### Modes
648
-
649
- 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.
650
-
651
- ```ts
652
- editor.modes: ReadonlyArray<Mode>; // frozen after construction
653
- // v1: ["select", "edit-content"]
619
+ editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction ["select", "edit-content"]
620
+ editor.state.mode: Mode;
621
+ editor.state.tool: Tool;
654
622
 
655
623
  editor.commands.set_mode(mode: Mode): void;
624
+ editor.set_tool(tool: Tool): void; // also dispatchable as the `tool.set` command (keymap V/R/O/L/T)
656
625
  ```
657
626
 
658
- Adding new modes (insertion tools, etc.) requires a PR to this package; per the anti-goals, this is not a vector authoring tool.
627
+ 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.
659
628
 
660
629
  ### Commands
661
630
 
662
- The full closed set for v1. Adding a command requires a PR.
631
+ The full closed set. Adding a command requires a PR to this package.
663
632
 
664
633
  ```ts
665
634
  editor.commands.{
@@ -669,8 +638,10 @@ editor.commands.{
669
638
  enter_scope(group: NodeId): void;
670
639
  exit_scope(): void;
671
640
 
672
- // mode
641
+ // mode + tool
673
642
  set_mode(mode: Mode): void;
643
+ // `set_tool` is also accessible as `editor.set_tool(...)`; the command form
644
+ // is provided so keymap bindings (V/R/O/L) can dispatch via the registry.
674
645
 
675
646
  // generic property (any SVG/CSS attribute)
676
647
  set_property(name: string, value: string | null): void;
@@ -685,20 +656,102 @@ editor.commands.{
685
656
  opts?: { reuse_existing?: boolean },
686
657
  ): { gradient_id: string };
687
658
 
688
- // transforms
659
+ // opacity — typed, clamped sugar for set_property("opacity", …); one
660
+ // history step across the selection. `value` is clamped to [0, 1]. This
661
+ // is the command behind the digit → opacity shortcut; the digit
662
+ // keybindings are NOT shipped in the default keymap (host-wired).
663
+ set_opacity(value: number): void;
664
+
665
+ // transforms (atomic — the bundled HUD drives drag-resize-rotate internally)
689
666
  translate(delta: { dx: number; dy: number }): void;
667
+ nudge(direction: "left" | "right" | "up" | "down", step?: number): void;
668
+ resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void;
669
+ resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
670
+ rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
671
+ rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
672
+ // `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
673
+ transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
674
+ flatten_transform(): void; // bake `transform=` into native attrs where possible
675
+
676
+ // alignment (operates on selection of ≥2 nodes against their union bbox)
677
+ align(direction: AlignDirection): void;
690
678
 
691
679
  // structure
692
680
  reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
681
+ group(): void; // wrap selection in a new <g>
682
+ ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
683
+ // (clean-structural subset only; refuses
684
+ // groups with visual state — see TODO §10)
693
685
  remove(): void;
694
686
 
687
+ // clipboard — the payload is a STANDALONE SVG DOCUMENT, not a private
688
+ // format (the file is the IR, so the clipboard is the file format).
689
+ // Copy carries the outbound url(#…)/href reference closure in one
690
+ // <defs> block and declares borrowed xmlns prefixes on the payload
691
+ // shell; ancestor transforms / inherited presentation / viewport are
692
+ // deliberately NOT carried (verbatim policy). Cut = copy + remove as
693
+ // ONE history step labeled "cut"; undo restores the document and the
694
+ // clipboard keeps the payload (cut → undo → paste = move). Paste is
695
+ // synchronous over delivered text (`text ?? internal buffer`) and has
696
+ // a gesture-grade refusal table: non-parseable environment input is a
697
+ // no-op `[]`, never a throw (insert_fragment keeps strict semantics).
698
+ // System-clipboard wiring is the DOM surface's native ClipboardEvent
699
+ // transport (text/plain = the markup itself) plus the optional
700
+ // ClipboardProvider seam. Full contract:
701
+ // https://grida.co/docs/wg/feat-svg-editor/clipboard
702
+ copy(): string | null; // payload | null on empty selection; no history
703
+ cut(): string | null; // one undoable step; buffer secured before delete
704
+ paste(text?: string): NodeId[]; // inserted roots (selected); [] = refusal
705
+
706
+ // duplicate — the clipboard FRD's SECOND extraction operation
707
+ // (subtree clone): in-document, so NO defs closure and NO xmlns
708
+ // shell are carried; subtrees and authored ids clone verbatim
709
+ // (colliding ids resolve first-in-document-order; Tidy dedups).
710
+ // Each clone lands as its origin's next sibling (paints above it);
711
+ // selection moves to the clones; ONE history step. Alt-drag
712
+ // translate-with-clone consumes the same operation. Repeating
713
+ // offset: duplicate → move the copy → duplicate repeats the
714
+ // translate delta (an Alt-drag clone commit arms the same memory);
715
+ // still one undo step, degrades to in-place when the preconditions
716
+ // don't hold. Contract:
717
+ // https://grida.co/docs/wg/feat-svg-editor/subtree-clone
718
+ duplicate(): NodeId[]; // clone ids (selected); [] = refusal
719
+
720
+ // insertion — `tag` is an open string (so paste / RPC can create any element,
721
+ // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
722
+ // draw gesture and default paint.
723
+ insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
724
+ // markup-shaped sibling of `insert` — one or more sibling elements, or a
725
+ // full `<svg>` doc (the shell is discarded; its children are the content).
726
+ // Subtrees adopted verbatim; ONE history step; returns root ids in
727
+ // document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
728
+ // undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
729
+ // in the same step. Position is authored content: wrap the fragment in
730
+ // `<g transform="translate(x y)">` to land it at a point — same single
731
+ // undo step, no placement opt.
732
+ insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
733
+ insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
734
+
695
735
  // content
696
736
  set_text(value: string): void;
737
+ enter_content_edit(target?: NodeId, opts?: VectorSubSelectionInput): boolean;
738
+ // vector content-edit — set the vertex / segment / tangent sub-selection
739
+ // while a path is open in content-edit (the write half of #790). `mode`
740
+ // defaults to "replace"; "add"/"toggle" fold into the existing selection.
741
+ // One undoable step, like a knob click. Returns false (no-op) when no vector
742
+ // session is active, no surface is attached, the input is out of range, or
743
+ // it resolves to the current selection — a strict surface refuses rather
744
+ // than silently mishandling. To open a path with an initial sub-selection in
745
+ // ONE step, pass the same input as `enter_content_edit`'s `opts`.
746
+ set_vector_selection(input: VectorSubSelectionInput, mode?: SelectMode): boolean;
697
747
 
698
748
  // file
699
749
  load_svg(svg: string): void;
700
750
  serialize_svg(): string;
701
751
 
752
+ // cleanup — never silent, never automatic
753
+ tidy(opts?: TidyOptions): void;
754
+
702
755
  // history
703
756
  undo(): void;
704
757
  redo(): void;
@@ -707,19 +760,9 @@ editor.commands.{
707
760
 
708
761
  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.
709
762
 
710
- **`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).
711
-
712
- #### Content edit
713
-
714
- ```ts
715
- editor.enter_content_edit(target?: NodeId): boolean;
716
- ```
717
-
718
- 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.
719
-
720
- #### Naming convention
763
+ `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.
721
764
 
722
- 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.
765
+ (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.)
723
766
 
724
767
  ### Providers
725
768
 
@@ -744,8 +787,6 @@ type FileIOProvider = {
744
787
  };
745
788
  ```
746
789
 
747
- 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.).
748
-
749
790
  ### Style
750
791
 
751
792
  `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.
@@ -758,18 +799,18 @@ type EditorStyle = {
758
799
  handle_stroke: string;
759
800
  endpoint_dot_radius: number;
760
801
  selection_outline_width: number;
761
- measurement_color: string; // measurement guide lines + numeric pills
802
+ // ...
762
803
  };
763
804
 
764
805
  editor.style: Readonly<EditorStyle>;
765
806
  editor.set_style(partial: Partial<EditorStyle>): void;
766
807
  ```
767
808
 
768
- `set_style` is the legitimate runtime path — wire it up for theme switching.
769
-
770
809
  ### React API (thin wrapper)
771
810
 
772
- 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.
811
+ 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.
812
+
813
+ #### Core (the primitives)
773
814
 
774
815
  ```tsx
775
816
  import {
@@ -778,18 +819,45 @@ import {
778
819
  useSvgEditor,
779
820
  useEditorState,
780
821
  useCommands,
781
- useCameraSnapshot,
782
822
  } from "@grida/svg-editor/react";
783
823
  ```
784
824
 
785
- That's the whole public surface.
786
-
787
825
  - `SvgEditorProvider` — owns the headless editor, puts it in context.
788
- - `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container: div, gestures, fit, initial_camera })` on mount and `detach()` on unmount. Pass `onAttach={setHandle}` to receive the `DomSurfaceHandle` (for `handle.camera` / `handle.gestures`).
826
+ - `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.
789
827
  - `useSvgEditor()` — returns the editor instance from context.
790
- - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change.
828
+ - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
791
829
  - `useCommands()` — sugar for `useSvgEditor().commands`.
792
- - `useCameraSnapshot(handle, selector, fallback)` — sibling to `useEditorState`, for the camera channel. Returns the selected value and re-renders on camera mutation; uses `fallback` while `handle` is null. The recipe lives in the package because both demos wrote it identically (see `/canvas/svg` and `/canvas/svg/keynote`).
830
+
831
+ #### Bundled hooks (state-slice convenience + lifecycle-aware sessions)
832
+
833
+ 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.
834
+
835
+ ```tsx
836
+ import {
837
+ // state slices (one-line wrappers over useEditorState)
838
+ useSelection, // → readonly NodeId[]
839
+ useTool, // → Tool
840
+ useMode, // → Mode
841
+ useCanUndo, // → boolean
842
+ useCanRedo, // → boolean
843
+
844
+ // lifecycle-aware preview sessions — unmount = discard (never commit)
845
+ usePaintPreview, // (channel) → PaintPreviewSession
846
+ usePropertyPreview, // (name) → PreviewSession
847
+
848
+ // bound imperative actions, stable identity across renders
849
+ useEditorLoad, // → (svg: string) => void
850
+ useEditorSerialize, // → () => string
851
+
852
+ // RAII hover override (clears on unmount if this hook set the override)
853
+ useHoverOverride, // → (id: NodeId | null) => void
854
+
855
+ // camera bridge (subscribe to a slice of handle.camera without bumping state.version)
856
+ useCameraSnapshot, // (handle, selector, fallback) → T
857
+ } from "@grida/svg-editor/react";
858
+ ```
859
+
860
+ 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.
793
861
 
794
862
  Top-level wiring:
795
863
 
@@ -814,19 +882,27 @@ Everything else is consumer-built against the editor's API. The two patterns:
814
882
 
815
883
  ```tsx
816
884
  function Toolbar() {
817
- const mode = useEditorState((s) => s.mode);
818
- const cmd = useCommands();
885
+ // Insertion is the `Tool` axis, not `Mode` — `Mode` is only
886
+ // "select" / "edit-content". Flip tools via `editor.set_tool(...)`.
887
+ const tool = useEditorState((s) => s.tool);
888
+ const editor = useSvgEditor();
819
889
  return (
820
890
  <>
821
891
  <ToolButton
822
- active={mode === "select"}
823
- onClick={() => cmd.set_mode("select")}
892
+ active={tool.type === "cursor"}
893
+ onClick={() => editor.set_tool({ type: "cursor" })}
824
894
  >
825
895
 
826
896
  </ToolButton>
827
897
  <ToolButton
828
- active={mode === "edit-content"}
829
- onClick={() => cmd.set_mode("edit-content")}
898
+ active={tool.type === "insert" && tool.tag === "rect"}
899
+ onClick={() => editor.set_tool({ type: "insert", tag: "rect" })}
900
+ >
901
+
902
+ </ToolButton>
903
+ <ToolButton
904
+ active={tool.type === "insert-text"}
905
+ onClick={() => editor.set_tool({ type: "insert-text" })}
830
906
  >
831
907
  T
832
908
  </ToolButton>
@@ -867,7 +943,7 @@ function PropertyPanel() {
867
943
  const selection = useEditorState((s) => s.selection);
868
944
  const cmd = useCommands();
869
945
 
870
- // v1: single-selection path. Multi-selection aggregation is deferred.
946
+ // v0: single-selection path. Multi-selection arrives with the mixed-values layer.
871
947
  if (selection.length !== 1)
872
948
  return <MultiSelectionPlaceholder count={selection.length} />;
873
949
  const id = selection[0];
@@ -879,7 +955,8 @@ function PropertyPanel() {
879
955
  return (
880
956
  <>
881
957
  {/* PaintInput is consumer-built. `provenance` tells the user whether this
882
- value came from an attribute, inline style, was inherited, or defaulted. */}
958
+ value came from an attribute, inline style, a stylesheet rule, or was
959
+ inherited / defaulted. */}
883
960
  <PaintInput
884
961
  label="Fill"
885
962
  declared={fill.declared}
@@ -903,136 +980,40 @@ function PropertyPanel() {
903
980
  }
904
981
  ```
905
982
 
906
- 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.
983
+ 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.
907
984
 
908
985
  What this means in practice:
909
986
 
910
- - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.gradients.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
987
+ - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.*.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
911
988
  - A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
912
989
  - Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
913
990
 
914
- ## Low-level API
915
-
916
- 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.
917
-
918
- ```ts
919
- editor.document; // raw IR handle
920
- editor.dom_computed_property(id, name); // getComputedStyle when DOM-attached
921
- editor.dom_computed_paint(id, channel);
922
- editor.commands.register(id, handler);
923
- editor.commands.invoke(id, args);
924
- editor.commands.has(id);
925
- editor.keymap; // host-controlled bindings
926
- ```
927
-
928
- ### `editor.document`
929
-
930
- 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.
931
-
932
- ### `editor.dom_computed_property` / `editor.dom_computed_paint`
933
-
934
- ```ts
935
- editor.dom_computed_property(id: NodeId, name: string): string | null;
936
- editor.dom_computed_paint(
937
- id: NodeId,
938
- channel: "fill" | "stroke"
939
- ): { computed: string; resolved_paint: Paint | null } | null;
940
- ```
941
-
942
- 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.
943
-
944
- 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.
945
-
946
- ### `editor.commands.register / invoke / has`
947
-
948
- ```ts
949
- editor.commands.register(id: CommandId, handler: CommandHandler): () => void;
950
- editor.commands.invoke(id: CommandId, args?: unknown): boolean;
951
- editor.commands.has(id: CommandId): boolean;
952
-
953
- type CommandId = string; // dotted: "history.undo", "selection.remove", "host.copy"
954
- type CommandHandler = (args?: unknown) => boolean | void;
955
- ```
956
-
957
- 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).
958
-
959
- ### `editor.keymap`
960
-
961
- ```ts
962
- editor.keymap.bind(binding: KeymapBinding): () => void;
963
- editor.keymap.unbind(spec: { keybinding?: Keybinding; command?: CommandId }): void;
964
- editor.keymap.bindings(): readonly KeymapBinding[];
965
- editor.keymap.dispatch(event: KeyboardEvent): boolean;
966
- ```
967
-
968
- 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.
969
-
970
- The `@grida/keybinding` package provides the `Keybinding` type, `kb()` / `seq()` / `platformKb()` builders, and the `KeyCode` enum.
971
-
972
- ---
973
-
974
- 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.
975
-
976
- ## Presets
977
-
978
- ```ts
979
- import { keynote } from "@grida/svg-editor/presets";
980
-
981
- const handle = keynote.attach(editor, { container, padding: 80 });
982
- // later: handle.set_padding(0); // present-mode toggle
983
- // handle.detach();
984
- ```
985
-
986
- Presets are **opinionated bundles** that compose the public primitives (`attach_dom_surface`, `camera.constraints`, `load_version`, etc.) into one-import setups for common UX shapes. They live at the **`@grida/svg-editor/presets`** subpath — an explicit opt-in. The main `@grida/svg-editor` entry never references this subpath, so hosts that don't want a preset don't pay for it.
987
-
988
- **v1.1 ships one preset:**
989
-
990
- - **`keynote`** — slide-shaped canvas. Attaches the DOM surface with `fit: true`, installs `camera.constraints = { type: 'cover', bounds: '<root>', padding }`, subscribes to `editor.state.load_version` for refit-on-load. Returns a `KeynoteSurfaceHandle = DomSurfaceHandle & { set_padding(p): void }` — the `set_padding` method swaps the constraint padding at runtime (use for "Present mode" toggles).
991
-
992
- The preset's source ([`src/presets/keynote.ts`](https://github.com/gridaco/grida/blob/main/packages/grida-svg-editor/src/presets/keynote.ts)) is ≤ 70 lines of pure composition over the public package surface — no `_internal` reach, no private coupling. If you need a different shape, copy the file and modify.
993
-
994
- **Discipline.** Code in `src/presets/` is allowed to import only from `@grida/svg-editor` / `@grida/svg-editor/dom` / `@grida/svg-editor/react` and third-party packages — never from `src/core/`, `src/commands/`, `src/keymap/`, or `src/gestures/`. This means every preset is composable against what an external consumer has access to: if a preset works, the same composition works for any host.
995
-
996
- A second preset graduates when a second use case asks for one (`slidedeck`, `sticker`, `canvas`, …) — discovery-loop discipline.
997
-
998
- ## Deferred for v1
999
-
1000
- What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workaround.
1001
-
1002
- - **`<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)).
1003
- - **Defs registries other than gradients** (patterns, symbols, markers, clip-paths, masks, filters). Walk `editor.document` directly.
1004
- - **`commands.resize` (top-level), `commands.rotate`, `commands.tidy`**. Resize gestures are HUD-driven via a preview session; rotate and tidy are unimplemented.
1005
- - **Multi-selection aggregation** (mixed-value property/paint views).
1006
- - **Insertion modes / shape tools.** Anti-goal — "Not a vector authoring tool."
1007
- - **Copy/paste, alt-drag, snap-to-geometry, snap-to-pixel-grid, alignment hotkeys, duplicate (Cmd+D), select-all.**
1008
- - **Non-DOM surfaces** (worker, React Native, headless test harness).
1009
- - **Paint-server validity warnings** (dangling `url(#id)` references).
1010
- - **Rotation gesture in the HUD.**
1011
- - **Persisted camera state.** Camera is surface-scoped and never enters `editor.state` / `editor.serialize()` / history. Hosts that want view bookmarks persist `handle.camera.transform` themselves.
1012
- - **Gesture momentum / inertia / animated `fit`.** The `animate` shape isn't reserved in the type — it'll be added when delivered, not before.
1013
- - **`CameraConstraints` variants beyond `'cover'`.** v1.1 ships `type: 'cover'` only (bounds cover viewport — slide / keynote UX). The discriminator is a tagged union so future variants land as additive arms: `type: 'contain'` (bounds always fully visible inside viewport — bounded artwork) and `type: 'pan-region'` (d3-zoom `translateExtent` style — pan clamped to a region with free zoom) are reserved names but not yet implemented. Peer-validated direction: tldraw's `TLCameraConstraints.behavior: 'inside' | 'outside'`, mapped to CSS-style `'cover'` / `'contain'` naming.
1014
-
1015
991
  ## Anti-goals
1016
992
 
1017
993
  What this editor will never be. Each one is a defensive perimeter for the principles above.
1018
994
 
1019
995
  - **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
1020
996
  - **Not an animation editor.** SMIL is preserved verbatim, never authored or mutated.
1021
- - **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.)
997
+ - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
1022
998
  - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
1023
999
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
1024
- - **Not a private IR.** SVG is the source of truth; there is no canonical Grida representation behind it.
1000
+ - **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.
1001
+ - **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.)
1025
1002
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
1003
+ - **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.
1026
1004
 
1027
1005
  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.
1028
1006
 
1029
1007
  ## Status
1030
1008
 
1031
- `v1.0.0-alpha.4` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts, **surface-scoped camera** (pan / zoom / fit with idempotent `set_transform`), **gestures layer** sibling to `editor.keymap` (wheel-pan-zoom, space-drag, middle-mouse-drag, keyboard zoom), **typed `camera.constraints`** (`'cover'` variant, peer-validated tagged union), **`editor.state.load_version`** (fresh-document signal distinct from `structure_version`), and the **`@grida/svg-editor/presets`** subpath shipping `keynote.attach` for slide-shaped SVGs.
1009
+ - `v0.x` — selection, transform, insert (rect / ellipse / line), inline text
1010
+ edit, and the click-to-place text tool. Experimental.
1011
+
1012
+ 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.
1032
1013
 
1033
- 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.
1014
+ ## Contributing
1034
1015
 
1035
- The shape may still change between alpha and `1.0.0` based on dogfooding feedback. Don't depend on it from production code yet.
1016
+ - [`TODO.md`](./TODO.md) open questions and deferred work, grouped by area.
1036
1017
 
1037
1018
  ## License
1038
1019