16. Decisions log

Esta página está em inglês; a tradução está a caminho.

ADR-1. Canvas2D first, behind a Renderer interface. Context: the canvas must render vector UI with Figma’s paints, blend modes and effects at 60 fps (NFR-1), and match export (NFR-8). Decision: Canvas2D with tiling and LOD for 0.1–1.0; WebGL later behind the same interface if profiling demands it. Why: Canvas2D in Chromium is GPU-accelerated Skia, already implements every blend mode, gradients, filter: blur, and anti-aliased paths; a WebGL renderer means writing path tessellation, anti-aliasing and blend shaders before drawing the first rectangle. Consequences: per-frame cost depends on tile caching; very large drags use layer bitmaps (§7.3); blur and background blur must be matched against resvg by the golden suite.

ADR-2. QuickJS in WASM for use_figma. Context: agents run arbitrary JavaScript against the document (CMD-10) with limits (CMD-11), in the host and in the CLI. Decision: QuickJS compiled to WASM (quickjs-emscripten), run in a worker in the host. Why: Node’s vm is not a security boundary and has no memory limit; isolated-vm is a native module (PLT-9); a webview or iframe sandbox does not exist in the CLI. QuickJS has interrupts, memory limits, ES2023 and a few milliseconds of start-up. Consequences: scripts run slower than V8 (fine for design scripts); every member access crosses a boundary, so reads are batched (§8.2).

ADR-3. Figma-compatible names. Context: agents and skills are trained on Figma’s MCP tools and Plugin API. Decision: tool names and parameters are Figma’s (CMD-1); node types and property names in the core are Figma’s where Figma has one (layoutMode, itemSpacing, layoutSizingHorizontal, colours 0–1). Why: the Plugin API layer becomes a thin map instead of a translation; ud inspect output reads like Figma; agents need no new vocabulary. Consequences: we inherit some awkward names; registry command ids are our own (layout.setAutoLayout) and document their Plugin API equivalent.

ADR-4. Flat node map with fractional indexes. Context: the file must merge in git without conflicts (COL-4, NFR-2). Decision: nodes are stored in one map keyed by id, each with parent and a fractional index string; there are no nested children arrays. Why: two branches that insert siblings or move different nodes touch different keys; a nested array would conflict on every concurrent insert. Lookup by id is O(1) everywhere (scripts, CLI, hit testing). Consequences: the children index is derived (§4.2); fractional keys need occasional rebalancing, done by consolidation.

ADR-5. Derived layout. Context: auto layout positions depend on content, fonts and variables. Decision: positions and sizes in flow are computed by the core, not stored (LAY-9). Why: storing them would make every text edit or mode switch rewrite many nodes, causing large diffs and merge conflicts; and a stored value could disagree with the rules. Consequences: every surface must compute identical layout, hence one engine compiled for every runtime, the determinism rules (§5.8), and the identity tests.

ADR-6. A replica of the core in the webview. Context: Software Board builds its scene on the host and the page only draws. A design canvas needs live reflow during drags (CAN-11, LAY-7) and must not wait for a round trip per pointer move. Decision: the webview holds a replica that folds the host’s events, previews gestures locally and predicts commits; the host stays the only writer. Consequences: the core, text, layout, render and commands must be webview-safe (§2.2); the seeded id scheme (§3.6) keeps predictions and commits identical.

ADR-7. resvg for raster export, our own PDF writer. Context: ud render and exports run without a browser (CLI, host). Decision: rasterise our SVG output with resvg-wasm; write PDF ourselves. Why: no native modules (no node-canvas, no Skia bindings); resvg is a complete, deterministic SVG renderer; the PDF features we need (paths, images, embedded subset fonts, pages) are a small writer, and PDF libraries in JavaScript would not share our text engine. Consequences: two rasterisers (Chromium’s Skia and resvg) must agree within NFR-8, with resvg as the reference (ADR-8); gradients that SVG cannot express and background blur are rasterised by shared code on both renderers (§7.2).

ADR-8. Renderer parity: resvg is the reference. Context: NFR-8 asks that the canvas, ud render, exports and the player agree pixel for pixel within a tolerance, but the canvas is Chromium’s Skia (GPU or software, per machine and driver) and the CLI is resvg. Blurs and shadows use different kernels; SVG filters default to linearRGB while Canvas2D’s filter: blur() works in sRGB; conic and diamond gradients have no SVG primitive and Skia’s createConicGradient interpolates differently from any raster we would write; some blend modes differ at the edges; and CI has only Playwright’s software Chromium on Linux, so a GPU comparison cannot be a gate. An earlier draft of §7.2 had Canvas2D use createConicGradient while claiming the SVG path’s tile was “the same code”, which could not both be true. Decision: RasterRenderer (resvg) output is the reference image. NFR-8 binds RasterRenderer and Canvas2DRenderer with GPU rasterisation off (0.5 % of pixels over 2/255, with 8/255 allowed inside the render bounds of blur and shadow effects). The GPU canvas gets a smoke tolerance only and is not a CI gate. SVG and PDF exports are compared after rasterising with resvg. JPG and WebP are excluded (lossy). The prototype player joins the comparison at 0.5. Conic and diamond gradients use one shared raster tile on both renderers, never a native gradient. SvgWriter sets color-interpolation-filters="sRGB" so both sides blur in the same space. CMD-32’s “pixel for pixel” is read as “within the NFR-8 tolerance”. Consequences: a change in resvg’s output is a change of the reference and goes through golden:update review; a visible difference on a GPU canvas is a bug to chase, not a test failure; the golden suite is the shape of §7.5; a future WebGL renderer (ADR-1) inherits the same contract.

ADR-9. Bundle the CLI and the extension host; keep the webview unbundled. Context: CMD-31 gives a headless read 400 ms including Node start-up, file read and fold. Under ELECTRON_RUN_AS_NODE with several hundred unbundled ES modules, HarfBuzz instantiation and a fresh fold per ud mcp call, that budget is not reachable; the extension host’s activation has the same shape. The SRS limits “no bundler” to the webview; an earlier draft of this document applied it everywhere. Decision: esbuild bundles out/src/extension.js into dist/extension.js and out/src/cli/main.js into dist/ud.js (§15.1); both enable Node’s compile cache; ud mcp caches the folded document by path, size and mtime, keeps one warm QuickJS runtime per file and lays out only the requested subtree (§9.3); CMD-31’s use_figma budget is measured in the process that runs the script, and one-shot headless runs are excluded from it (§8.1). The webview stays unbundled: plain modules under the page contract, media/lib/ as tsc output. Why: a bundle is a build artefact, not a framework; it changes nothing in how the code is written or layered (§2.2 is still enforced on src/), and it removes the one cost (module resolution and parse) that dominates a cold CLI start. The webview’s constraint is a readability and review rule for the page, which a bundle would defeat; it is also not where start-up time is lost. Consequences: tests run against out/ and the package runs dist/, so the integration suite runs once against the packaged build before a release; node_modules is no longer shipped (§15.4); .wasm files stay external and are loaded by path; stack traces in bug reports come from the bundle, so release builds keep a sourcemap outside the package.

ADR-10. Webview threading: shaping, layout and tiles in a Worker; no retained context. Context: with everything on the webview’s main thread, folding a 50 000-node snapshot, shaping long text and rasterising tiles compete with pointer input and panel typing; and with retainContextWhenHidden: false every tab switch rebuilt the page from scratch and lost the viewport and tool state. Setting retainContextWhenHidden: true would keep a full canvas, two replicas and a tile cache alive per hidden tab. Decision: a render worker per webview (media/workers/render.worker.js) owns the scene canvas through OffscreenCanvas, a replica of the document, the text and layout engines and the tile cache; the main thread owns the overlay, tools, hit testing and panels, exchanging viewport, preview patches and a bounds table with the worker (§11.2). CSP gains worker-src <cspSource> (§13). retainContextWhenHidden stays false; viewport, zoom, page, tool and panel state are persisted with vscode.setState() and restored on re-show, within the “re-show after hide” budget of §12. Why: OffscreenCanvas and module workers are in every Chromium VS Code ships; the webview-safe layers (§2.2) already run without DOM, so they run in a worker unchanged; and hidden tabs are the common case in a multi-file session, so their cost must be near zero. Consequences: two replicas per webview (counted in the §12 memory budget); the worker is fed the same init and events as the page (§11.4); hit testing stays on the main thread against the bounds table, so it never waits on a frame; the dev page and the identity tests can run the same code on the main thread behind one interface.

ADR-11. Third-party licences: a notices file and a CI allowlist. Context: the package ships WASM builds of HarfBuzz, ICU4X, resvg, QuickJS, mozjpeg and libwebp, five font families, a vendored licensing client, and ud-plugin-api.d.ts, which is derived from MIT-licensed @figma/plugin-typings and therefore must carry its copyright notice. The review found no inventory; resvg’s licence (MPL-2.0) and the encoders’ were unverified. Decision: a THIRD-PARTY-NOTICES file at the package root, generated by scripts/check-licences.mjs from package-lock.json, media/wasm/VERSIONS, media/fonts/ and a hand-kept list for vendored and derived files (including the MIT notice of @figma/plugin-typings with the derivation stated), and committed; the same script enforces an allowlist (MIT, BSD-2/3-Clause, ISC, Apache-2.0, Unicode-3.0, OFL-1.1, MPL-2.0 for unmodified files only, Zlib, CC0) on every production dependency and fails CI on anything else or on a stale notices file. The licence column of §15.4 names each WASM asset’s licence as to be verified at the first check:licences run; a dependency that fails verification is swapped for one that passes before 0.1 ships. Why: the obligations are all notice-based and cheap to meet, and only if met mechanically; a CI check is the only inventory that stays true. Consequences: adding a dependency means its licence must be on the list or the build fails; the .vsix includes THIRD-PARTY-NOTICES; the trademark question about Figma’s tool names and copied descriptions is a separate decision (SRS App. D #12), not a licence one, and is not settled here.