From 656f15c55854f7ba238c3a1525f54422c7dd2921 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 20 May 2026 18:12:36 +0900 Subject: [PATCH 1/3] docs(svg-editor): describe element IR in package README Placeholder commit for feature/svg-editor draft PR. The bulk of the SVG editor + /svg page + AI agent work stays unpushed locally. --- packages/grida-svg-editor/README.md | 102 ++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/grida-svg-editor/README.md b/packages/grida-svg-editor/README.md index 74a034bd3..b4c7e9c92 100644 --- a/packages/grida-svg-editor/README.md +++ b/packages/grida-svg-editor/README.md @@ -45,6 +45,14 @@ The defaults are inverted from how most editor SDKs grow. Default is **core, not 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. +### Element IR (internal) + +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. + +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. + +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`. + ## Principles 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. @@ -95,6 +103,8 @@ These are the design principles guiding the implementation. - 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. - 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. +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. + ## Examples A few scenarios this is designed to handle well. @@ -162,9 +172,9 @@ The editor core is **headless**. It parses the SVG, owns the document IR, accept 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. ```ts -import { domSurface } from "@grida/svg-editor/dom"; +import { attach_dom_surface } from "@grida/svg-editor/dom"; -const handle = editor.attach(domSurface(container)); +const handle = attach_dom_surface(editor, { container }); // later: handle.detach(); ``` @@ -179,9 +189,6 @@ interface Surface { // surface → editor: hit-test on screen pixel hit_test(x: number, y: number): NodeId | null; - // geometry queries (bbox, screen ↔ local projection) - readonly geometry: SurfaceGeometry; - // surface → editor: subscribe to normalized input on_input(listener: (event: SurfaceInputEvent) => void): Unsubscribe; @@ -189,7 +196,11 @@ interface Surface { } ``` -`@grida/svg-editor/dom` exports `domSurface(container: HTMLElement, opts?)` as the default DOM implementation. 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. +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. + +`@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. + +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. ### Lifecycle @@ -215,21 +226,27 @@ editor.reset(): void; // back to last load() input, clears history editor.state: { readonly selection: ReadonlyArray; readonly scope: NodeId | null; // active isolation (group entered via dblclick) - readonly mode: Mode; // "select" | "insert-rect" | "edit-content" | ... + readonly mode: Mode; // "select" | "edit-content" + readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode readonly dirty: boolean; // unsaved changes since load() / serialize() - readonly canUndo: boolean; - readonly canRedo: boolean; - readonly version: number; // monotonically increments on any mutation + readonly can_undo: boolean; + readonly can_redo: boolean; + readonly version: number; // bumps on any emission — drag, history, mutation + readonly structure_version: number; // bumps only when tree shape or display-label inputs change + readonly geometry_version: number; // bumps only when something that could shift world bounds changes + readonly load_version: number; // bumps once per `editor.load()` call (constructor doesn't count) }; editor.subscribe(fn: (state: EditorState) => void): Unsubscribe; -editor.subscribeWithSelector( +editor.subscribe_with_selector( selector: (state: EditorState) => T, fn: (value: T, prev: T) => void, equals?: (a: T, b: T) => boolean, ): Unsubscribe; ``` +`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. + `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. ### Observation — properties @@ -442,13 +459,13 @@ const id = editor.defs.gradients.upsert({ { offset: 1, color: "#7fb8e0" }, ], }); -editor.commands.setPaint("fill", { kind: "ref", id }); +editor.commands.set_paint("fill", { kind: "ref", id }); ``` For the very common "set fill from picker that just produced a gradient" path, a sugar command exists: ```ts -editor.commands.setPaintFromGradient( +editor.commands.set_paint_from_gradient( channel: "fill" | "stroke", definition: GradientDefinition, opts?: { reuse_existing?: boolean }, // dedupe by definition equality @@ -489,7 +506,7 @@ Modes are the editor's internal state machine for "what does a click do." Consum editor.modes: ReadonlyArray; // discoverable, frozen after construction // e.g. ["select", "insert-rect", "insert-ellipse", "insert-line", "insert-text", "edit-content"] -editor.commands.setMode(mode: Mode): void; +editor.commands.set_mode(mode: Mode): void; ``` 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. @@ -506,8 +523,10 @@ editor.commands.{ enter_scope(group: NodeId): void; exit_scope(): void; - // mode + // mode + tool set_mode(mode: Mode): void; + // `set_tool` is also accessible as `editor.set_tool(...)`; the command form + // is provided so keymap bindings (V/R/O/L) can dispatch via the registry. // generic property (any SVG/CSS attribute) set_property(name: string, value: string | null): void; @@ -524,13 +543,25 @@ editor.commands.{ // transforms (atomic — the bundled HUD drives drag-resize-rotate internally) translate(delta: { dx: number; dy: number }): void; + nudge(direction: "left" | "right" | "up" | "down", step?: number): void; resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void; + resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void; rotate(args: { angle: number; pivot?: { x: number; y: number } }): void; + rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void; + flatten_transform(): void; // bake `transform=` into native attrs where possible + + // alignment (operates on selection of ≥2 nodes against their union bbox) + align(direction: AlignDirection): void; // structure reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void; + group(): void; // wrap selection in a new remove(): void; + // insertion + insert(tag: InsertableTag, attrs?: Readonly>): NodeId; + insert_preview(tag: InsertableTag, initial?: Readonly>): InsertPreviewSession; + // content set_text(value: string): void; enter_content_edit(target?: NodeId): boolean; @@ -596,7 +627,9 @@ editor.set_style(partial: Partial): void; ### React API (thin wrapper) -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. +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. + +#### Core (the primitives) ```tsx import { @@ -608,14 +641,43 @@ import { } from "@grida/svg-editor/react"; ``` -That's the whole public surface. - - `SvgEditorProvider` — owns the headless editor, puts it in context. -- `SvgEditorCanvas` — the only UI component we ship; internally calls `editor.attach(domSurface(div))` on mount and `detach()` on unmount. +- `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. - `useSvgEditor()` — returns the editor instance from context. - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive. - `useCommands()` — sugar for `useSvgEditor().commands`. +#### Bundled hooks (state-slice convenience + lifecycle-aware sessions) + +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. + +```tsx +import { + // state slices (one-line wrappers over useEditorState) + useSelection, // → readonly NodeId[] + useTool, // → Tool + useMode, // → Mode + useCanUndo, // → boolean + useCanRedo, // → boolean + + // lifecycle-aware preview sessions — unmount = discard (never commit) + usePaintPreview, // (channel) → PaintPreviewSession + usePropertyPreview, // (name) → PreviewSession + + // bound imperative actions, stable identity across renders + useEditorLoad, // → (svg: string) => void + useEditorSerialize, // → () => string + + // RAII hover override (clears on unmount if this hook set the override) + useHoverOverride, // → (id: NodeId | null) => void + + // camera bridge (subscribe to a slice of handle.camera without bumping state.version) + useCameraSnapshot, // (handle, selector, fallback) → T +} from "@grida/svg-editor/react"; +``` + +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. + Top-level wiring: ```tsx @@ -752,7 +814,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.) - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem. - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components. -- **Not a private IR.** SVG is the source of truth; there is no canonical Grida representation behind it. +- **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.) - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter. 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. From 7c074899ed8f42b176588ef3e8752af3271940e8 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 22 May 2026 23:20:35 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(svg-editor):=20day-1=20SDK=20design=20?= =?UTF-8?q?=E2=80=94=20refs,=20WG=20docs,=20sdk-*=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the design substrate for the @grida/svg-editor SDK ahead of the implementation slice. Day-1 SDK design is too load-bearing to bundle with code that would rewrite itself against an evolving design; the implementation slice (package + /svg demo + AI agent + slides) lands on top in a follow-up PR. - sdk-design / sdk-seam skills (cross-cutting; not svg-specific). - SVG spec reference under docs/reference/svg/ (element-model, transform-and-frame) — used by both the editor SDK and the Rust SVG importer. - feat-svg-editor WG directory: index, element-ir (typed IR proposal), svg-editor-intent-matrix (current-state inventory), hit-test (v2 architecture), feedback-transform (transform- pipeline critique), glossary/policy-class (the "Policy Class" defined term). - Comparative research: research/usvg-tree-notes. - Selection-intent extension: Tier-1 sub-selection scenarios (vertex / tangent / segment) and the absolute-gesture click-no-drag invariant. Docs that critique in-flight implementation carry a header disclaimer that the cited source paths describe a forthcoming slice not yet on main. Status: pre-implementation; design under review. --- .agents/skills/sdk-design/SKILL.md | 284 +++++ .agents/skills/sdk-seam/SKILL.md | 349 +++++++ CONTRIBUTING.md | 4 + docs/reference/svg/element-model.md | 735 +++++++++++++ docs/reference/svg/transform-and-frame.md | 355 +++++++ .../ux-surface/selection-intent.md | 81 ++ docs/wg/feat-svg-editor/_category_.json | 3 + docs/wg/feat-svg-editor/element-ir.md | 973 ++++++++++++++++++ docs/wg/feat-svg-editor/feedback-transform.md | 396 +++++++ .../feat-svg-editor/glossary/policy-class.md | 714 +++++++++++++ docs/wg/feat-svg-editor/hit-test.md | 440 ++++++++ docs/wg/feat-svg-editor/index.md | 87 ++ .../svg-editor-intent-matrix.md | 468 +++++++++ docs/wg/research/usvg-tree-notes.md | 186 ++++ packages/grida-svg-editor/README.md | 10 +- 15 files changed, 5084 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/sdk-design/SKILL.md create mode 100644 .agents/skills/sdk-seam/SKILL.md create mode 100644 docs/reference/svg/element-model.md create mode 100644 docs/reference/svg/transform-and-frame.md create mode 100644 docs/wg/feat-svg-editor/_category_.json create mode 100644 docs/wg/feat-svg-editor/element-ir.md create mode 100644 docs/wg/feat-svg-editor/feedback-transform.md create mode 100644 docs/wg/feat-svg-editor/glossary/policy-class.md create mode 100644 docs/wg/feat-svg-editor/hit-test.md create mode 100644 docs/wg/feat-svg-editor/index.md create mode 100644 docs/wg/feat-svg-editor/svg-editor-intent-matrix.md create mode 100644 docs/wg/research/usvg-tree-notes.md diff --git a/.agents/skills/sdk-design/SKILL.md b/.agents/skills/sdk-design/SKILL.md new file mode 100644 index 000000000..d6c7dc642 --- /dev/null +++ b/.agents/skills/sdk-design/SKILL.md @@ -0,0 +1,284 @@ +--- +name: sdk-design +description: > + Doctrine for designing and evolving any **SDK** Grida ships — + TypeScript, Rust, or otherwise. "SDK" here means a surface that + crosses a foreign-or-foreign-treated boundary: published packages, + separately-versioned consumers, FFI bindings, public-by-design + modules. An SDK's job is to refuse; a strict, honest surface + rejects the wrong contents and keeps the package testable in + isolation. Default is "core, not customizable"; customization is + the exception, defended by a deciding table. Use when authoring or + evolving any such surface — `@grida/*` published packages, + `crates/*` published or FFI-exported, intent/message vocabularies, + any contract a second author will compile against. Internal-only + helper packages are welcome to follow, not forced. Companion skill + for two-sided contract work: $sdk-seam. Critique partners: + $pedantic, $etiology. Related: $naming. +--- + +# sdk-design + +This is **not** a style guide. Style and language-specific code +shape are downstream (see $code-ts, $code-react for the TS sides). +This is about what an SDK refuses to do — the discipline that keeps +a package small, legible, and replaceable, regardless of language. + +## The thesis + +An SDK lives or dies by what it refuses to expose. **Default is +core; customization is the exception.** Every public knob is a +contract you cannot retract without a semver break and a coordinated +migration across every downstream call site. + +A library with too few knobs is easy to grow. A library with too +many is impossible to retire. The asymmetry is brutal — design from +it. + +This doctrine applies whether the package ships as an npm scope, a +crate, a header-only library, a WASM module, a Python wheel, a +hosted service with an SDK, or a pair of microservices defining a +shared message vocabulary. The mechanics of "publish" differ; the +discipline of "refuse the wrong contents" does not. + +## Scope: what counts as an SDK + +This is the gate. The skill says "SDK," not "package," because the +two are different. An SDK is: + +- **A surface that crosses a foreign-or-foreign-treated boundary.** Published to a registry (`npm`, `crates.io`, `PyPI`); linked by a separately-versioned consumer (a desktop binary against a crate, a generated WASM/FFI binding); or authored as if a foreign consumer existed even if one doesn't yet (any package whose README documents it as a public surface, anything tagged for publication, anything in a `*-hosted` suffix family). +- **Versioned independently of its callers**, even if today every caller lives in the same monorepo and ships on the same commit. The intent to be replaceable is what counts. + +What this excludes — where the doctrine is **welcome but not +load-bearing**: + +- A package with exactly one internal caller, shipping on the same commit, where if the caller's needs changed the package would be rewritten freely. That's not an SDK; that's a refactored module that happens to live in `packages/`. Adopt the parts of this skill that pay; skip the rest without apology. +- One-off helper crates pulled in by a single binary in `crates/`. Same logic. + +Don't extend the doctrine to internal-only utilities just because +the file structure looks like an SDK. The discipline costs +something — designed views over raw streams, anti-goals as +perimeters, promotion-on-dogfooding — and that cost is paid by the +foreign-callers it protects. If there are no foreign callers (now +or planned), the strictness doesn't pay back. + +`sdk-seam` triggers on the same gate from the other angle: any +boundary that meets the SDK bar above, where the same author +writes both sides. Include FFI bindings to internal crates here — +binding regeneration cost makes the boundary foreign-treated even +when the crate is same-repo. + +## The deciding table + +When a new decision lands — "should this be a provider hook? a +built-in toggle? a sibling package? a public type or an internal +seam?" — walk these in order. **First match wins.** + +| Question | If yes → | Why | +| ----------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------- | +| Would customization let a consumer break the invariant this package exists to protect? | **Core**, non-customizable | Sovereignty | +| Is this genuinely a host-owned concern (I/O, locale, surface, credentials, clock)? | **Provider** at construction | Host knows what you can't | +| Is this per-variant edit/parse/render semantics, complex but bounded by a spec or schema? | **Internal seam**, no public API | Code organization, not API | +| Does the candidate have ≥2 internal consumers AND can be tested without mounting the SDK? | **Separate layer** (own module/package) | Earned its separation | +| Have ≥2 internal consumers shaped the contract already? | **Eligible for public** | Public only after dogfooding | +| Otherwise | **Core, internally modular** | Default-in, not default-out | + +The third rung — "complex but internal" — is where most +"extension-point" mistakes get caught. A real spec or schema (SVG +element table, MIDI event types, OpenType tables, USB device +classes) is the registry; the SDK implements against it. Don't +re-invite the spec to be re-implemented at runtime by consumers. + +## Five disciplines + +### D1. Subscribe to outcomes, not events + +The public observation surface is **designed**, not raw. It exposes +purpose-built views — selection, mode, dirty/version, computed +property — each handling multi-target, capability variance, and +bookkeeping internally. Consumers never receive raw input events, +reducer actions, or internal state frames. + +If a needed view doesn't exist, that's an **API gap to close, not +an internals hatch to open.** Exposing the internal stream because +"the consumer can compose it themselves" is how you wake up six +months later unable to refactor the core. + +The same rule applies to the other direction: emit named outcomes +(intents, commands, requests), not "the user moved their pointer." +If your outputs carry phase markers (`preview` / `commit`, `begin` / +`progress` / `end`), the consumer wraps history/transactions without +guessing internal state. + +### D2. Pure-logic core, thin adapter shell + +One-directional dependency, layered: + +``` +primitives / math ← logic core ← adapter shell ← host +``` + +The math/logic core has no I/O, no DOM, no canvas, no UI runtime, +no global clock. Plain function over plain inputs returns plain +output. **Runnable under the language's basic test runner with zero +mocks.** A Rust crate's core compiles under `no_std` where feasible; +a TS package's core has no `window` / `document` import; a Python +package's core does not touch the filesystem. + +The shell is a thin wire: lifecycle, draw loop, host wiring. Its +own logic should be trivial enough to verify by inspection, because +**it's the part you can't test headlessly.** + +Why this matters: when a shell grows logic, that logic ships +unguarded. Common failure: the shell holds a switch (`render`, +`dispatch`, `route`) and a new core variant is added without +updating the switch — the core's tests pass; the shell silently +drops the variant; downstreams hit it in production. **Push logic +into the core. Tests follow.** + +### D3. Outputs that satisfy different constraints stay separate + +Don't conflate outputs that exist to satisfy different constraints. +Every paired-but-asymmetric surface — render vs. hit-test, read vs. +write, declared vs. computed, preview vs. commit, encode vs. decode +— earns its asymmetry from a real disagreement in requirements. +When you find yourself unifying them "for elegance," you're about +to break one. + +Concrete pattern: a UI surface that draws and hit-tests as two +separate outputs. Drawing optimizes for legibility at any zoom; +hit-testing optimizes for Fitts'-reach (fat targets, virtual regions +that extend past the visible shape). Collapsing them — sizing the +visual to match the hit AABB, or shrinking the hit region to match +the visual — breaks one of the two; each side has to compromise to +satisfy the other. + +The generalization: tests assert each side separately, and — where +they intentionally differ — assert the direction of difference +(e.g., the hit region strictly contains the rendered bbox). + +### D4. Anti-goals as defensive perimeters + +Every published SDK ships an explicit **Anti-goals** section in its +README. It is not aspirational; it is the perimeter that lets the +package stay small. Examples that have already prevented bloat +across various Grida packages: + +- "Not a host of plugins." — kills every PR that wants to add a widget registry. +- "Not undo-aware." — host owns history; SDK emits phase markers. +- "Not a private IR." — file bytes are the source of truth; the parsed view is rebuilt on load. +- "Not a renderer." — the surface backend is intentionally minimal. + +When a feature request arrives, **the first question is which +anti-goal it would violate.** If it violates one, the right answer +is "this is the wrong tool." If it threatens one without crossing +it, write the anti-goal sharper. + +Adding an anti-goal is the cheapest design work an SDK author does. + +### D5. Names commit you + +See $naming for the full treatment. The SDK-specific corollary: + +- **Public identifier costs ≫ directory cost.** Directory rename is `git mv`; published-name rename is a coordinated downstream migration. Invest heavily before a name escapes its file. +- **Terseness is a uniqueness claim.** A bare `Surface`, `Encoder`, `Intent`, `Paint` in a package asserts "nothing else competes for this slot here." If a peer could be added later, qualify now. +- **Suffix siblings over nested folders.** Keep the parent's scope tight; new subdirectories quietly widen it. +- **Avoid leaking consumer concerns into the producer's names.** A type field documented as "used by `` for ``" is leaking the consumer's problem into the contract. The field name should justify itself in producer-only terms. + +## The promotion contract + +Internal seams stay internal until **≥2 internal consumers** have +shaped the contract. This is not a bureaucratic gate — it's the +only way to avoid public APIs designed against one use case. + +Promoting too early produces: + +- The shape ossifies around the first caller's quirks. +- The second caller can't use it and writes a parallel API. +- Now you have two surfaces that drift, and you can't kill either. + +Promoting too late costs little. Internal callers reach into +internals; you tighten when the second consumer arrives. **Default +direction of pressure is inward, not outward.** + +When you do promote, the contract test is: "could a stranger build +the next caller against this API alone, without reading the SDK's +source?" If no, it's not promoted; it's exposed. + +For very new packages without a second internal consumer yet, the +honest move is to mark the surface as unstable in its README +("v0.x.y — no compatibility guarantees") and let the second +consumer's needs shape the contract before locking it. + +## Three extension paths, in order of preference + +For any extensibility request, walk this ladder. **Reach down only +when the rung above doesn't fit.** + +1. **Named built-in.** Things every consumer of this SDK will want + live inside the package as first-class features with their own + toggles. New canonical needs land here; open a PR against the SDK. +2. **Host-fed extras.** Transient, host-computed inputs/outputs passed + through a designed slot (per-frame draw, per-event hook, per-message + middleware). Best for things the host already computes and just + wants threaded through. +3. **Escape hatch.** The host owns some boundary (container element, + raw socket, file descriptor) and can splice its own logic in + around the SDK. **Deliberate escape hatch — reach for it only + when (1) and (2) don't fit, and prefer pushing canonical needs + into (1) over keeping them at (3).** + +What's absent from this ladder is a generic plugin / widget / +middleware registry. That's the point. A registry is the path that +turns small packages into god-classes — the lesson is repeated +across the industry (jQuery plugins, Babel plugins of the early era, +Webpack loaders) and locally (the Grida main editor's 6,800-line +god-class grew partly from this). + +## Tests are spec + +For an SDK, tests carry double weight: + +- The SDK is configurable — hosts pass styles, providers, callbacks. Small refactors land all the time. +- There's no visible behavior to inspect from outside the package; one regression can ship silently across every downstream. + +**Discipline: every default behavior is locked by a test whose +description names the behavior in plain language.** The test name +is the spec. The body proves the code obeys it. A comment above +explains _why_ — the design intent that the code itself can't carry. + +Where applicable, embed scenario names verbatim in test text so +"did we drop a rule?" is grep-able across implementations and +ports. This matters most for SDKs that ship parallel implementations +(TS + Rust + WASM bindings) of the same contract. + +A PR that touches a public behavior without touching the matching +test is a smell; a PR that flips a test's assertion without +changing the test name is a near-certain regression. + +## Critique partners + +- **`$pedantic`** — before drafting a public API, run the design through pedantic. The probes for unfalsifiability, vague quantifiers, and assumed-bedrock catch the "this feels finished but isn't grounded" failure mode that produces APIs you can't retract. +- **`$etiology`** — before patching across an SDK boundary, walk the diagnostic ladder. Most "quick fixes" at a boundary are API-contract bugs (rung 3), not call-site bugs (rung 2). Treating one as the other is how contracts rot. + +## Cross-package work — the seam + +Work that touches more than one SDK — your producer and its +consumer, two sibling packages, a published surface and its tests, +two crates on either side of an FFI boundary — has a specific +failure mode of its own: when you control both sides, you shotgun +changes across them in a single edit and the contract silently +degrades. The joint between the two sides is a **seam**; keeping +seams clean has its own discipline. See **`$sdk-seam`**. + +## The short version + +- Default is **core, not customizable.** Customization is the exception, defended by the deciding table. +- **Subscribe to outcomes, not events.** Designed views, not raw streams. Missing view = API gap, not internals hatch. +- **Pure core, thin shell.** The logic is testable headlessly; the shell is boring on purpose. Logic in the shell is logic you can't defend. +- **Asymmetric outputs stay separate.** Render vs. hit, read vs. write, declared vs. computed — different constraints earn different surfaces. +- **Anti-goals are defensive perimeters.** Every SDK ships them. Sharpen them before adding features. +- **Promote on dogfooding.** ≥2 internal consumers shape the contract before it escapes the package. +- **Three extension paths, in order: built-in → host-fed extra → escape hatch.** Generic plugin registries are the road to god-classes. +- **Tests are spec.** Every default behavior pinned by a test whose name is the rule and whose comment is the why. +- **The seam between two SDKs has its own discipline** — see `$sdk-seam`. diff --git a/.agents/skills/sdk-seam/SKILL.md b/.agents/skills/sdk-seam/SKILL.md new file mode 100644 index 000000000..c5d40851f --- /dev/null +++ b/.agents/skills/sdk-seam/SKILL.md @@ -0,0 +1,349 @@ +--- +name: sdk-seam +description: > + Discipline for the seam between two SDKs (or two sides of one + contract) that the same hand writes. The failure mode: "we own + both sides" produces dirty contracts no foreign reviewer would + accept. The exercise: pretend the other side is FFI, IPC, or a + network protocol you cannot rewrite. Spawn an adversarial subagent + profiled as the producer's maintainer; negotiate the change as a + feature request, not a PR. Companion to $sdk-design. + Language-agnostic — applies to a TS package + its consumer, a Rust + crate + its WASM binding, two services sharing a wire format, or + any other boundary the same author writes both ends of. +--- + +# sdk-seam + +> Companion to [`sdk-design`](../sdk-design/SKILL.md). Read that +> first; its deciding table and disciplines are the foundation this +> skill builds on. Where `sdk-design` is about a single SDK's +> surface, this skill is about the **seam** — the joint between two +> SDKs (or two sides of one contract) — and what it takes to keep +> that joint clean when the same author writes both sides. + +## The failure mode + +When you control both sides of a boundary, you produce dirty +contracts — **simply because you can.** A field gets added on the +producer side because the consumer needs it; the consumer reaches +into the producer's internal shape because no public view exposes +the slice; a one-off helper crosses the boundary because "we're +going to refactor it later." Six months later the contract is +unrecoverable, and the two sides can only be deployed together. + +The honest test: **if we couldn't shotgun-edit both sides at once, +this design wouldn't be possible.** That's not a virtue. It's a +warning. + +If the contract had been an IPC channel, an FFI ABI, a network +protocol, or a published-versus-consumed package boundary from the +start, the design would have been clean from the beginning, and it +would have evolved cleanly. Cross-boundary work inside one repo, +one workspace, or one mono-language project doesn't get that +discipline for free — you have to manufacture it. + +This applies to: + +- Two `packages/grida-*` packages where one consumes the other. +- A package and its host application (a `crates/*` crate + a binary that links it; an npm package + a Next.js app). +- A Rust crate and its WASM/FFI bindings (where one party can change generated code on the other side at will). +- Two services sharing a wire format you control on both ends. +- Two modules within one package that genuinely should be on opposite sides of a contract (because one is "core logic" and the other is "shell" — see $sdk-design D2). + +## The exercise + +Before editing, restate the work as if you only owned one side. + +For every change that crosses a seam, write down: + +1. **Which side initiates.** What concrete problem on side A forced you to look at side B? +2. **What contract change is being requested.** Phrase it as a feature request to side B's maintainer ("HUD: please add `down_doc` to the `translate_tangent` gesture so absolute-position commits can detect click-no-drag"). +3. **What side B's maintainer would push back on.** What's the alternative shape? What invariant of side B does the requested change threaten? What test would side B add to defend it? +4. **What ships in this PR vs. follow-up.** Often the answer is: side B's contract change ships first, locked by tests, then side A is updated against the new contract. + +If you can't fill in (3) credibly, you haven't designed the change +— you've just typed the diff. + +## The subagent move + +When the change is non-trivial, **delegate the work to a subagent +profiled as the producer's maintainer.** This is not a review +step; it is the actual implementation handoff. The subagent +defends, decides, AND ships the producer-side change. The main +agent never touches the producer's files. + +This is the mechanism that keeps the contract unopinionated and +agnostic: the subagent doesn't have your consumer-side context, so +it can't be tempted to "just add the field." It has to reason from +the producer's own invariants — its README, its tests, its +anti-goals — and respond as if it were any other foreign maintainer +fielding a feature request from any other consumer. + +### The flow + +``` +┌─────────────────────┐ ┌──────────────────────┐ +│ Main agent │ │ Subagent │ +│ (consumer side) │ │ ("you are the │ +│ │ │ maintainer of X") │ +│ 1. Writes │ FEEDBACKS.md → │ 3. Reads README + │ +│ FEEDBACKS.md │ │ FEEDBACKS.md │ +│ 2. Spawns subagent │ │ 4. Defends / accepts│ +│ │ │ / counter- │ +│ │ │ proposes │ +│ │ │ 5. Implements the │ +│ │ │ producer change │ +│ │ ← decision + │ 6. Writes producer │ +│ 7. Reads decision │ diff summary │ tests │ +│ 8. Updates │ │ 7. Returns │ +│ consumer side │ │ verdict + diff │ +│ against the │ │ │ +│ SHIPPED contract│ │ │ +└─────────────────────┘ └──────────────────────┘ +``` + +### Step 1 — write the FEEDBACKS.md (or equivalent artifact) + +The requester writes a self-contained feature request. Name it +whatever fits the workflow — `FEEDBACKS.md`, `REQUEST.md`, +`/_inbox/-.md`, a GitHub-style issue draft. +The format matters less than the contents. + +The artifact MUST contain: + +1. **The consumer's problem in producer-neutral terms.** What + _observable_ behavior is wrong or missing? State it without + reference to the consumer's internal architecture. +2. **The minimal contract change being requested.** A field, a + variant, a relaxation, a new method. Spell out the proposed + shape, but flag it as a _proposal_, not a directive. +3. **What invariants of the producer the requester thinks are at + risk.** Honest acknowledgment that the requester knows they're + asking for something that touches the producer's design — and + why they think it's worth it. +4. **What the requester has already considered and rejected.** + Alternative shapes, consumer-side workarounds, why they don't + fit. This is what keeps the subagent from suggesting the same + options back. +5. **What success looks like.** A test the producer could write + that, if it passes, satisfies the request. Phrased in + producer-only terms. + +The artifact MUST NOT contain: + +- Direct file edits or diffs for the producer side. The subagent decides those. +- Pressure phrasing ("we need this by EOD," "just add the field"). +- References to the consumer's internal types or call sites that wouldn't survive a refactor. + +### Step 2 — profile the subagent as the maintainer + +Spawn the subagent with a brief that names the producer and its +authoritative docs: + +> You are the maintainer of `[package/crate X]`. Your authoritative +> doctrine is `[path/to/X/README.md]` and `[any AGENTS.md, design +docs]`. Read those before responding to any feature request. +> +> A consumer has filed `[path/to/FEEDBACKS.md]`. Process it as +> you would any external feature request: +> +> 1. Read the FEEDBACKS.md and the producer's README/AGENTS.md. +> 2. Decide: **accept**, **counter-propose**, or **refuse**. +> - Accept: implement the requested shape, possibly with tightened naming or added invariants. +> - Counter-propose: implement an alternative shape that solves the same observable problem but fits the producer's design better. +> - Refuse: cite the anti-goal or invariant violated, propose how the consumer can absorb the problem differently. +> 3. If accepting or counter-proposing, **ship the change**: edit +> the producer's source, add producer-side tests that lock the +> new contract in producer-only terms (no naming the consumer), +> update the producer's README/doctrine if the rule generalizes. +> 4. Return a verdict (accept / counter / refuse), a one-paragraph +> rationale, and a summary of the diff (file paths + what +> changed). Do NOT touch consumer-side files. + +The subagent's tool access should be scoped to the producer's +files only — or, if that's not enforceable, the instruction must +be unambiguous. Consumer-side files are off-limits for this +subagent. + +### Step 3 — main agent reads the verdict, then updates the consumer + +The subagent returns one of three outcomes. The main agent's next +move depends on which: + +- **Accept / counter-propose with diff.** The producer change has + shipped (locally). The main agent reads the new public contract + — from the producer's exports, not from the subagent's + description — and updates the consumer against it. Producer + tests run; consumer tests run. +- **Refuse.** The main agent does not edit the producer to override + the refusal. Either absorb the problem on the consumer side per + the subagent's suggestion, or escalate (rewrite the FEEDBACKS.md + with better grounding, re-spawn). "I'll just edit both anyway" + is the failure mode this whole skill exists to prevent. + +### Why this is more than ceremony + +Three things only this flow gets right: + +1. **The producer side never sees the consumer's pressure.** The + subagent has no investment in shipping the consumer's PR — its + reward is a producer that stays clean. That asymmetry is what + foreign maintainers have for free and same-hand authors lose. +2. **The producer's README becomes load-bearing.** The subagent's + only authority is the producer's own doctrine. A vague README + produces a vague decision; a strict README produces a strict + one. This is real pressure to keep $sdk-design D4 (anti-goals) + and the README sharp. +3. **The producer's diff is small and self-contained.** The + subagent ships one change with one set of tests, written + without naming the consumer. The next consumer of the same + producer inherits the rule for free — see $sdk-design D1 and + the worked example below. + +### When NOT to use the subagent + +- **Pure consumer-side fix.** No producer change needed. Don't spawn. +- **Producer change is mechanical** (rename, doc-only, format) and obviously doesn't touch the contract. +- **You've already filed the same FEEDBACKS once and got a refusal.** Don't re-spawn to get a different answer. Either rewrite the request with new grounding or absorb on the consumer side. + +### Common subagent outcomes + +- **Accept.** The subagent implements the requested shape, possibly + with tightened naming (renames the field), added invariants + (JSDoc/rustdoc clauses, runtime guards), or a more conservative + default (the field is optional with a safe default, not + required). +- **Counter-propose.** The subagent implements an alternative — e.g. + "carry the data through the existing event stream instead of a + new struct field," "expose a derived computed view instead of + the raw state slice," "split the request into two narrower + methods." The main agent reads the counter, updates the consumer + against the actual shape. +- **Refuse.** The subagent rejects, points to the anti-goal it + violates (see $sdk-design D4), and proposes the consumer absorb + the problem differently. Cites the README section that grounds + the refusal. + +Each outcome produces a cleaner contract than "just add the field +because we control the file." + +## Four procedural patterns + +These are language-agnostic. They apply whether the boundary is a +TypeScript module export, a Rust trait, a FlatBuffers schema, a +JSON-RPC method, or a C ABI. + +### Stage 1 — contract first + +When the contract has to change, **change the contract first, in +its own commit (or its own logical unit of work).** Ship the +producer side with new tests against the new shape. Only after the +contract is locked do you update consumers against it. + +Anti-pattern: "I added the field and the consumer that needs it in +the same hunk." The producer-side test for the field is the +consumer's test by accident, and the contract isn't really +specified — it's just whatever the consumer happened to need. + +### Stage 2 — every contract change earns a test on the producer side + +Every new field, every new variant, every relaxation of an existing +shape — the producer adds a test that pins the new behavior in +producer-only terms. "Given input X, the API returns Y with field +Z set" — **without naming the consumer that asked for it.** + +A producer test that mentions only the consumer's use case is a +contract that breaks when the consumer leaves. The doctrine +generalizes the grep-contract idea from $sdk-design: scenario +names belong in test text, not consumer references. + +### Stage 3 — never reach across a boundary in the same edit window + +If you have two files open from two sides of a boundary and you're +editing them in tandem, **stop.** Either: + +- Land the producer change, run its tests, commit (or stage) — then move to the consumer. +- Or revert the consumer change and re-state it as a feature request to the producer. + +This is the boring procedural step that prevents the bad design. +The reason seams in foreign systems stay clean is that the deploy +boundary forces this sequencing. Manufacture the same +sequencing here by hand. + +For a Rust crate ↔ WASM binding, this means: change the crate's +public function signature, regenerate bindings as a separate step, +then update the binding's callers. Not all three in one edit. + +### Stage 4 — consumer side never reads producer internals + +A producer that exposes "subscribe to anything" or "raw state" +surfaces is one a consumer will inevitably reach into. +$sdk-design D1 ("Subscribe to outcomes, not events") is the +prevention; this subskill is the discipline when the prevention +hasn't fully landed yet. + +If the consumer wants something not in the public observation +surface, **the consumer files a feature request**, doesn't reach. +"There's no public view for [internal field X], so I'll just access +it via the internal property / via reflection / via `pub(crate)`" is +the moment a contract dies. + +## A worked example (illustrative) + +The session that produced this skill added a `down_doc` field to a +gesture struct on one side of a boundary to fix a click-no-drag +mutation on the other. The fix was correct, but the way it landed +was clean only because the procedural steps were followed: + +| Step | What happened | If it had gone wrong | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Diagnose | $etiology ladder: symptom is "control moves on bare press." Proximate: absolute-position commit writes pointer position even when pointer didn't move. **API contract:** absolute vs delta gesture asymmetry. | Skipping the ladder, we'd have added `if (dx === 0 && dy === 0)` in the consumer — bandaid that leaks. | +| Decide who owns the fix | Producer owns gesture state; the no-drag guard belongs in the producer's commit handler. Field is added to the producer's gesture struct, with doc explaining why it's distinct from existing fields. | We could have written the guard on the consumer side. The next consumer would re-trigger. | +| Lock the contract | New test on the producer side: click-no-drag does NOT emit the commit intent. **Producer-only — doesn't name the consumer.** | Test on the consumer only — producer could regress silently. | +| Update the doctrine | Spec amendment added a Conformance rule: "Absolute-gesture click-no-drag is mute." Future consumers (other applications of the same producer) inherit the rule for free. | Rule lives in someone's head; the next consumer re-discovers the bug. | + +The work that produced these clean outcomes was procedural. None of +it required new tooling. + +**The trap avoided** — and that this skill exists to prevent — was +the version where, because we controlled both files, we silently +muted the commit on the consumer side and moved on. That version +would have shipped, the test suite would have stayed green, and +the next consumer of the same producer would have re-hit the bug +with no breadcrumb back. + +## Smells that mean you're losing the discipline + +- The diff touches two sides of a boundary in the same hunk and the producer-side test references the consumer's call site by name. +- A producer field carries documentation that says "used by `` for ``" — the field is leaking the consumer's concern into the contract. +- The consumer imports a type or path from the producer that isn't in the producer's public entry (`index.*`, `lib.rs`'s `pub use`, the schema's `public` namespace, etc.). +- The producer's tests pass even when the consumer is broken (or vice versa) — i.e., the two sides have no independently verifiable invariants. +- You hesitate to ship the producer change without the consumer change because "it would be unused" — that's the contract-first sequencing speaking. +- The phrase "we control both sides" appears in your reasoning for skipping a step. + +Any one of these is a stop-and-reset. Two or more is a redesign +signal. + +## When the discipline gets relaxed (and why it's rare) + +- **Contract is genuinely private.** Two files in the same package with no public re-export. Treat as one boundary — but if you want one boundary in two files because "the file is too big," extract first. +- **Hot loop ships together always.** A producer + adapter released as a single unit with byte-equal version locks. Even then, contract-first sequencing pays — it's the only way the producer becomes reusable later. + +If neither applies, you're inside the boundary and the discipline +holds. + +## Critique partners + +- **`$pedantic`** — when defending a contract change, pedantic probes catch unfalsifiable rationale ("this field will be useful for future flexibility") and leaked-uncertainty ("we might need to extend this later" — quarantine and ship the minimum that's clear). +- **`$etiology`** — most cross-seam bandaids are API-contract bugs (rung 3 of the diagnostic ladder). The temptation to "just patch the consumer" almost always means the producer's contract is the real defect. + +## The short version + +- If we couldn't shotgun-edit both sides, this design wouldn't be possible — that's a warning, not a virtue. +- Restate the change as a feature request: which side initiates, what's the contract change, what would the other side's maintainer push back on? +- **Delegate the defense.** When non-trivial, spawn a subagent that owns one side and pushes back. Don't bypass it by editing both files in the same turn. +- **Contract first.** Ship the producer change with producer-side tests, lock it, then update consumers. +- **No producer test names the consumer.** No consumer reaches past the producer's public surface. +- **No edit-in-tandem across two sides of a boundary.** Sequence by hand what foreign deployment would sequence for free. +- Smells (cross-side hunks, doc naming consumers, hesitancy to ship the producer alone) are stop-and-reset signals. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c62d82e7c..d3e0ea34d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,10 @@ just build canvas wasm - [docker desktop](https://docker.com) required - for Grida-specific Supabase setup (migrations, env, signing keys), see `supabase/README.md`. +### Signing in locally + +After `supabase db reset --local` runs, `supabase/seed.sql` creates three test users you can sign in as via the `/sign-in` route. The default for normal flows is **`insider@grida.co` / `password`** (owner of the `local` org). See [`supabase/seed.md`](./supabase/seed.md) for the other personas (`alice@acme.com` for multi-tenant testing, `random@example.com` for no-org access checks). All three share the password `password`. + ## Support If you have any problem running the project locally or for any further information, please contact us via Slack. diff --git a/docs/reference/svg/element-model.md b/docs/reference/svg/element-model.md new file mode 100644 index 000000000..ec58dc093 --- /dev/null +++ b/docs/reference/svg/element-model.md @@ -0,0 +1,735 @@ +--- +title: "SVG Element Model — Geometry, Presentation, Frames, Round-Trip Hazards" +tags: + - internal + - research + - svg + - svg-editor +--- + +# SVG Element Model + +Spec-grounded reference for the SVG element surface a graphical editor IR must +expose. Scope: per-element geometry attributes, presentation attribute hooks, +local coordinate frames, the kinds of in-place mutations that preserve byte +round-trip, and cross-element constructs that resist editing. Citations link +to specific sections of [SVG 2](https://www.w3.org/TR/SVG2/), [SVG 1.1](https://www.w3.org/TR/SVG11/), +[CSS Cascade 5](https://www.w3.org/TR/css-cascade-5/), [Filter Effects](https://drafts.fxtf.org/filter-effects/), +and [CSS Masking](https://drafts.fxtf.org/css-masking/). + +## Conventions + +- **Presentation attributes** are the painting / typography properties listed + in [SVG 2 §11 Painting](https://www.w3.org/TR/SVG2/painting.html) and + [§13 Text](https://www.w3.org/TR/SVG2/text.html): `fill`, `fill-opacity`, + `fill-rule`, `stroke`, `stroke-width`, `stroke-opacity`, `stroke-linecap`, + `stroke-linejoin`, `stroke-miterlimit`, `stroke-dasharray`, + `stroke-dashoffset`, `paint-order`, `opacity`, `visibility`, `display`, + `color`, plus text properties `font-family`, `font-size`, `font-weight`, + `font-style`, `text-anchor`, `dominant-baseline`, etc. Each may be written + as an XML attribute or as a CSS declaration; this doc says "presentation" + to mean both encodings unless noted. See + [§Hazards](#hazards-cross-cutting) for the cascade interaction. +- **Structural attributes** mean `id`, `class`, `style`, `transform`, + `clip-path`, `mask`, `filter`, plus ARIA / scripting hooks. They are + available on essentially every rendered element; per-element sections + call out only deviations. +- **Local frame** describes the coordinate system the element's geometry + attributes are interpreted in, before `transform=` is applied. Whether + the element establishes a new viewport (per [§7.2 Establishing a new + SVG viewport](https://www.w3.org/TR/SVG2/coords.html#EstablishingANewSVGViewport)) + is called out explicitly. +- **Edit characterization** describes what graphical mutations the editor + can perform without falling back to a `` conversion. The blanket + rule: shapes have no native scale/skew attribute, so non-uniform scale, + shear, and rotation must live on `transform=`. + +--- + +## `rect` + +Defined in [SVG 2 §10.2](https://www.w3.org/TR/SVG2/shapes.html#RectElement). + +- **Geometry attrs**: `x` (init `0`), `y` (init `0`), `width` (init `0`, + must be `>=0`), `height` (init `0`, `>=0`), `rx` (init `auto`), + `ry` (init `auto`). `pathLength` per + [§10.7](https://www.w3.org/TR/SVG2/shapes.html#PathLengthAttribute). +- **Presentation attrs**: full fill/stroke set; see + [§Conventions](#conventions). +- **Structural attrs**: standard set. +- **Local frame**: `(x, y)` is the top-left corner in the current user + coordinate system; the rect is axis-aligned in that frame + ([§10.2](https://www.w3.org/TR/SVG2/shapes.html#RectElement)). +- **Edit characterization**: + - translate: mutate `x`, `y`. + - resize axis-aligned: mutate `width`, `height` (and possibly `x`, `y` + for top/left drags). + - corner radius: `rx`, `ry`. + - rotation, shear, non-uniform scale: `transform=` only. +- **Round-trip hazards**: `rx`/`ry` `auto` resolves from the other value at + render time; preserve the raw token, not the resolved length. Negative + `width`/`height` are an error per spec — clamp at editor boundary, do + not silently rewrite. + +--- + +## `circle` + +Defined in [SVG 2 §10.3](https://www.w3.org/TR/SVG2/shapes.html#CircleElement). + +- **Geometry attrs**: `cx` (init `0`), `cy` (init `0`), `r` (init `0`, + `>=0`). `pathLength` permitted. +- **Presentation attrs**: full fill/stroke set. +- **Structural attrs**: standard. +- **Local frame**: `(cx, cy)` is the center in the current user coordinate + system. +- **Edit characterization**: + - translate: `cx`, `cy`. + - uniform scale: `r`. + - turn into an ellipse (independent x/y radii): no native path — either + add `transform=scale(...)` (changes paint scaling, not geometry) or + convert to ``. Pick one and document it; do not silently + cross element type. + - rotation: irrelevant geometrically, but `transform=` still mutates the + rendered frame for strokes and child markers. +- **Round-trip hazards**: none specific beyond shared transform vs scale + ambiguity. + +--- + +## `ellipse` + +Defined in [SVG 2 §10.4](https://www.w3.org/TR/SVG2/shapes.html#EllipseElement). + +- **Geometry attrs**: `cx` (init `0`), `cy` (init `0`), `rx` (init `auto`), + `ry` (init `auto`). `pathLength` permitted. +- **Presentation attrs**: full fill/stroke set. +- **Structural attrs**: standard. +- **Local frame**: `(cx, cy)` is the center; axes are aligned with the + user coordinate system. +- **Edit characterization**: + - translate: `cx`, `cy`. + - resize axes: `rx`, `ry`. + - rotation: `transform=rotate(...)` only; ellipse has no native + `rotate` attribute. +- **Round-trip hazards**: `rx`/`ry` `auto` defaults to the other value per + [§10.4](https://www.w3.org/TR/SVG2/shapes.html#EllipseElement); the IR + must remember which side was `auto` to round-trip the bytes. + +--- + +## `line` + +Defined in [SVG 2 §10.5](https://www.w3.org/TR/SVG2/shapes.html#LineElement). + +- **Geometry attrs**: `x1`, `y1`, `x2`, `y2` (all init `0`). + `pathLength` permitted. +- **Presentation attrs**: stroke set is the meaningful subset (no fill + area by default). +- **Structural attrs**: standard. +- **Local frame**: both endpoints in the current user coordinate system. +- **Edit characterization**: + - move endpoint A: `x1`, `y1`. + - move endpoint B: `x2`, `y2`. + - translate whole: mutate both endpoints; or wrap in `transform=`. + - any non-translate global affine: `transform=` only. +- **Round-trip hazards**: `line` accepts `fill` but it has no rendered + effect ([§10.5](https://www.w3.org/TR/SVG2/shapes.html#LineElement)); + do not treat author fill as dead-code and strip it. + +--- + +## `polyline` + +Defined in [SVG 2 §10.6](https://www.w3.org/TR/SVG2/shapes.html#PolylineElement). + +- **Geometry attrs**: `points` (list of coordinate pairs, init empty). + `pathLength` permitted. +- **Presentation attrs**: full fill/stroke set. **Note:** `polyline` is + _not_ implicitly closed but the `fill` region is still defined by + closing the polygon for fill rule purposes. +- **Structural attrs**: standard. +- **Local frame**: every coordinate is in the current user coordinate + system ([§10.6](https://www.w3.org/TR/SVG2/shapes.html#PolylineElement)). +- **Edit characterization**: + - move vertex `n`: rewrite the `n`-th pair in `points`. + - insert / delete vertex: rewrite `points`. + - global affine: `transform=`. +- **Round-trip hazards**: `points` permits whitespace, commas, sign + packing (`1-2` = `1,-2`). Preserve the source token sequence; do not + re-serialize on every save or you will churn diffs. + +--- + +## `polygon` + +Defined in [SVG 2 §10.7](https://www.w3.org/TR/SVG2/shapes.html#PolygonElement). + +- **Geometry attrs**: `points` (list of coordinate pairs, init empty). + `pathLength` permitted. +- **Presentation attrs**: full fill/stroke set; the close-segment is + implicit. +- **Structural attrs**: standard. +- **Local frame**: every coordinate is in the current user coordinate + system. +- **Edit characterization**: same as `polyline`. Adding/removing the + final segment is implicit — there is no "close" toggle attribute; to + un-close, switch element type to `polyline`. +- **Round-trip hazards**: same source-token preservation hazard as + `polyline`. + +--- + +## `path` + +Defined in [SVG 2 §9](https://www.w3.org/TR/SVG2/paths.html#PathElement); +the `d` property is in [§9.4](https://www.w3.org/TR/SVG2/paths.html#DProperty). + +- **Geometry attrs**: `d` (init `none`), `pathLength`. +- **Presentation attrs**: full fill/stroke set; `fill-rule` matters. +- **Structural attrs**: standard. +- **Local frame**: every coordinate inside `d` is in the current user + coordinate system; commands `M m L l H h V v C c S s Q q T t A a Z z` + per [§9.3.1](https://www.w3.org/TR/SVG2/paths.html#PathDataBNF). + Uppercase = absolute, lowercase = relative to current point. +- **Edit characterization**: + - move vertex / handle: rewrite the affected command's coordinates. + - segment-type change (e.g. line to cubic): rewrite the command, + inheriting endpoints. + - global affine: `transform=` (preferred — `d` rewrite would lose + user-authored relative/absolute structure). +- **Round-trip hazards**: + - `d` is a serialized mini-language. Round-tripping requires either + preserving the source string verbatim when no vertex changed, or + a canonical normalizer the editor commits to and the user accepts. + - Implicit lineto after a moveto (per + [§9.3.3](https://www.w3.org/TR/SVG2/paths.html#PathDataMovetoCommands)) + is easy to lose in a naive parse-emit cycle. + - `pathLength` is author-supplied and rescales dash arrays; do not + drop it on edit. + +--- + +## `text` + +Defined in [SVG 2 §11.2 Text element](https://www.w3.org/TR/SVG2/text.html#TextElement). + +- **Geometry attrs**: `x`, `y`, `dx`, `dy`, `rotate` (each a list, applied + per-character), `textLength`, `lengthAdjust` (`spacing` | + `spacingAndGlyphs`). Single-number `x`/`y` is the common case. +- **Presentation attrs**: paint set plus text properties + (`font-family`, `font-size`, `font-weight`, `font-style`, + `text-anchor`, `dominant-baseline`, `direction`, `writing-mode`, + `letter-spacing`, `word-spacing`, `white-space`). See + [§11.12](https://www.w3.org/TR/SVG2/text.html#TextProperties). +- **Structural attrs**: standard. +- **Local frame**: the _current text position_ starts at the first + `(x, y)` from this element or inherited from ancestor. Glyphs advance + along the inline-progression axis from there. `(0, 0)` is the + anchor before the first glyph in this element's frame, not its + visual top-left. +- **Edit characterization**: + - move whole block: `x`, `y` (single values). + - per-glyph positioning: only respectable if the user authored a + per-glyph list to begin with. Do not synthesize per-glyph arrays + on translate of an authored single-value `x`. + - rotate text block: `transform=rotate(...)` (the `rotate=` attribute + is _per-glyph_, not block-level). + - reflow / resize: SVG `` does not wrap; width is implicit. + Resize is not a native operation — either re-author `x`/`y` per + line as separate `` or accept that drag-resize is a no-op. +- **Round-trip hazards**: significant whitespace handling is governed by + `white-space` and `xml:space`; collapsing or re-indenting the XML + silently mutates rendered content. + +--- + +## `tspan` + +Defined in [SVG 2 §11.3](https://www.w3.org/TR/SVG2/text.html#TextElement) +(shares the text-element section). + +- **Geometry attrs**: same set as `text` — `x`, `y`, `dx`, `dy`, + `rotate`, `textLength`, `lengthAdjust`. Unspecified positions inherit + the parent's current text position. +- **Presentation attrs**: full text/paint set; intended primary use is + _overriding_ a property for a substring. +- **Structural attrs**: standard. +- **Local frame**: inherits the enclosing `` element's coordinate + system and current text position. +- **Edit characterization**: + - the canonical editor operation is "apply property X to selection + [a, b)"; this is a `` split. Splitting / merging tspans is + not a single-attribute mutation — it restructures the XML tree. + - geometric per-glyph positioning lives on the parent `` or + via per-character arrays here; either is legal. +- **Round-trip hazards**: tspan boundaries are visible in the text node + graph; splitting on every property toggle bloats the DOM, merging + loses author intent. Editor must choose a normalization policy. + +--- + +## `textPath` + +Defined in [SVG 2 §11.4](https://www.w3.org/TR/SVG2/text.html#TextPathElement). + +- **Geometry attrs**: `href` (the path), `startOffset`, `side` + (`left` | `right`), `method` (`align` | `stretch`), + `spacing` (`auto` | `exact`), `textLength`, `lengthAdjust`. Inline + `path=` is also allowed in SVG 2. +- **Presentation attrs**: text/paint set. +- **Structural attrs**: standard. **Note:** `x`, `y`, `dx`, `dy`, + `rotate` on a `` are _ignored_ — the path supplies them. +- **Local frame**: positions are 1-D offsets along the referenced + path's arclength, not 2-D coordinates. `(0, 0)` is not meaningful; + the origin is the path start (modulated by `startOffset`). +- **Edit characterization**: + - slide along path: mutate `startOffset`. + - flip to other side: `side=left|right`. + - rebind to a different path: mutate `href`. + - move-by-drag in screen space: not a native operation; the path is + independent geometry. Refuse or fall back to editing the path. +- **Round-trip hazards**: changes to the referenced `` `d` shift + the text without any local mutation; editor must invalidate. + +--- + +## `svg` + +Defined in [SVG 2 §5.1](https://www.w3.org/TR/SVG2/struct.html#SVGElement). +Establishes a new viewport per +[§7.2](https://www.w3.org/TR/SVG2/coords.html#EstablishingANewSVGViewport). + +- **Geometry attrs**: `x`, `y`, `width`, `height` (for inner ``; + outermost `` uses CSS box on the host), `viewBox`, + `preserveAspectRatio`, `zoomAndPan`. +- **Presentation attrs**: standard paint set is accepted; primary use is + `overflow`, `color`. +- **Structural attrs**: standard plus `xmlns`, `version`, `baseProfile`. +- **Local frame**: establishes a new SVG viewport and a user coordinate + system. `viewBox` sets the user coordinates _inside_; + `preserveAspectRatio` controls how that user space maps onto the + viewport rectangle. +- **Edit characterization**: + - reposition inner ``: `x`, `y`. + - resize: `width`, `height`; this rescales children if `viewBox` is + set, or just enlarges the viewport otherwise. + - rebind frame: mutate `viewBox`; pan = translate min-x/min-y, + zoom = scale width/height. +- **Round-trip hazards**: `width`/`height` as percentages vs lengths + vs absent (defaults to `100%`) all render the same when embedded but + differ semantically; preserve the source form. CSS-property + `transform` on `` applies to the _outside_ of the element per + [§7.4](https://www.w3.org/TR/SVG2/coords.html#TransformProperty), + conceptually wrapping the element; not symmetric with `transform=` + on inner elements. + +--- + +## `g` + +Defined in [SVG 2 §5.2](https://www.w3.org/TR/SVG2/struct.html#GroupElement). + +- **Geometry attrs**: none. `g` has no `x`, `y`, `width`, `height`. +- **Presentation attrs**: paint set is accepted and _inherited_ by + descendants (the primary use of `g` for grouped property assignment). +- **Structural attrs**: standard. `transform=` is the only positioning + knob. +- **Local frame**: identity unless `transform=` is set. Does _not_ + establish a viewport. +- **Edit characterization**: + - translate group: `transform=translate(...)`. + - any other affine: `transform=`. + - "set width": not a thing — group dimensions are the union of + children. Drag-resize is per-child re-layout, not a group attr. +- **Round-trip hazards**: ungrouping / regrouping reorders the DOM and + can change cascade order if children carry presentation attrs; + refuse to auto-collapse `g` wrappers. + +--- + +## `symbol` + +Defined in [SVG 2 §5.5](https://www.w3.org/TR/SVG2/struct.html#SymbolElement). +Establishes a new viewport when _instanced by ``_. + +- **Geometry attrs**: `x`, `y`, `width`, `height`, `viewBox`, + `preserveAspectRatio`, `refX`, `refY`. +- **Presentation attrs**: paint set is inherited by clones. +- **Structural attrs**: standard. +- **Local frame**: not rendered on its own. When referenced by ``, + the `` establishes a nested viewport sized by the `` + geometry, with `viewBox` mapping the internal user space. +- **Edit characterization**: + - editing geometry attrs on `` affects _every_ `` of it; + this is reference editing, not instance editing. + - to edit one instance, edit the `` instead. +- **Round-trip hazards**: `` outside `` is still + non-rendering by itself but participates in cascade and document + order; do not collapse to `` membership. + +--- + +## `defs` + +Defined in [SVG 2 §5.4](https://www.w3.org/TR/SVG2/struct.html#DefsElement). + +- **Geometry attrs**: none. Contents do not render directly. +- **Presentation attrs**: accepted on `` but inherited by + children; rarely useful. +- **Structural attrs**: standard. +- **Local frame**: not rendered; `(0, 0)` is not meaningful. +- **Edit characterization**: `` is a non-rendering container. + Edits to children (paint servers, symbols, filters) propagate to + every reference. Treat as a "definition library" panel, not a + canvas-editable target. +- **Round-trip hazards**: some authoring tools require all referenced + definitions to live inside a ``; others permit forward + references. The spec does not require `` membership for + referenceable elements ([§5.4](https://www.w3.org/TR/SVG2/struct.html#DefsElement)). + +--- + +## `switch` + +Defined in [SVG 1.1 §5.8](https://www.w3.org/TR/SVG11/struct.html#SwitchElement) +(SVG 2 carries the element with the same behavior). + +- **Geometry attrs**: none. +- **Presentation attrs**: standard set; `transform=` allowed. +- **Structural attrs**: standard. Children are evaluated against + `systemLanguage`, `requiredExtensions`, `requiredFeatures` + (last is SVG 1.1 only — SVG 2 deprecates it). +- **Local frame**: identity; rendering frame of whichever child is + selected. +- **Edit characterization**: the rendered child is the _first_ child + whose conditional attrs all evaluate true. A graphical editor cannot + meaningfully select the "child" without first picking a locale + context. Editing siblings is editing the unrendered branches. +- **Round-trip hazards**: see [§Hazards](#hazards-cross-cutting). + +--- + +## `use` + +Defined in [SVG 2 §5.6](https://www.w3.org/TR/SVG2/struct.html#UseElement). + +- **Geometry attrs**: `x`, `y`, `width`, `height`, `href` + (or legacy `xlink:href`). +- **Presentation attrs**: paint set is inherited by the cloned subtree + unless overridden in the source. +- **Structural attrs**: standard. +- **Local frame**: the cloned content is rendered as if it were a + shadow tree at the `` element's position. `width`/`height` + only have effect when the referent is `` or `` + (overriding their viewport sizing). +- **Edit characterization**: + - move instance: `x`, `y`. + - resize: only meaningful for symbol/svg referents. + - global affine: `transform=`. + - edit instance contents: not possible directly — the shadow tree + is read-only per + [§5.6.1](https://www.w3.org/TR/SVG2/struct.html#UseShadowTree): + "Any attempt to directly modify the elements, attributes, and + other nodes in the shadow tree must throw a + `NoModificationAllowedError`." Editing the referent is the only + path; it changes every instance. +- **Round-trip hazards**: circular `` references are invalid and + must not render. Editor must reject creation of cycles. + +--- + +## `linearGradient` + +Defined in [SVG 2 §13.2](https://www.w3.org/TR/SVG2/pservers.html#LinearGradients). + +- **Geometry attrs**: `x1` (init `0%`), `y1` (init `0%`), `x2` (init + `100%`), `y2` (init `0%`), `gradientUnits` (init `objectBoundingBox`), + `gradientTransform`, `spreadMethod` (`pad` | `reflect` | `repeat`), + `href` (template). +- **Presentation attrs**: not a render target; child `` carries + `stop-color`, `stop-opacity`. +- **Structural attrs**: standard. +- **Local frame**: depends on `gradientUnits`: + - `userSpaceOnUse` — coordinates in the user coordinate system at the + _referencing_ element's position. + - `objectBoundingBox` (default) — `0..1` maps to the referencing + element's bounding box. +- **Edit characterization**: not an edit target on the canvas; edits + happen via paint pickers operating on the _referencing_ element's + `fill`/`stroke`. The IR must surface gradient handles in the picker's + frame, not the document's. +- **Round-trip hazards**: `objectBoundingBox` coordinates are unit + square; the editor cannot present them in document pixels without + knowing the current referent. `href` chains let one gradient + inherit stops or geometry from another — partial overrides. + +--- + +## `radialGradient` + +Defined in [SVG 2 §13.3](https://www.w3.org/TR/SVG2/pservers.html#RadialGradients). + +- **Geometry attrs**: `cx` (init `50%`), `cy` (init `50%`), `r` + (init `50%`), `fx`, `fy` (init equal to `cx`, `cy`), `fr` (init `0%`), + `gradientUnits`, `gradientTransform`, `spreadMethod`, `href`. +- **Presentation attrs**: see linear gradient. +- **Structural attrs**: standard. +- **Local frame**: same `userSpaceOnUse` / `objectBoundingBox` split as + linear. +- **Edit characterization**: as linear; the focal-point handles + (`fx`, `fy`, `fr`) are extra UI affordances. +- **Round-trip hazards**: `fx`/`fy` default to `cx`/`cy` — preserve + absence vs equal-value to round-trip. + +--- + +## `pattern` + +Defined in [SVG 2 §13.4](https://www.w3.org/TR/SVG2/pservers.html#PatternElement). + +- **Geometry attrs**: `x`, `y`, `width`, `height`, `patternUnits` (init + `objectBoundingBox`), `patternContentUnits` (init `userSpaceOnUse`), + `patternTransform`, `viewBox`, `preserveAspectRatio`, `href`. +- **Presentation attrs**: not a render target. +- **Structural attrs**: standard. +- **Local frame**: tile rectangle established by `patternUnits`; tile + _content_ uses `patternContentUnits` — note the two axes can be + different units, which is a common authoring footgun. +- **Edit characterization**: paint server; same as gradients — not a + canvas-direct target. +- **Round-trip hazards**: `patternUnits` and `patternContentUnits` + defaults differ; canonical normalization will change rendering for + documents that relied on defaults. + +--- + +## `marker` + +Defined in [SVG 2 §11.6 Marker properties](https://www.w3.org/TR/SVG2/painting.html#MarkerElement). + +- **Geometry attrs**: `refX`, `refY` (length, percentage, or keyword: + `left`/`center`/`right` for `refX`; `top`/`center`/`bottom` for + `refY`), `markerWidth`, `markerHeight`, `markerUnits` + (`strokeWidth` | `userSpaceOnUse`), `orient` (`auto` | + `auto-start-reverse` | angle), `viewBox`, `preserveAspectRatio`. +- **Presentation attrs**: inherited by marker contents. +- **Structural attrs**: standard. +- **Local frame**: a viewport sized by `markerWidth` × `markerHeight`; + `viewBox` sets the user-space mapping; `refX`/`refY` is the point + inside that frame that aligns to the vertex being decorated. +- **Edit characterization**: glyph decorator; edited via the picker + attached to `marker-start` / `marker-mid` / `marker-end` on the + referent. Per-instance rotation comes from `orient=auto`, not from + the marker element itself. +- **Round-trip hazards**: `markerUnits=strokeWidth` (default) means + marker size depends on the referent's `stroke-width`; resizing a + marker by mutating `markerWidth` rescales every reference. + +--- + +## `clipPath` + +Defined in [CSS Masking Module §6](https://drafts.fxtf.org/css-masking/#ClipPathElement); +historical attributes per [SVG 1.1 §14.3.5](https://www.w3.org/TR/SVG11/masking.html#ClipPathElement). + +- **Geometry attrs**: `clipPathUnits` (`userSpaceOnUse` (default) | + `objectBoundingBox`). The clipping geometry lives in _child shape + elements_. +- **Presentation attrs**: `clip-rule` on children matters; paint is + ignored. +- **Structural attrs**: standard. +- **Local frame**: per `clipPathUnits`. With `userSpaceOnUse`, child + shapes are in the user coordinate system at the _referencing_ + element; with `objectBoundingBox`, they are in unit-square coordinates + of the referent's bbox. +- **Edit characterization**: clip-shape editing is editing the child + shape's native attributes; not a ``-attribute mutation. +- **Round-trip hazards**: CSS `clip-path` property and SVG `` + - `clip-path=url(#...)` attribute are different surfaces with + different syntax (`inset()`, `polygon()`, etc. for CSS); preserve + the original form. + +--- + +## `mask` + +Defined in [CSS Masking Module §8](https://drafts.fxtf.org/css-masking/#MaskElement); +historical attributes per [SVG 1.1 §14.4](https://www.w3.org/TR/SVG11/masking.html#MaskElement). + +- **Geometry attrs**: `x` (init `-10%`), `y` (init `-10%`), `width` + (init `120%`), `height` (init `120%`), `maskUnits` (init + `objectBoundingBox`), `maskContentUnits` (init `userSpaceOnUse`), + `mask-type` (`luminance` (default in SVG 1.1) | `alpha`). +- **Presentation attrs**: mask region paint is honored; `color` and + filters apply. +- **Structural attrs**: standard. +- **Local frame**: split — region rectangle in `maskUnits`, content in + `maskContentUnits`. Defaults differ (region = bbox unit square, + content = user space); a common rendering surprise. +- **Edit characterization**: paint a mask = edit the child rendering + tree; reposition the mask region = mutate `x`/`y`/`width`/`height`. +- **Round-trip hazards**: same dual-units surprise as ``. + +--- + +## `filter` + +Defined in [Filter Effects Module §6](https://drafts.fxtf.org/filter-effects/#FilterElement); +historical attributes per [SVG 1.1 §15.5](https://www.w3.org/TR/SVG11/filters.html#FilterElement). + +- **Geometry attrs**: `x` (init `-10%`), `y` (init `-10%`), `width` + (init `120%`), `height` (init `120%`), `filterUnits` (init + `objectBoundingBox`), `primitiveUnits` (init `userSpaceOnUse`), + `href` (template); `filterRes` is SVG 1.1 only and deprecated. +- **Presentation attrs**: irrelevant to the filter element itself; + primitives carry their own. +- **Structural attrs**: standard. +- **Local frame**: filter region in `filterUnits`; primitive lengths in + `primitiveUnits`. Same dual-units pattern as mask and pattern. +- **Edit characterization**: filter editing means editing the + _primitive children_ (``, ``, …), + which is a node-graph editor, not a single-attribute mutation. +- **Round-trip hazards**: filter primitive graphs have implicit + in/result chaining; deleting a primitive can break downstream + references silently. CSS `filter` property and SVG `` + + `filter=url(#...)` are separate surfaces. + +--- + +## `image` + +Defined in [SVG 2 §9 Embedded content](https://www.w3.org/TR/SVG2/embedded.html#ImageElement). + +- **Geometry attrs**: `x`, `y`, `width`, `height`, `href`, + `preserveAspectRatio`, `crossorigin`. +- **Presentation attrs**: `image-rendering`, `opacity`, `visibility`, + `clip-path`, `mask`, `filter` apply; `fill`/`stroke` do not paint + the bitmap. +- **Structural attrs**: standard. +- **Local frame**: `(x, y)` is the top-left of the positioning + rectangle in user coordinates; the bitmap is fitted into that + rectangle by `preserveAspectRatio`. `overflow:hidden` by default + per spec — content that violates aspect ratio fit is clipped. +- **Edit characterization**: + - move: `x`, `y`. + - resize: `width`, `height`. + - rebind asset: `href`. + - crop: not native — wrap in `` or use a CSS aspect-ratio + override. +- **Round-trip hazards**: data-URI `href` payloads are large and + whitespace-sensitive; the IR must hold the raw `href` token, not a + decoded blob. + +--- + +## `style` + +Defined in [SVG 2 §6.4](https://www.w3.org/TR/SVG2/styling.html#StyleElement). + +- **Geometry attrs**: none. +- **Presentation attrs**: none (style is a sheet, not a graphic). +- **Structural attrs**: `type` (init `text/css`), `media` (init `all`), + `title`. +- **Local frame**: not rendered. +- **Edit characterization**: a CSS text node. Mutating a single + selector's value can affect any number of canvas elements that match + it. Not a direct graphical mutation target; either treat as opaque + source or refuse edits that round-trip through inline-style + conversions. See [§Hazards](#hazards-cross-cutting). +- **Round-trip hazards**: the cascade. + +--- + +## `foreignObject` + +Defined in [SVG 2 §9.8](https://www.w3.org/TR/SVG2/embedded.html#ForeignObjectElement). + +- **Geometry attrs**: `x`, `y`, `width`, `height`. No + `preserveAspectRatio` and no `href`. +- **Presentation attrs**: `overflow`, `opacity`, `visibility`, plus + applicable CSS layout on contents. +- **Structural attrs**: standard. +- **Local frame**: the rectangle is a CSS containing block for the + foreign-namespace contents; child layout follows the foreign + language's model (HTML/CSS for `xmlns="http://www.w3.org/1999/xhtml"`, + MathML, etc.). +- **Edit characterization**: only the SVG-side rectangle is editable in + the canvas. The contents are an embedded foreign document — out of + scope for a vector editor; treat as opaque. +- **Round-trip hazards**: see [§Hazards](#hazards-cross-cutting). + +--- + +## Hazards (cross-cutting) + +Constructs that resist round-trip graphical editing across element types. + +- **`