diff --git a/.agents/skills/sdk-design/SKILL.md b/.agents/skills/sdk-design/SKILL.md new file mode 100644 index 000000000..c4289a60f --- /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: + +```text +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..8634f4065 --- /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 + +```text +┌─────────────────────┐ ┌──────────────────────┐ +│ 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..6c477267b --- /dev/null +++ b/docs/reference/svg/element-model.md @@ -0,0 +1,743 @@ +--- +title: "SVG Element Model — Geometry, Presentation, Frames, Round-Trip Hazards" +description: "Spec-grounded reference for the SVG element surface a graphical editor IR must expose: per-element geometry, presentation hooks, local frames, and round-trip hazards." +keywords: + - svg + - element-model + - geometry + - presentation + - round-trip + - svg-editor +tags: + - internal + - research + - svg +format: md +--- + +# 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. + +- **`