@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.11

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,14 @@ 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`. Migration sketch: `docs/wg/feat-svg-editor/element-ir-migration.md`.
55
+
48
56
  ## Principles
49
57
 
50
58
  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 +71,11 @@ A small, named set of concerns belongs to the embedding product: clipboard, file
63
71
 
64
72
  ### P4. Subscribe to outcomes, not events.
65
73
 
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.
74
+ 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
75
 
68
76
  ### P5. A separate layer earns its separateness by reuse or isolated testability.
69
77
 
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.
78
+ 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
79
 
72
80
  ### P6. Public only after dogfooding.
73
81
 
@@ -89,11 +97,13 @@ When a new design decision lands, walk these in order. The first match wins.
89
97
 
90
98
  These are the design principles guiding the implementation.
91
99
 
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.
100
+ - Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core. Plug into any rendering surface.
93
101
  - The IR carries source-position trivia, so the serializer can rewrite only the bytes that actually changed.
94
102
  - 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
103
  - 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.**
104
+ - 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.
105
+
106
+ 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
107
 
98
108
  ## Examples
99
109
 
@@ -107,13 +117,13 @@ A few scenarios this is designed to handle well.
107
117
  >
108
118
  > 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
119
 
110
- > _"I translated a rect 10px to the right."_
120
+ > _"I rotated a rect by 12 degrees."_
111
121
  >
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`.
122
+ > 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
123
 
114
124
  > _"This SVG has a `<style>` block with `.brand { fill: var(--brand) }`. I want to change the fill of one element to red."_
115
125
  >
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)).
126
+ > 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
127
 
118
128
  > _"This file has an Inkscape `<sodipodi:namedview>` block and `inkscape:label` attributes."_
119
129
  >
@@ -131,7 +141,7 @@ npm install @grida/svg-editor
131
141
 
132
142
  ## API
133
143
 
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.
144
+ > **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
145
 
136
146
  ### Construction
137
147
 
@@ -155,11 +165,11 @@ const editor = createSvgEditor({
155
165
 
156
166
  `createSvgEditor` is the only constructor. The returned `SvgEditor` is the only object consumers ever hold.
157
167
 
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).
168
+ The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a `Surface` (next section).
159
169
 
160
170
  ### Surface
161
171
 
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.
172
+ A `Surface` is the host-provided rendering and input boundary. The editor pushes paint instructions and HUD descriptors to the surface; the surface pushes normalized input events back. Non-DOM hosts (React Native, worker-side renderer, headless test harness) implement the `Surface` interface themselves. The shipped `domSurface` is the reference implementation used by the React layer.
163
173
 
164
174
  ```ts
165
175
  import { attach_dom_surface } from "@grida/svg-editor/dom";
@@ -169,7 +179,28 @@ const handle = attach_dom_surface(editor, { container });
169
179
  handle.detach();
170
180
  ```
171
181
 
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.
182
+ The contract (full surface contract documented separately out of scope for this README):
183
+
184
+ ```ts
185
+ interface Surface {
186
+ // editor → surface: paint the document + HUD overlay
187
+ paint(snapshot: SurfacePaintSnapshot): void;
188
+
189
+ // surface → editor: hit-test on screen pixel
190
+ hit_test(x: number, y: number): NodeId | null;
191
+
192
+ // surface → editor: subscribe to normalized input
193
+ on_input(listener: (event: SurfaceInputEvent) => void): Unsubscribe;
194
+
195
+ dispose(): void;
196
+ }
197
+ ```
198
+
199
+ 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.
200
+
201
+ `@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `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.
202
+
203
+ 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.
173
204
 
174
205
  ### Lifecycle
175
206
 
@@ -179,7 +210,7 @@ editor.detach(): void; // detach current surface, keep editor state
179
210
  editor.dispose(): void; // permanent teardown
180
211
  ```
181
212
 
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.
213
+ `load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached.
183
214
 
184
215
  ### External control
185
216
 
@@ -196,12 +227,14 @@ editor.state: {
196
227
  readonly selection: ReadonlyArray<NodeId>;
197
228
  readonly scope: NodeId | null; // active isolation (group entered via dblclick)
198
229
  readonly mode: Mode; // "select" | "edit-content"
230
+ readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode
199
231
  readonly dirty: boolean; // unsaved changes since load() / serialize()
200
232
  readonly can_undo: boolean;
201
233
  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
234
+ readonly version: number; // bumps on any emission — drag, history, mutation
235
+ readonly structure_version: number; // bumps only when tree shape or display-label inputs change
236
+ readonly geometry_version: number; // bumps only when something that could shift world bounds changes
237
+ readonly load_version: number; // bumps once per `editor.load()` call (constructor doesn't count)
205
238
  };
206
239
 
207
240
  editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
@@ -212,13 +245,13 @@ editor.subscribe_with_selector<T>(
212
245
  ): Unsubscribe;
213
246
  ```
214
247
 
215
- `state` is a frozen snapshot. Consumers never destructure into internals; if a view they need isn't here or in the purpose-built views below, that's an API gap.
248
+ `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.
216
249
 
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.
250
+ `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.
218
251
 
219
252
  ### Observation — properties
220
253
 
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).
254
+ 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
255
 
223
256
  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
257
 
@@ -227,9 +260,9 @@ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: *
227
260
 
228
261
  Plus the editor's own metadata, marked as such:
229
262
 
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.
263
+ - **`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
264
 
232
- Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
265
+ 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
266
 
234
267
  ```ts
235
268
  type Provenance = {
@@ -247,30 +280,13 @@ type InvalidComputedValue = {
247
280
  reason: string; // e.g. "var(--brand) is not defined and has no fallback"
248
281
  };
249
282
 
250
- type PropertyValue<T = string | number> = {
283
+ type PropertyValue<T = string> = {
251
284
  declared: string | null;
252
285
  computed: T | InvalidComputedValue | null;
253
286
  provenance: Provenance;
254
287
  };
255
288
  ```
256
289
 
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
290
  Read — per node:
275
291
 
276
292
  ```ts
@@ -282,7 +298,9 @@ editor.node_properties(
282
298
 
283
299
  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
300
 
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:
301
+ 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.
302
+
303
+ 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
304
 
287
305
  ```ts
288
306
  editor.commands.set_property(name: string, value: string | null): void;
@@ -300,12 +318,16 @@ The editor decides whether to write a presentation attribute vs. inline style fo
300
318
 
301
319
  `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
320
 
321
+ This section, like Properties above, is **per-node and spec-aligned**. Multi-selection aggregation is in the [Multi-selection](#multi-selection-mixed-values) section.
322
+
303
323
  The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
304
324
 
305
325
  ```
306
326
  <paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
307
327
  ```
308
328
 
329
+ `<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`.
330
+
309
331
  ```ts
310
332
  type Paint =
311
333
  | { kind: "none" } // fill="none"
@@ -316,13 +338,15 @@ type Paint =
316
338
 
317
339
  type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
318
340
 
341
+ // Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
342
+ // rgb resolution happens at *used* value, which requires the surface's painting context.
319
343
  type Color =
320
344
  | { 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
345
+ | { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
322
346
 
323
347
  type PaintValue = {
324
348
  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
349
+ computed: Paint | InvalidComputedValue | null; // post-defaulting, post-var
326
350
  provenance: Provenance;
327
351
  };
328
352
  ```
@@ -333,11 +357,11 @@ Read — per node:
333
357
  editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
334
358
  ```
335
359
 
336
- Notes per spec:
360
+ Notes on the `<url>` reference, per spec:
337
361
 
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.
362
+ - 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)).
363
+ - 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()`.
364
+ - `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
365
 
342
366
  Write — selection-scoped (same reasoning as for generic properties):
343
367
 
@@ -351,15 +375,43 @@ editor.commands.preview_paint(channel: "fill" | "stroke"): {
351
375
  };
352
376
  ```
353
377
 
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.
378
+ 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.
379
+
380
+ ### Multi-selection (mixed values)
381
+
382
+ 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.
383
+
384
+ This layer is **not deeply designed yet**. The shape will likely look something like:
385
+
386
+ ```ts
387
+ // Provisional — names, contract, and ergonomics subject to design before v0.
388
+ type MixedView<V> =
389
+ | { status: "single"; value: V } // every selected node agrees
390
+ | { status: "mixed"; per_node: ReadonlyMap<NodeId, V> } // values differ
391
+ | { status: "unsupported" } // no selected node has this property
392
+ | { status: "empty" }; // no selection
393
+
394
+ editor.selection_properties(names): { readonly [name: string]: MixedView<PropertyValue> };
395
+ editor.selection_paint(channel): MixedView<PaintValue>;
396
+ ```
397
+
398
+ `@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.
399
+
400
+ 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
401
 
356
402
  ### Observation — defs (resources)
357
403
 
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)).
404
+ 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
405
 
360
406
  ```ts
361
407
  editor.defs: {
362
408
  gradients: GradientsApi;
409
+ patterns: PatternsApi;
410
+ symbols: SymbolsApi;
411
+ markers: MarkersApi;
412
+ clip_paths: ClipPathsApi;
413
+ masks: MasksApi;
414
+ filters: FiltersApi;
363
415
  };
364
416
 
365
417
  interface GradientsApi {
@@ -422,6 +474,10 @@ editor.commands.set_paint_from_gradient(
422
474
 
423
475
  This is one undo step.
424
476
 
477
+ 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>`.
478
+
479
+ 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)`.
480
+
425
481
  ### Observation — tree
426
482
 
427
483
  ```ts
@@ -440,45 +496,24 @@ editor.tree(): {
440
496
  };
441
497
  ```
442
498
 
443
- Returns a shallow snapshot. Cheap to call after a `structure_version` bump (and stable across pure attribute writes, so layer-list panels keyed on it don't re-render at gesture rate).
444
-
445
- ```ts
446
- editor.display_label(
447
- id: NodeId,
448
- opts?: { tagLabel?: (tag: string) => string }
449
- ): string;
450
- ```
451
-
452
- Single source of truth for "what to call this node in a panel." For `<text>` nodes the label is the (collapsed, truncated) text content; for everything else it's `"tag #id"` when the `id` attribute is present and just `"tag"` otherwise. The optional `tagLabel` resolver lets you substitute friendlier or localized terms ("rect" → "Rectangle") without losing the structural rule. Consumers should call this rather than synthesize their own.
453
-
454
- ### Observation — surface hover
455
-
456
- Out-of-canvas UI (layers panel, breadcrumbs) often wants to mirror the canvas's pointer hover. This is a transient channel — it does **not** bump `state.version`, so listeners don't get full snapshot re-renders.
457
-
458
- ```ts
459
- editor.surface_hover(): NodeId | null; // current effective hover (pointer or override)
460
- editor.set_surface_hover_override(id: NodeId | null): void; // push hover from out-of-canvas UI
461
- editor.subscribe_surface_hover(cb: () => void): Unsubscribe;
462
- ```
463
-
464
- When a layers-panel row is hovered, call `set_surface_hover_override(id)` to push the highlight into the HUD (drives measurement guides, outline, etc.). Pass `null` to release and let the pointer pick take over again. The subscribe channel fires for both pointer-driven and override-driven changes.
499
+ Returns a shallow snapshot. Cheap to call after a `version` bump.
465
500
 
466
501
  ### Modes
467
502
 
468
503
  Modes are the editor's internal state machine for "what does a click do." Consumers observe `state.mode`, flip it via commands, but cannot define new modes.
469
504
 
470
505
  ```ts
471
- editor.modes: ReadonlyArray<Mode>; // frozen after construction
472
- // v1: ["select", "edit-content"]
506
+ editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction
507
+ // e.g. ["select", "insert-rect", "insert-ellipse", "insert-line", "insert-text", "edit-content"]
473
508
 
474
509
  editor.commands.set_mode(mode: Mode): void;
475
510
  ```
476
511
 
477
- Adding new modes (insertion tools, etc.) requires a PR to this package; per the anti-goals, this is not a vector authoring tool.
512
+ When a mode-driven gesture completes (rect drawn, text inserted), the editor returns to `select` automatically. Modifier keys can override this (Shift to stay in insert mode); that behavior is bundled, not customizable.
478
513
 
479
514
  ### Commands
480
515
 
481
- The full closed set for v1. Adding a command requires a PR.
516
+ The full closed set. Adding a command requires a PR to this package.
482
517
 
483
518
  ```ts
484
519
  editor.commands.{
@@ -488,8 +523,10 @@ editor.commands.{
488
523
  enter_scope(group: NodeId): void;
489
524
  exit_scope(): void;
490
525
 
491
- // mode
526
+ // mode + tool
492
527
  set_mode(mode: Mode): void;
528
+ // `set_tool` is also accessible as `editor.set_tool(...)`; the command form
529
+ // is provided so keymap bindings (V/R/O/L) can dispatch via the registry.
493
530
 
494
531
  // generic property (any SVG/CSS attribute)
495
532
  set_property(name: string, value: string | null): void;
@@ -504,20 +541,38 @@ editor.commands.{
504
541
  opts?: { reuse_existing?: boolean },
505
542
  ): { gradient_id: string };
506
543
 
507
- // transforms
544
+ // transforms (atomic — the bundled HUD drives drag-resize-rotate internally)
508
545
  translate(delta: { dx: number; dy: number }): void;
546
+ nudge(direction: "left" | "right" | "up" | "down", step?: number): void;
547
+ resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void;
548
+ resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
549
+ rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
550
+ rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
551
+ flatten_transform(): void; // bake `transform=` into native attrs where possible
552
+
553
+ // alignment (operates on selection of ≥2 nodes against their union bbox)
554
+ align(direction: AlignDirection): void;
509
555
 
510
556
  // structure
511
557
  reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
558
+ group(): void; // wrap selection in a new <g>
512
559
  remove(): void;
513
560
 
561
+ // insertion
562
+ insert(tag: InsertableTag, attrs?: Readonly<Record<string, string>>): NodeId;
563
+ insert_preview(tag: InsertableTag, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
564
+
514
565
  // content
515
566
  set_text(value: string): void;
567
+ enter_content_edit(target?: NodeId): boolean;
516
568
 
517
569
  // file
518
570
  load_svg(svg: string): void;
519
571
  serialize_svg(): string;
520
572
 
573
+ // cleanup — never silent, never automatic
574
+ tidy(opts?: TidyOptions): void;
575
+
521
576
  // history
522
577
  undo(): void;
523
578
  redo(): void;
@@ -526,19 +581,7 @@ editor.commands.{
526
581
 
527
582
  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
583
 
529
- **`translate` is a single-step displacement command.** Resize gestures are HUD-driven via a preview session (not via a top-level command there is no `commands.resize` at v1). Rotate, tidy, and multi-selection aggregation are [Deferred for v1](#deferred-for-v1).
530
-
531
- #### Content edit
532
-
533
- ```ts
534
- editor.enter_content_edit(target?: NodeId): boolean;
535
- ```
536
-
537
- Surface-dependent. With no DOM surface attached, returns `false` and does nothing — the headless editor cannot mount a `<textarea>` / DOM input. With a DOM surface attached, mounts an in-place text editor on the target `<text>` node.
538
-
539
- #### Naming convention
540
-
541
- Public API uses `snake_case` throughout (`set_property`, `node_paint`, `subscribe_with_selector`). User-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them (`set_property("stroke-width", …)`). The exceptions are `FileIOProvider.openSvg` / `saveSvg` and `FontResolver.resolve(...).metrics.unitsPerEm` — these match CSS Font-Metrics / file-API ergonomic naming and stay camelCase.
584
+ (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
585
 
543
586
  ### Providers
544
587
 
@@ -563,8 +606,6 @@ type FileIOProvider = {
563
606
  };
564
607
  ```
565
608
 
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
609
  ### Style
569
610
 
570
611
  `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 +618,18 @@ type EditorStyle = {
577
618
  handle_stroke: string;
578
619
  endpoint_dot_radius: number;
579
620
  selection_outline_width: number;
580
- measurement_color: string; // measurement guide lines + numeric pills
621
+ // ...
581
622
  };
582
623
 
583
624
  editor.style: Readonly<EditorStyle>;
584
625
  editor.set_style(partial: Partial<EditorStyle>): void;
585
626
  ```
586
627
 
587
- `set_style` is the legitimate runtime path — wire it up for theme switching.
588
-
589
628
  ### React API (thin wrapper)
590
629
 
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.
630
+ 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.
631
+
632
+ #### Core (the primitives)
592
633
 
593
634
  ```tsx
594
635
  import {
@@ -600,14 +641,43 @@ import {
600
641
  } from "@grida/svg-editor/react";
601
642
  ```
602
643
 
603
- That's the whole public surface.
604
-
605
644
  - `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.
645
+ - `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
646
  - `useSvgEditor()` — returns the editor instance from context.
608
647
  - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
609
648
  - `useCommands()` — sugar for `useSvgEditor().commands`.
610
649
 
650
+ #### Bundled hooks (state-slice convenience + lifecycle-aware sessions)
651
+
652
+ 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.
653
+
654
+ ```tsx
655
+ import {
656
+ // state slices (one-line wrappers over useEditorState)
657
+ useSelection, // → readonly NodeId[]
658
+ useTool, // → Tool
659
+ useMode, // → Mode
660
+ useCanUndo, // → boolean
661
+ useCanRedo, // → boolean
662
+
663
+ // lifecycle-aware preview sessions — unmount = discard (never commit)
664
+ usePaintPreview, // (channel) → PaintPreviewSession
665
+ usePropertyPreview, // (name) → PreviewSession
666
+
667
+ // bound imperative actions, stable identity across renders
668
+ useEditorLoad, // → (svg: string) => void
669
+ useEditorSerialize, // → () => string
670
+
671
+ // RAII hover override (clears on unmount if this hook set the override)
672
+ useHoverOverride, // → (id: NodeId | null) => void
673
+
674
+ // camera bridge (subscribe to a slice of handle.camera without bumping state.version)
675
+ useCameraSnapshot, // (handle, selector, fallback) → T
676
+ } from "@grida/svg-editor/react";
677
+ ```
678
+
679
+ 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.
680
+
611
681
  Top-level wiring:
612
682
 
613
683
  ```tsx
@@ -642,8 +712,14 @@ function Toolbar() {
642
712
 
643
713
  </ToolButton>
644
714
  <ToolButton
645
- active={mode === "edit-content"}
646
- onClick={() => cmd.set_mode("edit-content")}
715
+ active={mode === "insert-rect"}
716
+ onClick={() => cmd.set_mode("insert-rect")}
717
+ >
718
+
719
+ </ToolButton>
720
+ <ToolButton
721
+ active={mode === "insert-text"}
722
+ onClick={() => cmd.set_mode("insert-text")}
647
723
  >
648
724
  T
649
725
  </ToolButton>
@@ -684,7 +760,7 @@ function PropertyPanel() {
684
760
  const selection = useEditorState((s) => s.selection);
685
761
  const cmd = useCommands();
686
762
 
687
- // v1: single-selection path. Multi-selection aggregation is deferred.
763
+ // v0: single-selection path. Multi-selection arrives with the mixed-values layer.
688
764
  if (selection.length !== 1)
689
765
  return <MultiSelectionPlaceholder count={selection.length} />;
690
766
  const id = selection[0];
@@ -696,7 +772,8 @@ function PropertyPanel() {
696
772
  return (
697
773
  <>
698
774
  {/* PaintInput is consumer-built. `provenance` tells the user whether this
699
- value came from an attribute, inline style, was inherited, or defaulted. */}
775
+ value came from an attribute, inline style, a stylesheet rule, or was
776
+ inherited / defaulted. */}
700
777
  <PaintInput
701
778
  label="Fill"
702
779
  declared={fill.declared}
@@ -720,111 +797,33 @@ function PropertyPanel() {
720
797
  }
721
798
  ```
722
799
 
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.
800
+ 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
801
 
725
802
  What this means in practice:
726
803
 
727
- - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.gradients.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
804
+ - The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.*.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
728
805
  - A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
729
806
  - Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
730
807
 
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
808
  ## Anti-goals
808
809
 
809
810
  What this editor will never be. Each one is a defensive perimeter for the principles above.
810
811
 
811
812
  - **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
812
813
  - **Not an animation editor.** SMIL is preserved verbatim, never authored or mutated.
813
- - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. The keymap and the command-id registry are host-controlled seams (P2: bindings are a host concern) — see [Low-level API](#low-level-api). (P1, P6.)
814
+ - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
814
815
  - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
815
816
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
816
- - **Not a private IR.** SVG is the source of truth; there is no canonical Grida representation behind it.
817
+ - **Not a 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
818
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
818
819
 
819
820
  If a consumer needs any of the above, the right answer is "this is the wrong tool." Saying yes to any one is the path that turned the Grida main editor into a 6,800-line god-class.
820
821
 
821
822
  ## Status
822
823
 
823
- `v1.0.0-alpha.1` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts.
824
-
825
- Round-trip fidelity (P1) is the design invariant; the v1 surface is deliberately minimal and the [Low-level API](#low-level-api) is the escape hatch when the high-level API doesn't yet cover what you need.
824
+ - `v0.0.0` — selection only, no mutation.
826
825
 
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.
826
+ The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable. Do not depend on it from production code.
828
827
 
829
828
  ## License
830
829