# Internal build contract — snapdom vector v0 This document describes the internal migrated engine, SVD format and optional Figma consumer in `src/`, `svd/` and `figma-plugin/`. Historical examples below use the engine's internal API; they are not the public npm API. The public MIT-licensed plugin is `@zumer/snapdom-plugins/vector`: `vector({ exclude })` adds `toVector()` (SVG string) and `toFigma()` (clipboard write, returning `Promise`). Only `exclude` is a public Vector option. See [usage](README.md), [public types](../vector.d.ts) and [the public adapter](src/public.js). Plugins 4.x are available on npm and require core 3.x. **And the reverse duty, which is what this paragraph is for: a contract that lies is worse than no contract.** When this file described a shape the code no longer had, three consumers each invented their own probe list for the gradient `unit` keys (`from || start || p0`); two were right by accident and the Figma plugin was wrong, which flattened every linear gradient to top-to-bottom. The same failure repeated with `decoration`, read as an object when it had become an array — every underline in the document came out as NONE. Change the code and this file in the same pass, or the next reader builds on the older of the two. Last reconciled against the source: 2026-08-05 (text emitters, effect vocabulary, `emit/*` signatures). ## Scope: the engine walks snapdom's CLONE, not the live DOM This is the founding decision and it is not an implementation detail. The engine is a **snapdom plugin**. The caller writes the `snapdom()` call and registers us on it; our `beforeRender` hook takes the clone off the hook state, and the `toVector()` / `toVectorSvg()` exports we publish through `defineExports()` mount that clone offscreen and walk **that**. ```js import { snapdom } from '@zumer/snapdom' // v3, local repo import { vector, svdToSvg } from '@zumer/snapdom-vector' const result = await snapdom(el, { plugins: [vector({ mode: 'design' })] }) const doc = await result.toVector() // SVD document const svg = await result.toVectorSvg() // ...or straight to markup ``` There is no `toVector(element)`, and there must not be one again — see [Why a plugin, and not a hook in the core](#why-a-plugin-and-not-a-hook-in-the-core) below. `svdToSvg` / `svdToFigma` stay module-level functions because they are document→format, not capture APIs: give them a document and they need no DOM. The clone is where snapdom has already done the ugly half of the job: pseudo-elements materialised into real ``s, shadow DOM flattened with its CSS rewritten, icon fonts resolved to ``, backgrounds and images inlined past CORS, `backdrop-filter` pre-composed, scroll applied as a translate. Membership in the tree is *solved*; only geometry and paint are ours. **Measured, not assumed** (`test/_clonemetrics.mjs`, nine fixtures across two demo pages plus `test/fixtures/scrolldrift.html`): the mount reproduces the live layout with a delta of **0.00px** — median, p95 and max, across every mapped node. There is no accuracy argument for preferring the live DOM, and the clone additionally carries boxes for nodes the live DOM does not have at all. That number is the *output* of four repairs, not a property of mounting. Borrowing the page's cascade is what makes the clone faithful, and every way it fails comes from the same place — the page's selectors were not written for a tree that sits somewhere else, gained nodes, and connects custom elements on arrival. Each repair lives in `adapters/snapdom.js` with the drift it removed: | what breaks | repair | was | |---|---|---| | host has no width; a `width:auto` root shrink-wraps | `sizeHost` | 628px | | nothing styles a node snapdom invented (`::before` at 0×0) | `synthRulesFrom` | 18px | | a scroll wrapper breaks every `>` chain crossing it, whole subtree | `synthRulesFrom` | 268px | | connecting the clone upgrades custom elements, re-attaching shadow roots | `defuseUpgradedShadowRoots` | 37.72px | The snapshot rules go **only** to nodes the page cannot reach. Applying snapdom's whole `classCSS` on top of the page's cascade is worse than applying none of it: measured, fx-table's pixel diff went 1.83 → 10.25 and a paragraph re-wrapped. Mount in the LIGHT dom, never a shadow root: Chromium ignores `@font-face` inside one, and a clone laid out with substituted fonts measures nothing useful. `nodeMap` (clone → source) stays available and is what a harness uses to assert the clone has not drifted. It is a check, not the data path. ## Why a plugin, and not a hook in the core This section exists so nobody re-derives the hook. It has been invented twice. The history: an early revision had the engine walking the **live DOM**, because snapdom's internal hand-off (`__retain`, which burst's differential recapture uses to keep a clone alive) was not reachable from outside — `createContext` is an allowlist and silently drops any callback a caller passes. The fix at the time was to widen the allowlist: a public `retain` option, committed to snapdom v3 as `2c9d22c`. **That commit is reverted.** The access it added already existed. `snapdom(el, { plugins: [...] })` runs render hooks, and a render hook is handed the clone — the same clone, one line earlier in the same function. `retain` bought nothing a plugin could not already do, and it charged for it in public surface: once `retain` is in the allowlist it is a documented capability the core owes every caller forever, and the private `__retain` channel it shadowed is an implementation detail of burst that should stay private. So the core is untouched, and the engine is a plugin. Four things that decision fixes on the way past, each of which the loose API had to work around by hand: - **`burst` no longer has to be forced off.** The old API passed `burst: false` on every call, because burst's memo and differential paths answer with a URL without re-entering `captureDOM`, so there would be no clone. A plugin with render hooks that does not declare `pure: true` suspends auto-burst by construction (`snapdom/src/core/plugins.js`, `hasImpureRenderPlugins`). Measured: five back-to-back captures of one element all ran the pipeline; the loose API broke at the third. **Omitting `pure` is therefore load-bearing, not an oversight.** - **One capture, not two.** The loose API made its own `snapdom()` call, so a caller who wanted a PNG *and* an SVD paid for two full captures. Now both come off one. - **`toVector`, not `toSvg`.** `buildResult` derives the helper name from the export key, and plugin exports override core exports by name (`{...coreExports, ...provided}`). Publishing `svg` would have silently replaced `result.toSvg()` for every caller of that capture. Hence `vector` / `vectorSvg` → `result.toVector()` / `result.toVectorSvg()`. - **The caller owns the capture options.** `dpr`, `outerTransforms` and `burst` are all read before the first hook runs and **cannot** be forced from a plugin. That is not a loss of control — it is where the control belongs now. `outerTransforms:false` shows up as measured drift and gets filed, rather than being silently prevented. Two prices, both paid deliberately: - `report.timings.snapdomMs` is gone. It was only ever measurable while the engine made the `snapdom()` call itself. The caller makes it now, so the caller is what can hold a stopwatch — `demo/vector.html` times both halves and still shows the split. - `classPrefixCSS` is the one capture value no hook sees. It is recovered inside the plugin by `classPrefixFrom` (`adapters/snapdom.js`), which cuts it out of `classCSS` anchored on the exact suppressor literal plus the `[data-sd-pN]::marker` grammar — verified byte-identical to snapdom's own on 14/14 fixtures. If the anchor ever moves it falls back to the literal and files `capture.clone-css-partial` rather than guessing. ## Module signatures All modules are ESM, `.js`, no TypeScript, no build step. JSDoc for types. Files under `src/css/` MUST be importable from Node with no DOM access. ### `packages/svd/src/schema.js` ```js /** @returns {{ok: boolean, errors: string[]}} */ export function validate (doc) /** @returns {string[]} errors, empty if valid */ export function validateNode (node, id) ``` Strict: reject, never repair. Checks structural invariants (every `children` id exists in `nodes`, `root` resolves, every node has `frame` and `abs` and `fidelity.grade`, every non-`E` grade has a matching `diagnostics` entry, colors are 4-float arrays in 0..1, `paint.z` is an integer). **The effect vocabulary is closed and lives here**, because "what can an effect be" is the question every backend answers differently: ``` dropShadow | innerShadow {color, offset:[x,y], blur, spread, behind} layerBlur | backgroundBlur {blur} colorMatrix {values: 20 floats, srcCss: string, backdrop: boolean} ``` `colorMatrix` is the one that is not a blur and does not carry one: it is the 4x5 of `feColorMatrix type="matrix"`, row-major, composing the run of colour functions (`saturate`, `hue-rotate`, `grayscale`, `sepia`, `invert`, `contrast`, `brightness`) that `collect/box.js` (`colorMatrixOf`) folded together. `srcCss` is REQUIRED and is that CSS chain verbatim — a backend that cannot paint the matrix can still name what it dropped, and a diagnostic that cannot name the function is not much of a diagnostic. `backdrop` distinguishes `filter` from `backdrop-filter`: the two recolour different pixels. ### `packages/vector/src/css/color.js` ```js /** Any CSS color string -> [r,g,b,a] floats 0..1 sRGB. Null if unparseable. */ export function parseColor (css) // => {rgba: [r,g,b,a], srcCss: string} | null /** [r,g,b,a] -> "#rrggbb" / "rgba(...)" for SVG output. */ export function toCssString (rgba) export function isTransparent (rgba) // => boolean (a === 0) ``` Must handle: hex 3/4/6/8, `rgb()`/`rgba()` legacy and space-separated, `hsl()`/`hsla()`, named colors, `transparent`, `currentColor` (caller passes resolved value), and `oklch()`/`lab()`/`color()` by round-tripping through a canvas 2D context when a DOM is present, falling back to a documented approximation when not. Gamut-map by chroma reduction, never per-channel clipping. ### `packages/vector/src/css/gradient.js` ```js /** * @param {string} css one background-image layer, e.g. "linear-gradient(...)" * @param {{w:number,h:number}} box the painting box in px * @returns {Paint|null} null when the layer is not a gradient */ export function parseGradient (css, box) ``` Emits `{type:'linear'|'radial'|'angular', unit:{...}, transform:[a,b,c,d,e,f], stops:[...], notes:[]}`. **`unit` keys are normative — do not guess them downstream.** Leaving them undefined already cost us: three consumers each invented their own probe list (`from||start||p0`), two were right by accident and the Figma plugin was wrong, which silently flattened every linear gradient to top-to-bottom. ```js linear: { p0: [x, y], p1: [x, y] } // unit square, gradient line endpoints radial: { center: [x, y], radius: [rx, ry] | r, shape: 'circle'|'ellipse' } angular: { center: [x, y], startAngle: deg } // CW from 12 o'clock ``` `radius` is a PAIR or a single number. `css/gradient.js` normalizes anisotropy into `transform` and so writes the scalar; `svd/src/schema.js` accepts both and every emitter reads a bare `r` as `[r, r]`, which is what it means. This is the contract, not a divergence from it — do not "fix" one side without the other. A stop is `{ t, color: [r,g,b,a], srcCss }` with `t` in 0..1, monotonic. `transform` is the SVG-order 2x3 `[a,b,c,d,e,f]` taking unit space to painted px. `angular` is the one asymmetry: a conic's angles are real screen angles, so its matrix must stay a uniform similarity and the rotation lives in `unit.startAngle`. The emitter composes it; the matrix never carries it. Geometry normalized to the unit square; anisotropic ellipses absorbed into the matrix. Color-stop hints resampled to 8–16 stops. `transparent` replaced by `rgba(R,G,B,0)` of the neighbouring stop (this fixes premultiplication artifacts). `repeating-*` unrolled up to K=64. Record every approximation in `notes`. ### `packages/vector/src/css/shadow.js` ```js /** * @param {string} css * @param {{inset?: boolean, currentColor?: string}} [options] * @returns {{shadows: Effect[], warnings: string[]}} */ export function parseShadows (css, { inset = true, currentColor } = {}) ``` `{type:'dropShadow'|'innerShadow', color:[r,g,b,a], offset:[x,y], blur, spread, behind:false}`. Multiple comma-separated shadows, in CSS order. `blur` is the CSS radius verbatim. `warnings` is a RETURNED array, never a property hung off the list: a caller that destructures the shadows must not be able to drop the reasons by accident. A shadow that names no color *is* `currentColor`, and only the caller knows it — pass `cs.color` or the shadow comes out declared-black. ### `packages/vector/src/css/radius.js` ```js /** @returns {[[number,number],[number,number],[number,number],[number,number]]} tl,tr,br,bl as [rx,ry] */ export function parseRadii (cs, w, h) ``` Percentages resolved against the box. **Apply the CSS overlap scale factor `f`** (CSS Backgrounds §5.5) when adjacent radii exceed a side. ### `packages/vector/src/css/matrix.js` ```js /** @returns {{m:[a,b,c,d,e,f], decomposed:{tx,ty,rot,sx,sy,skewX}}|null} */ export function parseTransform (cssTransform, cssOrigin, w, h) export function multiply (m1, m2) export function applyToPoint (m, x, y) ``` `rot` in degrees clamped to -180..180. `null` for `none`. 3D transforms return the flattened 2D projection and the caller emits a diagnostic. ### `packages/vector/src/collect/box.js` ```js /** * @param {Element} el * @param {CSSStyleDeclaration} cs already-computed style for `el` * @param {CollectCtx} [ctx] * @returns {{fills:Paint[], strokes:Stroke[], effects:Effect[], textEffects:Effect[], * clip:Clip|null, mask:object|null, radii, opacity:number, blend:string, * warnings:Array}} */ export function collectBox (el, cs, ctx = {}) /** A background layer's painted rect, given its placement and the asset's intrinsic size. */ export function resolveLayerRect (placement, intrinsic = null) ``` `textEffects` is `text-shadow`: it paints the GLYPHS, so it cannot ride in `effects` (which filters the box) and the caller hands it to the block's text node. `mask` is the collected `mask-image` layers — a paint, not a node, and the schema's `mask` is a reference to a node, so `wireMasks` in `index.js` builds that node and writes `node.mask = {node, type}`. Both are inert until such a caller exists and both say so in `warnings`. Background layers -> `fills` in **reverse CSS order** (CSS paints last layer first). Coalesce: N background layers sharing a `clipBox` become N entries in one node's `fills` array, not N nodes. Uniform borders -> one stroke with per-side weights. Per-side differing colors -> `warnings` + caller composes. ### `packages/vector/src/collect/text.js` ```js /** * @param {Element} blockEl an element with a text-carrying inline formatting context * @param {CollectCtx} ctx * @returns {{characters:string, lines:Line[], runs:Run[], pitch:number, * align:string, direction:string, styles:Record, * sourceText:string, * warnings:Array}|null} */ export function collectText (blockEl, ctx) ``` **This is the hard one. The rules are not negotiable:** - `TreeWalker(SHOW_TEXT)` per block. **Never** `selectNodeContents` on a container with inline children — it returns duplicate rects (12 for 7 runs). **Never** per-character: measured 95ms/35k chars vs 4.1ms for 5600 nodes. - `Range.getClientRects()` returns the font's ascent+descent box, **not** the CSS line box. Baseline is `rect.top + ctx.measureText('Hxg').fontBoundingBoxAscent`. - **Group rects into lines by baseline** (0.5px tolerance), **never by `top`** — a larger-`font-size` span shares a baseline but not a top. - `pitch` is **measured** between consecutive baselines. Never read from CSS `line-height`. - Never scale metrics between sizes: Chromium rounds `fontBoundingBox*` to integers per size. - Whitespace collapsing means DOM offsets != rendered offsets. Build the run map on the **rendered** string. NBSP does not collapse. - `characters` joins visual lines with `
` (LSEP). **Never `\n`** — that creates a paragraph and triggers `paragraphSpacing` in Figma. - `sourceText` holds the pre-`text-transform` original, the SAME LENGTH as `characters` and indexed identically — a transform that changed the length is refused and reported instead. - **Every number in `lines` is in the block's OWN units**: an axis-aligned scale between the block and the capture root is divided out, because the node these lines land on carries that transform and would apply it again. The known gap: under a ROTATION the lines are measured on the block's AABB, which is the bounding box of a quad and cannot be undone from here. `emit/svg-flat.js` inverts it exactly (`unbbox`, `emit.svg.text-unbboxed`); a backend that does not must not compensate twice. - `Line = {i, start, end, x, w, baseline, asc, desc, frags?, spans?, words?, hyphen?}`. `w` is the sum of the painted glyph advances, **not** the bbox: - `frags:[{x,w,start,end}]` — present only when something that is not a character (an inline image, a float) splits the line into two or more painted stretches. They tessellate `[start, end)` left to right and are the only place the hole is visible. A consumer that ignores `frags` and stretches the line to `w` squeezes the glyphs. - `words:[{x,w,start,end}]` (2026-08-13) — measured anchor per word, written only for `text-align: justify` blocks (every line of one, last included) and only where trustworthy: axis-aligned chain, no RTL, two or more words. Unlike `frags` they do NOT tessellate — the gaps between them are the space characters, which belong to no word — and justification is the reason they exist: the slack goes into the word gaps, so no advance arithmetic can place a word. One Range measurement per word, paid only on justified blocks. A consumer that anchors each word at its `x` reproduces the justification exactly; one that ignores `words` falls back to the `textLength` tier below. - `spans:[{start,end,x,w}]` — the painted extent of each STYLE stretch on the line, one entry per contiguous piece, in logical order. A `Run` is character offsets and nothing else, so this is the ONLY place a backend can find where the run that carries a wavy underline starts and stops. Without it a decoration over `characters 23..41` had no extent and fell back to a straight line. - `hyphen: string` — the glyph the browser painted at a hyphenation break (`hyphens: auto`) and the document does not contain. **It is deliberately not in `characters`**: every offset in `lines`, `runs`, `frags` and `spans` indexes that string and `sourceText` matches it character for character, so inserting it would move all four at once. Its advance is ALREADY inside `w`, `spans` and `frags` — the browser reports it as ink of this line — so a backend paints the line as `characters.slice(start, end) + hyphen` and nothing else moves. Which character it is, is MEASURED (U+2010 vs U+002D vs a declared `hyphenate-character`), never assumed. - `Run = {start, end, style, href, dy}`. `dy` is the MEASURED offset of a raised or lowered run (superscript, subscript, `vertical-align`) from its line's baseline, positive downwards. The fold that put the run on this line already took the offset out of the baseline, so a consumer that drops `dy` renders H₂O as H2O. - A warning may be prose or `{code, grade, severity, message}`. A collector that knows what it lost states the grade; the orchestrator's regex is the last resort. **TextStyle**, as the collector returns it and as `index.js` (`registerTextStyle`) rewrites it into `doc.styles.text[ts_N]`. The rewrite is the one place the orchestrator translates rather than forwards, and both spellings are normative for the side that sees them: | collector | SVD | notes | |---|---|---| | `fontSize` | `size` | the only renamed key | | `fontFamily` / `family` | `stack` / `family` | `family` is the first entry of the stack, unquoted | | `fontWeight`, `fontStyle`, `fontStretch` | `weight`, `fontStyle`, `stretch` | plus `italic: boolean` | | `fontVariant` (+ `fontVariantCaps` merged in) | `variant` | see below | | `fontFeatureSettings`, `fontVariationSettings` | `features`, `variations` | computed CSS strings | | `letterSpacing`, `wordSpacing`, `lineHeight` | same | resolved to px numbers, never `'normal'` | | `textTransform` | `case` | `characters` is ALREADY transformed; this says what was applied | | `color`/`colorCss` | `fills:[{type:'solid', color, srcCss}]` | | | `decorations` (array) | `decoration` (array \| **null**) | | | `strokeWidth`, `strokeColor` | same | | | `ascent`, `descent`, `fontCss` | same | measured metrics for the face that actually painted | - **`decoration` is an ARRAY, or null — never a single object.** `text-decoration` does not inherit, it PROPAGATES: an `` and a `` inside it can each contribute one, and `underline line-through` is one declaration and two lines. Order is innermost first (`[0]` is the decoration nearest the glyphs). An entry is `{line, style, color, colorCss, thickness, offset, offsetCss}`; `thickness` and `offset` are `null` for `auto`, which is the honest answer — the value is then the font's, and the font's underline position is not in this document. Reading `decoration.line` off the array returns `undefined`, which is how a consumer silently emitted NONE for every underline in the document. - **`variant` is the whole `font-variant` shorthand with `font-variant-caps` merged in.** Chromium drops `small-caps` from the shorthand's serialization as soon as another longhand is set, so both are read and joined. It therefore carries two unrelated things at once — the caps half (`small-caps`, `all-small-caps`, …), which repaints letters, and the OpenType half (`tabular-nums`, `lining-nums`, `slashed-zero`, …), which selects figures and ligatures. Backends split it themselves (`emit/pdf.js` `splitSmallCaps`, `emit/figma-json.js` `variantOf`); there is no second field, so nobody has two places to look. - **`strokeWidth` / `strokeColor` are `-webkit-text-stroke`**, resolved (`strokeColor` falls back to `color`, and is `null` when the width is 0). It is centred on the glyph outline and painted over the fill — in CSS, in SVG and in Figma alike — so a backend that has a stroke primitive can carry the number across unchanged. ### `packages/vector/src/collect/image.js` ```js /** @returns {Promise<{asset:Asset, fill:Paint, warnings:string[]}|null>} */ export async function collectImage (el, cs, ctx = {}) ``` Read `currentSrc` (not `src`) and `naturalWidth/Height`. **If `currentSrc` is an SVG, inline and vectorize it — do not rasterize.** Honour `object-fit`/`object-position` as a crop plus matrix: the returned `fill` carries `crop` (the visible region in the asset's OWN source pixels) and `transform` (a 2x3 mapping the unit square of that crop onto the node's border box in px), and between them those express all five `object-fit` values. `null` only when the element carries no replaced content at all — the one case with nothing to report, because nothing was lost. **Passthrough markup carries no active content.** `