| name | import-design |
| description | Import designs from Figma, Stitch, v0, Pencil, React Native, or Claude Design into Pulp web-compat JS with automated visual validation. Claude Design imports also scaffold a pulp::view::EditorBridge handler file. Versioned parser, format, and compatibility-schema detection lives behind `--detect-only` and `--report-new-format`. |
Import Design
Import a design from an external tool (Figma, Stitch, v0, Pencil, React Native, Claude Design, or the experimental JSX runtime lane) into this Pulp project.
TOOLS THIS SKILL ALREADY SHIPS — reach for these before hand-rolling (read this first)
Every one of these is documented further down this file. That was not enough:
an agent verifying an import hand-rolled PIL crop scripts instead, because the
guidance sat ~1,000 lines deep and a sibling skill told it to use PIL. If you
are about to write a script that opens two PNGs and diffs them, stop — it
exists:
| Need | Tool |
|---|
| "It looks off" → which node, and by how many px | pulp-import-design … --validate --dump-layout L.json then python3 tools/import-design/layout_parity.py L.json |
| "It looks wrong but nothing failed" → was a property DROPPED? | node tools/import-design/material_audit.mjs --fig d.fig --frame F — run this FIRST on any fidelity complaint |
| Labeled N-panel comparison montage | python3 tools/import-design/montage.py --out cmp.png ... |
| Per-widget fidelity audit + JSON report | python3 tools/import-design/fidelity_diff.py --render r.png --scene scene.pulp.json --assets-dir DIR --frame-reference src.png |
| Side-by-side + heatmap + top offending regions | python3 tools/scripts/figma_import_diff.py — use after EVERY codegen change |
| Render an import at the design's own canvas size | tools/scripts/render-figma-import.sh |
| Masked per-region diff vs a reference | python3 tools/import-validation/diff_against_reference_regions.py |
| Re-import regression vs a golden | python3 tools/import-validation/golden_regression.py |
| Rasterize Figma vector frames | python3 tools/import-design/figma_rasterize_vector_frames.py |
The full, machine-checked list is docs/status/tools.yaml (with inputs,
outputs, and availability for each), and its digest is generated into CLAUDE.md
so it is always in context. The table above is the fast path for this skill's
own work; the registry is the source of truth, and a coverage sweep in
tools/scripts/tools_registry_check.py fails CI if a tool lands here without
an entry — so nothing can go quiet the way fidelity_diff.py did.
Check geometry BEFORE you look at pixels. A .fig carries Figma's
already-SOLVED rect for every node — auto-layout children included — so where
each node belongs is a known number, not something to infer from a screenshot.
--dump-layout writes Pulp's laid-out rects plus the design's own (as a
<stem>.geometry.json sibling), and layout_parity.py joins them by node id:
pulp-import-design --from fig --file d.fig --frame 'Main' --output ui.js \
--validate --screenshot-backend skia --dump-layout /tmp/layout.json
python3 tools/import-design/layout_parity.py /tmp/layout.json
--validate writes its render PNG beside --output (e.g. ui.js →
<name>-<source>-render.png in the same directory), not into the CWD — so it no
longer litters whatever directory you ran it from. Pass --output into a temp
dir to keep artifacts together.
This is pixel-free — no thresholds, no anti-aliasing noise — and it answers the
question a screenshot cannot: which node, off by how much, and under which
parent. Deltas are parent-relative and clustered by parent, so a shifted panel
is ONE finding rather than one per descendant, and a group of children sharing
one offset is reported as an alignment/padding drop on the parent by name.
Unmatched ids are listed as dropped/extra — a completeness check no pixel
heuristic can match. Reach for this FIRST when a layout looks wrong; the
montage/heatmap tools tell you that something moved, this tells you what.
--gate-px 16 blocks only on displacement above 16px and reports the rest as
advisory drift — a correct import of the reference design tops out at 12px,
while a dropped auto-layout contract produces ten findings above 16px. The
import-layout-parity-gate ctest runs the whole pipeline on
test/fixtures/imports/fig/synthetic.fig and is strict (exact parity).
It validates BOXES, not the ink inside them. A label whose glyphs are shoved
to one side of a correctly-placed box, an icon drawn at the wrong scale inside
right-sized bounds, a colour, a gradient — all invisible to it, by construction.
This is not hypothetical: an icon-placement change that moved glyphs within
their correct boxes made fg-icon findings disappear and layout_parity went
greener while the render got worse. Never read a clean layout_parity as "the
render is right." It means the boxes are right.
EXTRA means "the render invented a node" — and only that. The synthetic
<key>/mask-scope clip wrappers the mask lowering adds (scene.mjs) have no
design counterpart by construction, so they are tallied separately
(synthetic_extra in the JSON, an "ignored N synthetic mask-scope node(s)"
line in the text) instead of polluting EXTRA with ~16 fixed false positives per
FX file. The whitelist is a narrow suffix match (/mask-scope, " (mask scope)"), never a substring grep — a real node containing the marker mid-id
still counts as EXTRA.
The other half is TWO tools, not one, because they fail differently.
material_audit.mjs counts what the .fig DECLARES against what the import
emits, so it answers "was this dropped?" — deterministically, with no reference
image. thumb_parity.py compares block means against Figma's own raster, so it
answers "is the colour roughly right?" — and it is blind to anything that
preserves a region's mean. Reach for the audit first: a property that never
survived cannot be diagnosed by looking at pixels, and it is the cheaper
question. Neither proves the render is CORRECT; a human looking at a montage is
still the final say. (The audit's checks iterate declared occurrences, so a
node declaring the same property twice — knob bases carry doubled DROP_SHADOWs
in the reference files — would print the same finding line twice;
dedupFindings collapses byte-identical findings at the report boundary while
the count tables keep tallying occurrences.)
The one thing this design's fg-icon bug proves about all of them: every checker
here went green while the icons were visibly broken. layout_parity said the
boxes were right — and they were, because the box had collapsed to width 0 and
the glyph inside it was absolutely positioned, so the finding it did report
(dx=+6.5 dw=-12) read as a placement problem rather than the sizing one it was.
Read a tool's number as the answer to the question it asks, never as "the import
is fine."
Free offline ground truth: every .fig is a ZIP containing thumbnail.png
(Figma's own raster of the design) and a meta.json whose render_coordinates
thumbnail_size give an EXACT canvas→thumbnail transform — no image
alignment, no MCP, no REST, no rate limit. Note the thumbnail is ~0.4× (400 px
wide for a 1004 px design), so it adjudicates layout, colour, and presence —
NOT glyph-level detail. Do not draw fine conclusions from a 5× upscale of it.
--validate renders at the design's own canvas size by default. Do not
pass --render-size unless you specifically want a different size; a mismatched
render and reference makes every similarity score meaningless.
LOCAL-FIRST — never start with the REST script when Figma desktop is open (read this first)
The headless REST exporter (figma_rest_export.py, used in the steps below) is
the CI / true-headless fallback. On a dense real file it WILL be
rate-limited (HTTP 429): Figma's /images render endpoint is a strict Tier-1
budget keyed to the file's plan, so a big frame can 429 for many minutes.
But the Figma MCP is ALSO quota-limited — do not treat it as free. On a
View/Collab seat the MCP is 6 tool calls per MONTH (any plan); Dev/Full seats
get 200–600/day + a per-minute cap. Every read tool counts (get_metadata,
get_screenshot, get_design_context); write tools (whoami,
generate_figma_design) are exempt. This is a hard MONTHLY quota — backoff
cannot clear it. So be maximally frugal. Order of preference, smartest first:
- Reuse a CACHED artifact. Prior sessions cache Figma PNGs/scenes under the
session scratchpad.
find … -iname '<file>*' BEFORE spending a call — a cached
source screenshot suffices for source-vs-implementation checks with zero calls.
- Export a scene for import → the Pulp Figma desktop plugin
(
tools/figma-plugin) exports the figma-plugin-export-v1 envelope (SVG +
node tree) directly from the open file — no MCP tool call, no REST budget.
This is the truly-unlimited local path; once you hold the envelope, all
importer/render work is offline forever. Prefer this to spend ZERO quota.
The plugin also exposes an explicit, offline Forge bridge:
Copy for Forge wraps that same scene plus content-addressed assets in the
versioned pulp-figma-clipboard-v1 text envelope; Import pasted Forge UI
accepts the reciprocal canonical DesignIR envelope and rebuilds editable
hierarchy, layout, stable source identity, and audio-widget bindings. This is
intentionally a plugin-mediated copy/paste protocol, not a claim of native
Figma Cmd+C clipboard compatibility.
- Inspect / verify a design → the Figma desktop MCP, but BUDGET the
6/month. When you must call, use ONE
get_design_context (reference code +
screenshot + metadata together) instead of separate get_metadata +
get_screenshot, and never re-fetch what you cached.
- Only in true headless / CI (no desktop, no MCP) →
figma_rest_export.py.
Its figma_get honors 429 Retry-After with backoff; if it 429s it prints a
loud one-time advisory pointing back here — switch to (0)/(1), don't wait out
6×300s of backoff. Pass --cache-dir DIR to memoize the two REST-heavy
payloads (the /nodes geometry JSON + the frame SVG) per (file_key, node_id):
the first run populates the cache, every re-run reads it with ZERO REST calls
and needs no token — so iterating on the importer against a real Triaz frame
costs one fetch, not one per test. --refresh-cache busts it. This is the
transparent form of the manual --node-json / --frame-svg inputs.
Everything below documents lane (3)'s mechanics because it's the scriptable one,
but the ordering above governs which lane to start in. figma_rest_export.py
mirrors the desktop plugin field-for-field, so a scene from lane (2) or (3) is
interchangeable downstream.
There is no lane (4): pulp import-design --url cannot import from Figma.
Do not reach for --from figma --url 'https://figma.com/design/…' — the CLI
help and docs advertised it for a long time, but it never worked. --url is a
bare unauthenticated curl -fsSL (fetch_url_to_file) and the CLI has no
credential flag at all, so figma.com returns 403 for a private file and the
web app's HTML shell for a public one; the shell then dies deep inside
choc::json::parse with an error that looks like a parser bug rather than an
auth problem. The CLI now rejects figma.com design//file//proto/ URLs up
front (tools/import-design/figma_url.hpp) and names the lanes above. Note the
two --url flags are different: figma_rest_export.py --url is real — it
parses file-key + node-id out of the link and fetches with a token. The only
authenticated Figma REST client in the repo is that script; nothing in the C++
CLI can talk to Figma.
Figma → Pulp, faithful (1:1) — THE WORKING LANE (read first)
When the goal is a visually faithful (1:1) import of a component that lives in
the Figma file (e.g. the Ink & Signal library, file key q9iDYZzg86YrOQKr6I3bY0),
use the figma-plugin faithful-vector lane. This is the lane that actually
reproduces the design; the others below do NOT and waste hours:
- ❌ Do NOT hand-write a C++
paint() to mimic the design. It is never 1:1
(SVG icons, gradients, shadows, pills) and is pure slop. The framework exists
to render the design, not to re-draw it by hand.
- ❌ Do NOT use
--from claude for layout. On a standalone/bundled HTML it
falls back to regex text extraction ("0 widgets, N labels", ~58% and it even
scrapes CSS comments) — no CSS layout, no geometry.
The lane that works (Figma is the source of truth):
python3 tools/import-design/figma_rest_export.py \
--file-key q9iDYZzg86YrOQKr6I3bY0 --node 187:2 \
--cache-dir .pulp-figma-cache \
--out scene.pulp.json
build-gpu/tools/import-design/pulp-import-design \
--from figma-plugin --file scene.pulp.json --output ui.js \
--validate --screenshot-backend skia \
--reference <figma-node-render.png> --diff diff.png --render-size 1356x781
Offline .fig lane (--from fig) — no account, no network
When you have a local Figma save file (File → Save local copy…, a .fig),
--from fig decodes it offline — no Figma account, PAT, MCP, or network. It
produces the same figma-plugin envelope as figma_rest_export.py, then runs
the standard figma-plugin lane, so everything above (audio-widget/library
matching, faithful-vector render, --validate) applies unchanged. Requires
Node ≥ 22 on PATH (native zstd); the decoder is tools/import-design/fig_decode.mjs.
A .fig can carry hundreds of frames across many pages, so outline first,
then pick a frame — importing the whole file is never right:
pulp import-design --from fig --file design.fig --outline
pulp import-design --from fig --file design.fig --frame '102:1624' --output ui.js
Frame selection accepts a guid (102:1624, unambiguous) or an exact
case-insensitive name.
Mirrored layers: m02/m12 is the transform ORIGIN, not the box corner.
A .fig transform with m00 = -1 (horizontal flip — Figma's ⇋ on an icon)
stores the box's RIGHT edge in m02; the visual box spans [m02 - w, m02].
Three consequences, all learned from the Triaz "Reverse/Env" chips
(mirrorAwareOrigin in scene.mjs owns the rule):
- Sidecar truth:
geometry.json must report the visual min-corner, or
layout_parity cries "misplaced by exactly one width" (dx = -10.4 = the
icon's own width) at a node the flex pass placed perfectly.
- Container flips are dropped: an emitted frame carries no
scaleX(-1), so
a vector under a net-mirrored ancestor chain must have the ancestor mirror
baked into its own normalized path (geometryToPath's mirror arg) — the
icon frame's flip cancels its child Union's own baked flip in Figma, and
without the re-bake the "reverse" arrow renders pointing forwards.
- Scope: TRUE mirrors only (exactly one negative axis). BOTH axes negative
is a 180° rotation whose raw-
m02 placement is pinned by the slider-fill
lesson (see the orthogonal-rotation test in fig.test.mjs) — do not
"fix" it to min-corner.
A flowing vector's flex slot is its NODE box, not its ink. Figma's
auto-layout never sees a stroke, but the emitted ink box includes it (CENTER/
OUTSIDE bands, round caps overhang). The decoder reconciles the two with
negative margins on the child's layout (widget keeps the exact ink; Yoga
gets the node box back) — gated to overhangs ≤ strokeWeight + 1, because
through instance expansion the ink is solved at INSTANCE scale while
node.size can still be the master's, and that gap is a scale delta, not a
stroke. The JS codegen lowers layout.margin_* via setFlex — it silently
dropped them before, so if margins ever stop landing, check
emit_js_layout_constraints in design_codegen.cpp first.
Known residual (parity, not render): resized-instance auto-layout re-solve.
A 32×32 INSTANCE of a 40×40 auto-layout SYMBOL re-solves child layout at
32×32 in both Figma and our Yoga pass, but the geometry sidecar composes the
MASTER's 40×40-solved child transforms, so parity reports a phantom offset on
such children (designers-pick "HiHat 01"/"Cymbal 01", dx,dy ≈ -4). Truth-side
fix would need the decoder to re-solve (or read derivedSymbolData layout for
swapped subtrees); the render itself matches Figma's re-layout behavior.
The .fig lane cannot capture multi-state designs. Alternate frames are
lowered ONLY by the faithful_svg path, and the .fig decoder emits a
widget-recognition tree — no render_mode, no svg_asset_id (grep
tools/import-design/fig/*.mjs: zero hits). Repeated --frame here is refused
with exit 2. The lane's merge plumbing is written and tested and will start
working the day the decoder learns faithful export; until then, do NOT wire a
multi-state surface onto it.
Multi-state capture is --file repeated, on a faithful lane. Export one
envelope per state with a lane that emits faithful frames — the REST
faithful-vector export (figma_rest_export.py --faithful-vector) or the Figma
plugin — then merge them at import:
python3 tools/import-design/figma_rest_export.py \
--file-key <KEY> --node 187:15 --out typing.pulp.json --faithful-vector
python3 tools/import-design/figma_rest_export.py \
--file-key <KEY> --node 187:349 --out piano.pulp.json --faithful-vector
pulp import-design --from figma-plugin \
--file typing.pulp.json --file piano.pulp.json \
--emit cpp --mode baked --output kbd.cpp
The first --file is frame 0, the second frame 1, … — that index is what a
design's swap <n> layer targets, so reordering the flags re-points every
swap. The merge (envelope_merge.cpp, shared with the fig lane) folds each
later envelope's root into the first's alternate_frames; the C++ codegen and
the native materializer each emit one add_frame per entry, in order. A single
--file skips the merge entirely and behaves exactly as it always has — keep
it that way when touching this lane.
Captured states nobody can render are exit 2, not a diagnostic.
find_unrenderable_alternate_frames reports every node carrying
alternate_frames that is not a renderable faithful node; the CLI refuses the
import. Without it, the states are dropped and the import "succeeds" with one
frame — the user asks for N states and silently gets one. That silent no-op is
what this surface shipped as before the guard existed; keep the guard.
Swap elements come from the plugin lane only. faithful-vector.ts reads a
layer named swap <n> into a swap element; the REST exporter does not detect
swaps. A REST-captured multi-state component holds all its frames but is driven
by set_active_frame(i) from consumer code.
A swap whose target frame was never captured is reported, never silently
dead. apply_swap_target_verification flags an unset (-1), negative,
out-of-range, or self-targeting swap with a conflict signal, so it rides the
SAME channel as any other unresolved control: --import-report <json> shows it and
--fail-on-unresolved exits 2. Do NOT add a parallel diagnostic channel for
frame problems — route through the import report. This fires on single-frame
imports too (a design whose swap was always dead now says so).
Gotcha — an alternate frame is a SIBLING axis to children, not a
descendant. Any pass that walks the IR and must see every control has to
descend alternate_frames as well (collect_import_report and
apply_placement_verification both do). A walk that only follows children
silently ignores every control on frame 1+. Each alternate is also its own
render region: verify its overlays against ITS width/height, not frame 0's — a
mode toggle routinely swaps to a differently sized frame.
Gotcha — never skip an alternate frame whose SVG fails to resolve. Frame
indices are positional, so dropping frame k renumbers every later frame and
silently re-points the swaps that target them. Both generators add the frame
blank (overlays intact) and diagnose it instead. The decoder is purely structural — geometry, style, text,
and bundled raster assets; widget recognition stays the importer's job (a
node's name flows through untouched for the resolver to classify). Fidelity
losses are reported as named warnings in the emitted envelope's diagnostics
(vector-simplified, gradient-approximated, asset-missing) rather than
silently dropped. External-library instances and cross-file variables that a
local file can't resolve surface the same way — treat them as data, not failures,
and fill in the critical ones by hand.
Gotcha — a hand-written envelope fixture using data: URI SVG assets loses
its faithful node under --mode baked. The baked lanes run
refresh_design_ir_asset_manifest, which resolves assets against the filesystem
and drops inline data: entries ("0 assets"); the faithful node then can't
resolve its SVG and falls back to plain widgets, so render_mode,
svg_asset_id, and interactive_elements all vanish from the output and the
import report goes empty. This looks exactly like a codegen bug and is not one.
Write real .svg files and reference them by relative local_path (what a real
.fig decode emits). Note also that --emit cpp requires --mode baked, and
looks_like_figma_plugin_export keys off figma-plugin-v1 /
"adapter": "figma-plugin" — a fixture missing those but containing "version"
"root" is parsed as a serialized DesignIR instead of an envelope.
Gotcha — the .fig fixture-determinism test compares decoded content, not raw
bytes. fig.test.mjs's "generator output is deterministic" test regenerates
synthetic.fig and compares it to the committed fixture. It must compare the
decompressed schema + message + rasters (unpackFig(...)), never the raw file
bytes: the inner fig-kiwi chunks are DEFLATE-compressed, and zlib's exact output
varies by Node/zlib version, so a raw byte-compare tests zlib rather than the
generator and goes red on any CI host whose zlib differs from the one that
committed the fixture (this is exactly what reddened a VM runner while the same
commit stayed green on bare-metal). If you touch make_synthetic_fig.mjs,
regenerate the fixture from the canonical toolchain, but keep the comparison at
the decoded layer.
Component/instance semantics are preserved end-to-end via one normalized
figma-block contract. All three Figma producers emit the same field names in
a node's figma block — component_key, main_component_name,
main_component_id, component_set_name, remote_library (emitted only when
true), variant_properties ({axis: value}), and component_properties
({name: {type, value}}, type ∈ TEXT/BOOLEAN/NUMBER/VARIANT/INSTANCE_SWAP…) —
and design_ir_json.cpp::parse_ir_node preserves all of them into namespaced
node attributes: figmaComponentKey, figmaMainComponentName,
figmaMainComponentId, figmaComponentSetName, figmaRemoteLibrary,
figmaVariant.<axis>, figmaComponentProperty.<name> +
figmaComponentPropertyType.<name>. The consumer strips Figma's "#<id>"
property-name uniquifier (colliding stripped names fall back to the raw key)
and stringifies values (bool → "true"/"false", round numbers without
.0). Per lane: the plugin captures instance componentProperties /
variantProperties directly; REST derives variant_properties from
VARIANT-typed componentProperties entries (REST has no separate variant map)
and reads remote / set name from the /nodes components maps; the .fig
decoder resolves a state-group member's variantPropSpecs through the set's
componentPropDefs (member-name "a=b, c=d" parse as fallback) and modern
componentPropAssignments ({defID, value}; value.textValue is a TextData
struct — the string is .characters; INSTANCE_SWAP guidValue resolves to
the swapped-in component's name when in-file). A kiwi COMPONENT_SET is a FRAME
with isStateGroup: true — the set, not the member SYMBOL, owns the VARIANT
defs. Nothing is fabricated: legacy files (no assignments) emit identity only.
SLOT nodes carry no component identity in the Plugin API (SlotNode is a
frame defined by a component property reference), so only their name flows.
Mixed text runs are emitted by all three Figma lanes, on ONE offset unit:
UTF-8 bytes. A text node with per-range styling (a bold word, a colored
span) carries an ordered runs array of style DELTAS against the dominant
style — {start, end, fontSize?, fontWeight?, fontStyle?, color?, letterSpacing?, textDecoration?} (camelCase; the exact shape
design_ir_json.cpp::parse_ir_text_runs reads). start/end are [start, end) UTF-8 byte offsets into content in every lane — Figma indexes text
in UTF-16 code units (plugin getStyledTextSegments, REST
characterStyleOverrides, .fig TextData.characterStyleIDs all do), so
each producer converts through a per-code-point map (plugin
extract-pure.ts::utf16ToUtf8ByteOffsets, REST's u16_to_byte, .fig
extractFigTextRuns). A code-unit passthrough corrupts every run after the
first non-ASCII character — the tests pin an é/emoji fixture in each lane.
Homogeneous text emits NO runs array (the flat dominant style is the whole
story; the consumer prefers that path). Lane quirks: the plugin backfills the
dominant style from segment 0 when node-level reads return figma.mixed (a
symbol — typeof guards silently skip it otherwise); the .fig format has no
numeric fontWeight, so run weights derive from the override fontName.style
NAME ("Bold" → 700, "SemiBold" → 600, …); .fig styleOverrideTable rows are
NodeChange structs keyed by styleID. Codegen coverage (compat
text-per-range-styles): web-compat nested <span>s, JS-native
setTextRuns → AttributedString (single-line), SwiftUI concatenated Text
segments; baked C++ and live-native flatten — honest partial.
textAlignVertical is design authority; the tall-slot heuristic is only a
fallback. All three producers emit style.vertical_align
(top/middle/bottom; kiwi omits the .fig TOP default so it only appears when
set). Codegen honors it verbatim in both JS arms — including an explicit
top, which SUPPRESSES the old slot-taller-than-font centering guess (that
heuristic now fires only when the source never says). Expect imports to MOVE
text vs pre-emission renders where designs author TOP in a reserved slot —
that movement is fidelity, not a regression (layout dumps stay byte-identical;
vertical align is paint, not geometry). Auto-resize / truncation / max-lines /
whole-node hyperlink are preserved as namespaced attributes
(figma:text_auto_resize, figma:text_truncation, figma:max_lines,
figma:hyperlink), not lowered; paragraph/list/OpenType metadata is not
captured (compat text-extended-metadata).
Gotcha - old-style instance swap lives in overriddenSymbolID, not
componentPropAssignments. A file that predates component properties swaps a
nested instance's component with a symbolOverrides entry carrying
overriddenSymbolID — there are no componentPropAssignments /
componentPropertyReferences anywhere, so searching for the modern swap
machinery concludes "no swap" while every channel's icon IS swapped.
expandInstance (scene.mjs) honors the field: applyOverrideEntry's generic
copy lands it on the clone, and the expansion re-points at that master when it
resolves in-file (falling back to the authored symbolData.symbolID plus an
external-component diagnostic when it doesn't). Deeper override paths keep
resolving after the swap because the swap target's children carry the matching
overrideKeys. Symptom when broken: N siblings render identical component
content under N correct per-instance text overrides (sixteen mixer channels,
one kick-drum icon).
Gotcha - Figma "Clip content" is frameMaskDisabled (inverted), and groups
never clip. The .fig decoder emits style.overflow = 'clip' for a
FRAME/SYMBOL/INSTANCE with frameMaskDisabled: false unless the node is a
GROUP (resizeToFit: true — groups store the flag but ignore it). The REST
lane's equivalent is clipsContent → overflow. The native JS codegen lowers a
non-default style.overflow to setOverflow(id, 'clip') (bridge maps clip →
View::Overflow::hidden); visible is the View default and is deliberately
not emitted. This matters for expanded instances: a master whose decoration
overhangs its symbol bounds renders clipped in Figma, so an unclipped import
paints the overhang over whatever sits below the instance (a channel strip's
noise card ran 19px past its 235px symbol and buried the transport's step row).
Gotcha - a mask: true child paints NOWHERE; materializing it as content
occludes everything painted after it. Figma's mask layer clips the siblings
painted ABOVE it in the same parent and never renders its own fill. ALL THREE
Figma lanes now reconstruct this sibling-mask structure with one wrapper
contract: the masked siblings move into a synthetic
<mask name> (mask scope) wrapper (spans the parent, audio_widget: 'none',
node_id <maskId>/mask-scope) whose style.clip_path = path("<d>") carries
the mask outline in PARENT space. Per lane: the .fig decoder
(scene.mjs::walkChildren + maskClipOutline/boxMaskOutline;
geometryToClipPath in paths.mjs skips the 0,0-viewBox normalization
geometryToPath does because a CSS clip-path is consumed in the clipped
view's border-box space), the plugin (extract.ts::beginMaskScope over the
pure helpers in extract-pure.ts — maskClipOutline transforms
fillGeometry into parent space via inv(parent.absoluteTransform) ∘
node.absoluteTransform, group-proof; box-model masks get a synthesized
outline), and REST (figma_rest_export.py::_begin_mask_scope, same helpers
in Python; axis-aligned masks translate by absoluteBoundingBox deltas, robust
to REST's GROUP-parent coordinate quirks). The chain downstream already
existed end-to-end (parse_ir_style('clipPath') → codegen setClipPath →
SkPath::FromSVGString clip). Siblings BELOW the mask stay outside the
wrapper (Figma's scope); a second mask opens a nested wrapper so stacked
masks intersect. Fidelity is diagnosed, never silent: an outline clip is
exact for VECTOR (outline) masks and opaque-solid alpha masks; LUMINANCE
masks keep the best-effort outline clip plus a mask-luminance-approximated
warning, soft/partial alpha masks (image/gradient fill, partial opacity) get
complex-mask-flattened (plugin/REST; the .fig lane keeps its original
mask-approximated code), and unresolvable outlines / masks inside
auto-layout parents degrade with mask-approximated — but the mask itself is
never painted, in any branch of any lane. Arcs in vector mask geometry don't
survive a general affine, so an A command falls back to the box outline
(approximate clip beats no clip). Symptom when broken: a "gray" element whose
accent color is right there in the data — the selected mixer channel's red
tab read gray because the master's opaque Bg PAnel mask (invisible in
Figma) painted over it, and every channel body sat one gray lighter than the
design.
Gotcha - a Figma slider stores a value-driven fill position that can detach
from the thumb. A slider component (track + progress fill + round thumb) keeps
the fill's x/width per-instance; Figma's LIVE component render recomputes the
fill against the thumb at draw time, but the stored .fig (and REST/plugin
exports of it) only carry the frozen geometry - so an instance can persist a fill
that floats in a gap away from the thumb, and a faithful render draws a broken
detached bar. reconnect_slider_fill (core/view/src/design_import.cpp, run in
the native codegen arm BEFORE synthesize_primitive_paths bakes the width into
path_data) detects the triplet STRUCTURALLY - short wide container,
near-full-width thin track, shorter thin fill whose color differs from the track,
round thumb whose height fills the bar; never by layer name - and bridges the
fill to the thumb ONLY when they are horizontally disjoint. Do not "fix" a
detached fill by inventing a slider value or by matching on names: the structural
gap-bridge is the whole intervention, and a fill already touching its thumb (plus
track+thumb-only faders and every non-slider row) is left exactly as stored.
Covered by the [slider] cases in test_design_import_codegen.cpp.
Gotcha - a "universal" IR fix is only universal across the emit paths that
carry it. normalize_border_shorthand splits style.border into
border_color/border_width for every lane, but the border only PAINTS if the
node's emit path calls setBorder. design_codegen.cpp has TWO native-frame emit
paths: emit_js_container (recognized containers) and emit_js_generic_frame
(the fall-through for a childless node whose kind is neither
container/widget/vector/image/text — a v0/claude/stitch button/canvas/input
div). emit_js_generic_frame emitted background/gradient/corner-radius but NOT
setBorder, so a bordered generic-frame node silently lost its stroke on EVERY
lane even though the shorthand was split correctly. When you add a style emit to
one frame path, add it to BOTH — and verify a real lane, not a grep: import
test/fixtures/v0-dev/audio-control-panel.tsx and count declared-vs-emitted
(setBorder/setBackgroundGradient/setCornerRadius) in the output JS. A
grep-level "the field reaches the IR" check misses a drop that lives one layer
down in codegen.
Gotcha - the v0 TSX parser does not resolve style={constObject}
references. extract_jsx_style_body (design_import_v0_tsx.cpp) resolves only
inline style={{...}} object literals. A hand-authored/v0 pattern that hoists
the style into const panelStyle = {...} and passes style={panelStyle} is
dropped whole — on audio-control-panel.tsx the root panel loses its
background, padding, border, and radius. Resolving it means extracting
const …Style = {…} decls and threading a registry through
apply_v0_jsx_attribute; treat it as a focused parser change with its own tests,
not a one-liner.
Gotcha - the HTML regex fallback (parse_stitch_html, shared by the claude
lane via parse_claude_html) must skip non-visible elements. Its
<([a-z][a-z0-9]*)[^>]*>([^<]*)</\1> regex matches ANY tag, so a <script> body
(a Stitch tailwind.config = {...} block, a Claude bundler placeholder) or a
<style> sheet became a visible text LABEL — raw JS/CSS painted into the
imported UI. The fix filters a kNonVisibleTags set
(script/style/noscript/template/head/title/meta/link/base). When you touch this
fallback, verify with a real fixture: import
test/fixtures/imports/stitch/2025.04/code.html and confirm the element count
drops (the <script> label is gone) while the visible <div> text survives.
Note this is the LOSSY fallback — a JSON-IR or runtime-DOM claude/stitch input
never reaches it; it fires only on raw non-JSON HTML.
Gotcha - .fig layer rotation was dropped, so a rotated needle rendered as an
axis-aligned stub. scene.mjs's styleFor took only the translation column
(m02/m12) of a node's affine transform and threw away the rotation
(m00/m01/m10/m11). A knob's value needle — a thin ROUNDED_RECTANGLE rotated to
the value angle — then imported as a vertical bar floating off-centre instead of
a radial pointer (reported on TRIAZ "Rnd Pan"). The fix extracts
atan2(m10, m00), emits transform: rotate(<deg>deg), and compensates
left/top for the renderer's centre transform-origin (Figma rotates about the
layer origin; the view rotates about centre) — so NO setTransformOrigin is
emitted and CSS-lane rotate() (which is also centre-pivot) stays correct. The
native codegen lowers transform: rotate() to setRotation in the shared
emit_js_visual_overrides. TWO scope guards, both load-bearing:
- Non-orthogonal only. Apply the transform ONLY when the angle is not a
multiple of 90deg (
mod90 > 0.5 && mod90 < 89.5). A multiple of 90deg keeps a
rect axis-aligned, and for a solid fill a 180deg spin is a visual no-op — the
centre-pivot compensation then only shifts the box off its row. That exact
case floated a slider's 180deg-rotated progress fill ABOVE its track (a
regression from the first cut of this fix). Orthogonal rotations fall through
to plain m02/m12 placement.
- Box-model only. A
VECTOR_LIKE node bakes its rotation into path_data,
so re-applying it double-rotates the glyph (guard !VECTOR_LIKE.has(node.type);
verify a rotated icon like the "Reverse" ↩ button is byte-identical
before/after).
Covered by [rotation] in test_design_import_codegen.cpp + a decoder test in
fig/fig.test.mjs (45deg needle rotates; VECTOR and 180deg fill do not).
All three lanes now lower rotation (audit "Rotation / transform" row). The
plugin (extract.ts walk + extract-pure.ts::decodeRelativeTransform) and
REST (figma_rest_export.py::_decode_rotation) producers decode
relativeTransform and emit the same transform: rotate(<deg>deg) spelling,
with the .fig guards mirrored field-for-field (non-orthogonal tolerance
0.5 < mod90 < 89.5; vector-like exclusion — extended in plugin/REST to EVERY
node-export capture, i.e. widget-instance and pure-vector-illustration PNGs,
because exportAsync/the REST images render bakes the rotation into the
pixels; an image FILL asset is raw bytes, not a node render, so image-filled
boxes still rotate). Placement differs from .fig by design: plugin/REST
position by absoluteBoundingBox deltas, and the rotated AABB's center IS the
node's center, so they emit the UNTRANSFORMED size (node.width/height,
REST size — both ride with the export) centered in the AABB and let the
center-pivot rotate() land it (the origin-pivot compensation formula is only
the plugin's node.x/y fallback path). Two additions the .fig lane does not
have:
- Skew / non-unit scale / mirror-plus-rotation is NOT representable as a
single center
rotate() — the producers raise a
transform-skew-approximated warning (unsupported_property), keep the
node axis-aligned at its AABB (the pre-fix behavior), and never fake an
angle. A pure orthogonal flip stays silent (axis-aligned box already
occupies the right pixels — matches all shipped lanes).
figma:transform_matrix — the full 2x3 affine
("m00,m01,m02,m10,m11,m12", row-major, trimmed like the geometry attrs)
is preserved as a namespaced provenance attribute on every rotated or
diagnosed node, so a future matrix-capable renderer needs no re-export.
Same documented-provenance-sink contract as the figma:arc_data /
figma:stroke_* attrs; nothing consumes it today. The .fig lane does
not emit it (its rotation logic is frozen; envelope stays byte-identical).
Tests: transform.test.ts (plugin decode), rotation cases in
test_figma_rest_export.py (placement math, skew diagnostic, vector-leaf and
flowing-child exclusions), and plugin/REST end-to-end [rotation] cases in
test_design_import_codegen.cpp.
Gotcha - a "label" token names the caption, not the control.
detect_audio_widget (design_import.cpp) whole-token-matches "knob"/"fader"/…
to promote a node to a built-in widget. A node named sound / knob label
tokenizes to {sound, knob, label} and matched "knob", so the caption FRAME
promoted to a knob and painted a stock knob disc over its text (a "Classic"
filter-mode caption imported as a dark knob). The recognizer now returns none
whenever the name carries a label token — the caption is text; the real
control (sound / knob / small unipolar, no label) keeps its recognition.
This is the same "the layer name IS the art, not a control" rule as the
knob-art-layer suppression, one level up. Verified: createKnob count on a real
.fig dropped from 6 (all captions) to 0 while every art-layer knob still
rendered. Covered in detect_audio_widget tests.
Gotcha - a stroke-band vector must not ALSO carry a CSS border. Figma stores
a stroke as an already-expanded fillable band (strokeGeometry); geometryToPath
resolves it and scene.mjs paints it as a FILL in the stroke color (re-stroking
it would outline the outline). But the generic stroke→border lowering
(strokePaints + strokeWeight → style.border) then fired on that same node,
so codegen filled the band AND stroked it — two parallel lines where the design
has one (a triad-pad triangle and every stroked ring rendered doubled/too-thick;
the button rings even read as dark filled discs). The fix tracks
vectorStrokeExpressed (band painted OR stroke channels lowered) and skips the
border for those nodes. Covered by a materialize-level test in
fig/paths.test.mjs. One general fix cleared the triangle weight AND the
transport △○□ button rings at once.
Stroke survival — the four lanes a Figma stroke renders through (the
stroke-survival fix; fig/scene.mjs lowerStrokeChannels + fig/paths.mjs):
- Stroke-only vector, CENTER align — the baked
strokeGeometry band is
exact (caps/joins/dashes realized in the outline); painted as a FILL in the
stroke color. Unchanged, still the faithful path.
- Fill + stroke on one vector — the stroke rides the SAME path as real
stroke channels (
stroke/strokeGradient/strokeWidth →
setSvgStroke/setSvgStrokeGradient/setSvgStrokeWidth): SvgPathWidget
fills then strokes one path, and for CENTER align the fill outline IS the
stroke centerline, so this is exact. No CSS border (would double the edge).
Fractional widths survive (the border lane rounded to whole px).
- INSIDE/OUTSIDE-aligned stroke bands are baked UNCLIPPED — Figma's blob
holds the boundary outlined at ±weight (verified: a weight-2 INSIDE
Polygon 5 carries a 4px band; render-time clipping against the fill
region is what trims it, and we don't clip). Filling that band verbatim
painted the XY-pad triangle fat and bright — a #373737 grey where Figma renders #2b2b2b. The
decoder now prefers the FILL outline + a centered stroke channel — right
width and color, half a weight off in position — and raises
stroke-align-approximated.
- Gradient strokes —
GRADIENT_LINEAR lowers to the strokeGradient
channel end-to-end (SvgPathWidget::set_stroke_gradient →
Canvas::set_stroke_gradient_linear; solid composite kept beside it as the
parse-failure fallback). Works on vectors AND on ellipse primitives whose
path is synthesized later — design_ir_json.cpp captures the stroke
channels UNCONDITIONALLY (not path_data-gated) precisely so
synthesize_primitive_paths can grow the path afterwards.
Vector-network fallback. Symbol children can carry EMPTY
fillGeometry/strokeGeometry on the master AND every instance's derived
data (the FX knobs' Oval rim), leaving only vectorData.vectorNetworkBlob.
paths.mjs decodes that network (layout self-validated by exact byte
consumption: 12-byte header + 12B vertices + 28B segments; tangents are
vertex-relative control offsets) into the shape's CENTERLINE and marks the
result centerline: true — the caller STROKES it via the channels above,
never fills. Refused (→ existing plain-box diagnostic) when regions are
present, any vertex joins 3+ segments, or the byte count is off. Never used
for BOOLEAN_OPERATION (operand children are the faithful fallback).
Diagnostics carry the instance-path node id. pushDiag records
node.__key || guidKey(node.guid) — recording only the master guid attached
every symbol-expanded diagnostic to the MASTER while the envelope/materials
sidecar name the clone, so material_audit's per-node join scored honest
out-loud degradations as SILENT drops (diagnosed column read 0).
Design contract (pulp design compile) — the token/widget allowlist
Before generating or hand-writing a UI, compile the design contract: the
closed set of tokens and components the UI is allowed to bind to. It is compiled
from the buildable sources of truth — a Theme (the token allowlist) and the
pulp::design::catalog() component set (each widget's native class plus the exact
theme tokens it paints through) — so it never drifts from the code.
pulp design compile --out-dir build/design
pulp design compile --design-md DESIGN.md --out-dir build/design
pulp design compile --theme my-theme.json --dark --stdout --json
Two artifacts, one source of truth:
design-manifest.json — deterministic (all lists sorted): every token
(name, kind, value) and every component contract (native class, category,
reskin_tokens allowlist). This is the machine-readable allowlist an adherence
check validates generated JS against.
design-binding-prompt.md — an LLM-ready Markdown fragment listing the
allowed tokens and components with an explicit "do not invent token names"
directive. Embed it in an importer/codegen prompt so the model binds only to
real tokens and widgets. A value or widget outside the contract is a fidelity
break, not a silent drift.
The module is core/view/src/design_manifest.cpp
(pulp::design::compile_design_manifest / manifest_to_json /
emit_binding_prompt), gated behind PULP_ENABLE_DESIGN_IMPORT alongside the
rest of the authoring cluster.
Fidelity ledger (--fidelity-report) — a diffable record of import warnings
The import-time fidelity self-check (design_fidelity.hpp: skew, dropped-vector,
widget-size, …) always warns on stderr, and --strict-fidelity turns the hard
findings into exit 4. Those warnings are transient — they scroll past and are
lost. Pass --fidelity-report <file> to also persist the run's findings as a
named, machine-readable ledger so an import's fidelity is a durable artifact
you can diff across revisions or gate in CI:
pulp import-design --from fig --file synth.fig --frame 0:2 \
--output ui.js --fidelity-report build/fidelity.json
The ledger (core/view/src/design_fidelity_ledger.cpp,
pulp::view::fidelity_ledger_json) carries:
- a
summary — total, warnings (the hard findings that gate
--strict-fidelity), informational, and a by_kind count map;
- the
findings — each with kind, severity (warning for a hard
finding, info for an advisory one), node_id, node_name, detail;
- the
taxonomy — every fidelity kind the checks can emit, each with a
stable slug, default severity, and one-line summary, so a consumer can render
a kind it does not itself know about.
It is emitted regardless of --strict-fidelity: warnings are data, not
failures. The taxonomy in fidelity_taxonomy() must stay in step with the kinds
design_fidelity.hpp emits — add a kind to one, add it to the other.
Adherence lint (pulp design lint-adherence) — the mechanical backstop
The binding prompt tells the model what's allowed; the adherence lint proves the
generated JS actually stayed inside the contract. It flags three high-signal
drifts and is the CI-gateable counterpart to the prompt:
pulp design lint-adherence ui.js
pulp design lint-adherence ui.js --manifest build/design/design-manifest.json
pulp design lint-adherence ui.js --design-md DESIGN.md --strict
- raw-color (error): a hex literal (
#rrggbb/#rgb/#rrggbbaa) where a
bound theme should be referenced via var(--token). When the value is one the
system defines, the finding names the token to bind instead.
- unknown-token (error): a
var(--name) reference whose token is not in the
manifest — a hallucinated or renamed token that silently falls back at runtime
(resolve_color returns the default). This is the exact failure the binding
prompt exists to prevent, caught mechanically.
- raw-dimension (info): an
<n>px literal whose value matches a dimension
token — prefer the token.
Exit 0 when clean, 1 on any error-severity finding (--strict fails on info too).
The scan is purely lexical (core/view/src/design_adherence.cpp,
pulp::design::lint_adherence): line/block comments are ignored, string literals
are scanned, and token→var(--x) mapping mirrors export_css_variables
(.→-). Pair it with compile — the prompt and the lint share one manifest.
Project design ledger (pulp design record) — resumability + review status
Import/design sessions span days and multiple agents; "which of these five ui.js
revisions did the human approve" is a signal that otherwise lives only in chat.
The design ledger (.pulp-design-meta.json) makes it a durable, CLI-owned
record: each emitted artifact as a named, versioned asset with its source
provenance, viewport, bound design system(s), and a review status.
pulp design record --name main-panel --asset ui.js \
--source fig --viewport 340x280 --system ink-signal
pulp design record --name main-panel --version v2 --asset ui.js --status approved
pulp design record --list
pulp design record --remove main-panel@v1
pulp design record --reconcile
Discipline: only the CLI writes the ledger; skills/agents READ it. On resume,
read it (--list --json) to recover the bound design system (⇒ load its binding
prompt from compile without re-asking) and to see which revisions are
approved vs needs-review vs changes-requested. Never hand-edit the file and
never regenerate an approved asset without an explicit request. The ledger
operations are pure (core/view/src/design_ledger.cpp,
pulp::design::parse_ledger / ledger_to_json / upsert_asset / remove_asset
/ reconcile); all file IO lives in the pulp design record CLI.
THE #1 LESSON — --validate does NOT render the faithful SVG. This is what
cost hours. The scene's root carries render_mode=faithful_svg + the embedded
SVG, and the C++ runtime honors it (design_import_native_common.cpp →
make_faithful_svg_frame → DesignFrameView/SkSVGDOM, line ~1734). But
pulp import-design --validate renders the emitted native-widget JS
(build_native_view_tree/codegen materialization), NOT the faithful SVG — so it
mis-lays composite vectors (e.g. piano black keys grouped/dropped) and reports
~18/255 even though the faithful render is pixel-perfect. Do not trust
--validate's number as the faithful fidelity. The CLI now DETECTS a
faithful_svg scene and prints a caveat before the similarity number ("…renders
the native-materialized widget tree, NOT the 1:1 faithful SVG… native-materialize
fidelity… will UNDERSTATE the true faithful render. Verify with pulp-svg-probe"),
so the trap is self-documenting — but the note is a signpost, not a fix: still run
pulp-svg-probe for the real 1:1 number.
Validate the FAITHFUL render with pulp-svg-probe (renders an SVG via
DesignFrameView/SkSVGDOM, the real 1:1 path):
build-gpu/tools/import-design/pulp-svg-probe faithful.svg out.png 1356 781
python3 tools/figma-import/verify_region.py source.png out.png 80 9.0
This is how Musical Typing was proven 1:1 (1.08/255 vs the design). Pulp's
SkSVGDOM does render Figma's effects-heavy SVG (67 filters, 61 masks)
faithfully — the export and the SkSVGDOM render are both fine; only the
native-materialize/codegen path is lossy.
Bitmap assets inside the faithful SVG (<image> + base64 data URI). Figma
exports raster fills as <image href="data:image/png;base64,…">. Two Skia traps
made every one of those render blank (a hole where the art should be), and
both are now fixed in core/canvas/src/svg_dom_cache.cpp — know them, because a
blank image in a faithful render looks like an export bug and is not:
- SkSVGDOM needs a resource provider.
SkSVGImage::LoadImage returns early
on a null provider, and SkSVGDOM::Builder defaults it to null. SvgDomCache
installs skresources::DataURIResourceProviderProxy (+ registers the PNG /
JPEG / WebP / GIF codecs — Skia's codec registry starts EMPTY, so
SkCodec::MakeFromData fails until something calls SkCodecs::Register).
Gated on PULP_HAS_SKRESOURCES, a try-link probe in
core/canvas/CMakeLists.txt: the pinned native slices compile skresources
into libsvg.a (there is no libskresources.a at all), other slices ship a
standalone archive, and the wasm slice ships neither — only a try-link gets
all three right.
- Skia only parses
xlink:href, never SVG-2 href. The literal attribute
name its parser matches is xlink:href; Figma emits the bare href with no
xlink namespace, so Skia drops the attribute and the IRI is empty no matter
how good the resource provider is. SvgDomCache rewrites the attribute name
(tag-scoped, never inside a value or text) before parsing.
If an <image> still comes out blank, check the configure line
Pulp: SVG <image> data-URI decoding enabled (skresources links) — the opposite
message means the active Skia bundle can't link skresources and <image> is
genuinely unavailable in that build.
One-command path (USE THIS): tools/import-design/make_catalog_component.py.
It runs the whole lane — exports the Figma node, embeds the faithful SVG (chunked
base64), and emits the DesignFrameView subclass + the catalog/CMake/showcase
paste-ins. Example:
python3 tools/import-design/make_catalog_component.py \
--name "Channel Strip" --class ChannelStripView --node 182:2 \
--category containers --usage "Pro channel strip…"
Then paste the printed lines into core/view/CMakeLists.txt, the
design_system.{hpp,cpp} catalog, the showcase, and add a test
(test_faithful_specimens.cpp pattern). Validate with pulp-svg-probe +
verify_region.py. This is how Musical Typing, Channel Strip, and 7 specimen
components were built — never hand-paint.
No-metadata source? Use the annotated-capture lane (pulp-annotated-capture-import).
make_catalog_component.py needs Figma as the source of truth — typed nodes,
node ids, component names. A bare SVG capture (a vectorized screenshot, a
hand-authored asset, or a JUCE UI run through an extractor) has none of that: it
is just paths and rects. The annotated-capture lane
(tools/import-design/annotated_capture.{hpp,cpp} + the
pulp-annotated-capture-import CLI) is the no-metadata analog: feed it the bare
SVG plus a sidecar manifest that supplies the missing semantics per element,
and it emits the SAME artifacts (populated DesignFrameElement table +
DesignFrameView subclass + <snake>_svg.cpp + CMake paste-ins).
build/tools/import-design/pulp-annotated-capture-import \
--svg capture.svg --manifest ui_manifest.json --class ReverbPanelView
The sidecar schema is intentionally aligned with pulp-import-juce's
ui_manifest.json so a JUCE UI extractor (component-type → kind, bounds →
geometry, parameterID → param_key) can emit a manifest this lane consumes
directly. Each element declares {selector, kind, param_key, geometry, needle, options, …} (full field list in annotated_capture.hpp). When any element
carries a param_key, the generated ctor calls
route_changes_to_host_params(true) so the view runs unchanged embedded in
JUCE / iPlug2 / native. Gotcha baked into a regression test: emitted float
literals must be valid C++ (120.f, never the invalid 120f).
Under the hood: a 1:1 catalog component = subclass DesignFrameView with the embedded SVG.
See core/view/{include/pulp/view,src}/musical_typing_keyboard* +
design_system.cpp catalog entry: the class is
MusicalTypingKeyboard : public DesignFrameView, constructed from the
base64-embedded Figma SVG (musical_typing_keyboard_svg.cpp). Reskin/extend by
re-exporting the node and re-embedding — never re-draw by hand. The C++
codegen (generate_pulp_cpp) now lowers a faithful_svg node to a
DesignFrameView — it embeds the node's SVG as chunked base64 (resolved via the
shared resolve_svg_document, the same bytes the runtime materializer uses) and
reconstructs the typed interactive_elements overlays, so the generated C++ is
1:1, not just the runtime materializer. If the SVG asset can't be resolved at
codegen time it falls back to the native widget emit (so the output always
compiles). Remaining follow-ups: the JS emit path still lowers to native
widgets (a faithful frame needs a JS-bridge primitive that doesn't exist yet),
and --validate still renders the native-materialized output rather than the
faithful SVG.
Interactive-overlay kinds the IR carries end-to-end. The faithful_svg
interactive_elements IR (InteractiveElementKind in design_ir.hpp) supports
knob, fader, toggle, dropdown, text_field, tab_group, stepper, swap, action, xy_pad, value_label — each maps 1:1 in to_frame_elements()
(design_import_native_common.cpp) to the DesignFrameElement::Kind the runtime
already backs, and the schema (figma-plugin-export-v1.json
interactive_element.kind) accepts exactly that set. The schema segments
required per-kind: knob needs cx/cy/hit_radius; every other kind needs the
box x/y/w/h. Field map: fader translates svg_patch_d along the track;
toggle is a click-to-flip rect (a toggle WITH svg_patch_d is a switch; with
flash it is a press-flash command button); swap carries target_frame;
action carries the command id action; xy_pad adds default_value_y
(Y axis; X reuses default_value); value_label carries text +
value_left_align. When you add a kind, touch the whole chain in one commit —
schema → gen-types (types.generated.ts) → producer (faithful-vector.ts,
incl. detectOverlayControls if it should auto-detect) → IR enum →
design_ir_json.cpp parse/serialize → to_frame_elements() → the
design_cpp_codegen.cpp token switch + field emit — or it silently degrades.
detectOverlayControls auto-emits swap/action/xy_pad/value_label from explicit
whole-word node names (run AFTER the tuned dropdown/stepper/tab_group/text_field
detectors so those always win); the richer node-tree signals (prototype reactions
for swap, value patterns for value_label) land with P2's unified resolver. An
unknown kind string no longer silent-knobs: interactive_kind_from_id
reports it unrecognized and the parser emits a log_warn (the full ordered
resolution ladder + import report is the P7 work).
Binding an interactive element to a host parameter (param_key). A
faithful_svg interactive element carries an optional param_key (e.g.
"filter.cutoff") that flows schema → interactive_element.param_key → IR
IRInteractiveElement::param_key → to_frame_elements() →
DesignFrameElement::param_key. This is the binding channel for
geometry-detected controls — the Triaz case, where a knob is a custom design
component (not a recognized Pulp-Library widget), so the recognized-widget path
that lowers its binding through IRNode never fires. When ANY element in a frame
carries a non-empty param_key, make_faithful_svg_frame auto-enables
route_changes_to_host_params(true), so a user gesture drives the
framework-agnostic HostParamSurface (JUCE APVTS / iPlug2 / StateStore) directly
via element_for_param_key / sync_from_host_params. It is inert until a
producer emits a key: an all-unbound frame keeps routing OFF and behaves exactly
as before (the public on_element_changed / on_gesture_* callbacks fire
regardless of routing, so an existing consumer never changes).
Producer emission (both lanes). faithful-vector.ts (labelAndBindKnobs) and
figma_rest_export.py (_label_elements) resolve each geometry knob against the
frame's Figma node tree by position and stamp three things from the matched node:
a human label, provenance source_node_id, and — when the layer name carries an
opt-in param:/bind:/meter: sigil — a param_key. paramKeyFromLayerName /
_param_key_from_layer_name mirror the C++ figma_binding_from_layer_name
EXACTLY (leading-ws tolerant, case-insensitive sigil, trimmed value, ≥1 alnum), so
a sigil-named knob binds identically to a recognized widget. The sigil is
load-bearing: a bare/DESCRIPTIVE name (the real Triaz case — "Cutoff", "Res")
is NEVER auto-bound (verified: all Triaz panel captions classify as label with
zero false bindings in both lanes) — it gets a label + source_node_id so the
annotated-manifest lane can bind it by node id. Match tie-breaks: a sigil node
beats a plain-named one; within a rank, nearest center then smallest area; the
root frame is excluded so a panel name ("sound / main panel") never mis-binds a
centered knob. Tests: [layer-name-binding]/knob cases in
faithful-vector.test.ts + ParamKeyBindingTest in test_figma_rest_export.py.
Annotated-manifest binding (--param-binding-manifest) — the descriptive-name
lane. Real designs (Triaz) name knobs descriptively ("Cutoff"), not with a
sigil, so the producer stamps source_node_id but no param_key. Supply a JSON
object mapping that Figma node id to a host-param key —
{"10:42": "filter.cutoff"} — via pulp import-design … --param-binding-manifest bindings.json. apply_param_binding_manifest (design_import.cpp) walks the parsed
IR and sets param_key on each interactive element whose source_node_id is in
the manifest AND whose key is still empty — so an explicit layer-name sigil
always wins; the manifest never overwrites one. Applied once in the C++ import
CLI after IR parse (before the import report), so all downstream lanes
(materialize / codegen / DesignIR) see the binding. Get the node ids from the
importer's provenance or the Figma MCP get_metadata. Tests: [param-binding]
cases in test_design_import_ir.cpp (library) + test_import_design_tool.cpp
(CLI wiring + error paths).
Custom controls (P7 Tier-3) — the name→View factory registry. A genuinely
novel control resolves to kind=custom, which carries a factory_id (+ opaque
custom_props, typically JSON Pulp doesn't parse). The runtime
register_design_control_factory(id, factory) (design_frame_view.hpp) maps an
id to a std::function<unique_ptr<View>(const DesignControlContext&)>;
DesignFrameView::build_overlays looks the factory up for a Kind::custom
element and builds the overlay. UI-thread-only (registration at host startup,
lookup at overlay build) — the registry has no locking by contract. If no factory
is registered the element renders INERT (the baked SVG still shows) and
make_faithful_svg_frame emits a native-materialize-custom-factory-unregistered
diagnostic — a custom control never blanks or silent-knobs. Schema requires
factory_id for kind=custom. This is the piece a shared control PACKAGE (P8)
registers into. Beyond the usual atomic chain, the two exhaustive
DesignFrameElement::Kind switches in design_frame_view.cpp
(element_value/set_element_value) need the custom case, and the inspector's
frame_element_kind_name switch in inspect/src/inspector_window.cpp.
Import report (P7). Implementations of the import-report and
placement-verification passes live in core/view/src/design_ir_analysis.cpp
(extracted from design_ir_json.cpp, which is the IR JSON serialization
contract — keep the analysis passes there, not in the serializer).
collect_import_report(ir.root) (design_import.hpp)
walks the IR's interactive elements and surfaces each control's resolution
provenance — {source_node_id, kind, resolution_rung, confidence_score, conflict_signals, verification_pass} — plus summary counts (conflicted /
low_confidence / unresolved) and ok(). pulp import-design prints the
human summary (import_report_to_text) to STDERR for EVERY output mode (codegen
- DesignIR-v1), writes the machine-readable JSON (
import_report_to_json) when
--import-report <path> is given, and --fail-on-unresolved makes a conflicted
or inert control a nonzero (2) exit — the CI gate. So a low-confidence or
conflicted control is SEEN at import time, never discovered later in the DAW.
apply_placement_verification(ir.root, frame_w, frame_h) runs first (the
structural half of the render-golden gate): it flags an overlay with no
renderable extent (zero hit-radius AND zero-area box) or one entirely outside the
frame region — setting verification_pass=false + a conflict so the report/gate
catch it. Frame size 0 = "unknown" (skips the bounds half, keeps the
degenerate-extent check). The full PIXEL-level golden diff is the render-path
follow-up.
Multi-frame / post-processed components need a DEDICATED re-embed lane —
make_catalog_component.py is single-frame and applies no neutralization. The
Musical Typing Keyboard is TWO frames (typing 187:15 / piano 187:349) AND its
exported SVG is post-processed: the design bakes a "selected keys shown" demo
chord as lit #16DAC2 key gradients, which must be NEUTRALIZED to the resting
key color (the live momentary overlay owns all pressed-state lighting) or the
[regression] no baked-lit demo chord test trips. So it has its own
tools/import-design/reembed_mtk.py: fetch both nodes via /images?format=svg,
neutralize, emit the chunked-base64 cpp. Neutralization is content-based, not
positional — a lit gradient is a <linearGradient> with both stops #16DAC2 +
a 0.26→1.0 opacity ramp; classify white (gradient y-extent ≥ 65 → #EBEEF1)
vs black (< 65 → #3A3F47/#16191E) so it survives Figma edits. --validate
asserts the decoded SVGs still match the committed file. Gotcha — strip the
opacity: the lit gradient's top stop is 0.26 opacity (the design's press
fade). Neutralization must REMOVE that stop-opacity="0.26", not just swap the
hex — a resting key is SOLID #EBEEF1, so a was-lit key that keeps the 0.26
renders translucent over the dark bed and reads GRAY beside its solid neighbours
(the "gray E4" report). Gotcha — reflow: removing a toolbar element (e.g. the
top-right OCTAVE/VEL cluster) lets Figma's flex REFLOW siblings — the overview
strip widened (right edge 151→677) and the > arrow moved (~550→689). Any
hardcoded element/overlay coords in musical_typing_keyboard.cpp (strip bounds,
arrow rects) must be re-derived from the regenerated SVG after such an edit.
Lesser gotchas:
--validate's "Similarity %" also breaks on size mismatch (it renders at
--render-size, often 2× the reference) — always diff at matched dimensions
with verify_region.py (per-tile).
--render-size must match the node aspect or content letterboxes → inflated diff.
- Skia backend is mandatory for image/asset compositing (
--screenshot-backend skia).
This pairs with the upstream Figma-import toolkit (tools/figma-import/, which
captures the design HTML→Figma 1:1): Figma is the single source — design HTML
→ Figma (figma-import) → Pulp (this lane). Keep both ends improving from each
import's lessons.
CRITICAL: pulp-design-tool requires the GPU host (PULP_HAS_SKIA)
Before debugging any runtime-import resize / sizing / layout issue in pulp-design-tool or /tmp/<App>.app, verify the binary is using MacGpuWindowHost, not the CPU MacWindowHost. The design viewport pin, aspect-lock, and uniform paint-scale all live in MacGpuWindowHost (gated by #ifdef PULP_HAS_SKIA in core/view/platform/mac/window_host_mac.mm). When Skia isn't linked, WindowHost::create() returns MacWindowHost, where set_design_viewport() and set_fixed_aspect_ratio() are base-class no-ops — the example still builds and runs, but resize behaves as if every fix you've shipped is missing.
One-shot verification:
nm build/examples/design-tool/pulp-design-tool 2>/dev/null | grep -q MacGpuWindowHost \
&& echo "OK: GPU host present" || echo "FAIL: CPU-only build"
strings /tmp/MyApp.app/Contents/MacOS/MyApp-Bin | grep -F "[gpu-host]" \
&& echo "OK: GPU host present" || echo "FAIL: CPU-only build"
Recovery when Skia is missing (e.g. fresh worktree with external/skia-build/ containing only headers):
- Reuse the primary checkout's populated cache:
rm -rf external/skia-build
ln -s /Users/<you>/Code/pulp/external/skia-build external/skia-build
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
- Or run
tools/build-skia.sh to rebuild Skia binaries from scratch (~30 min).
- Or pass
-DSKIA_DIR=/abs/path/to/external/skia-builder/build/<plat>-gpu.
Defenses already shipped (don't bypass without reading the comments):
examples/design-tool/CMakeLists.txt issues FATAL_ERROR at configure if PULP_HAS_SKIA is FALSE — design-tool is intentionally a GPU-only example.
examples/design-tool/main.cpp checks #ifndef PULP_HAS_SKIA at startup and exits with EX_CONFIG (78) + a loud stderr message.
tools/cmake/PulpDependencies.cmake already prints a screaming WARNING banner when PULP_ENABLE_GPU=ON but Skia isn't found; the release lane (PULP_REQUIRE_GPU_FOR_SDK=ON in release-cli.yml) escalates to FATAL_ERROR.
If your runtime-import test produces a window where content doesn't scale, the dark fill is visible past the design surface, or aspect-lock doesn't engage during drag, the FIRST thing to check is nm | grep MacGpuWindowHost. Don't tune set_design_viewport / windowWillResize: / setContentAspectRatio: until you've confirmed the GPU host is actually linked.
Detect which design source the user wants by checking:
- If a Figma MCP server is available (com.figma.mcp), offer to read the current file/selection
- If Stitch MCP is available (mcp__stitch__*), offer to list projects and get screens
- If Pencil MCP is available (mcp__pencil__*), offer to read the current editor state
- If the user mentions Claude Design or hands over a manually-exported HTML file from Anthropic Labs' Claude Design tool, treat as
--from claude (no MCP — Anthropic has no public API; manual file export is the supported path, and Spectr's editor.html mapping is the precedent)
- If the user provides a file path or URL, use that directly
- If none of the above, ask the user for a source and file
Workflow
Imported designs: declare dimensions in CMake, never hand-roll view_size()
Pulp's SDK auto-sizes plugin editors from a single pair of CMake args, so
every imported-design plugin opens at the right size in AU / AUv3 / VST3 /
CLAP / Standalone with no per-format override. Always use the CMake path
— don't hand-roll Processor::view_size() for imported designs.
pulp_add_plugin(MyPlugin
FORMATS AU AUv3 VST3 CLAP Standalone
...
DESIGN_WIDTH 900 # preferred editor width in logical pixels
DESIGN_HEIGHT 520 # preferred editor height in logical pixels
# Optional explicit bounds; omit for auto-derivation:
# min = preferred * 2/3
# max = preferred * 2
# aspect_ratio = width / height
# DESIGN_MIN_WIDTH 700
# DESIGN_MIN_HEIGHT 400
# DESIGN_MAX_WIDTH 1920
# DESIGN_MAX_HEIGHT 1080
SOURCES my_plugin.hpp my_plugin.cpp
)
Mechanism:
pulp_add_plugin injects PULP_PLUGIN_DESIGN_W/H/MIN_W/... as
target_compile_definitions on ${target}_Core (PUBLIC, so every
linked format adapter sees them).
format::Processor::view_size()'s default checks for the macros and
calls view_size_from_design(w, h, ...) to derive the full ViewSize.
- The derived
min > 0 is what enables CLAP's gui_can_resize
(core/format/include/pulp/format/clap_entry.hpp:391) and prevents
the corner-drag-crops-instead-of-resizes regression that landed on the
m149 branch and bit us in Reaper.
Symptoms when this is missing (any of these = you forgot the CMake args):
- CLAP / VST3 corner-drag crops instead of resizes. Default
min = 0
fails gui_can_resize, host treats the editor as non-resizable.
- Plugin opens at a random portrait size after re-launch. With no
design-side dimensions and no saved window rect, hosts fall back to
the format's default content area (often portrait ~360×480).
How to choose the numbers:
- Open the imported JSX bundle in
pulp-screenshot --script <bundle.js>
and try --width / --height values until the layout looks right with
no letterboxing or content clipping. Those become DESIGN_WIDTH/HEIGHT.
- Trust the auto-derivation for min/max unless you have a specific reason
to clamp tighter (e.g., a widget that breaks below 700×400).
- The derived
aspect_ratio = width/height makes the host snap the
corner-drag to the design AR (Phase 3 design viewport letterboxes the
rest).
Stage B of #2784 — pulp import-design auto-emitting a .size.json
sidecar that pulp_add_plugin reads, so the dimensions are declared
once at import time — is the immediate follow-up. Until then,
DESIGN_WIDTH / DESIGN_HEIGHT is the codified path; the C++
view_size_from_design() helper is unit-tested in
test/test_processor_defaults.cpp.
Do not write a view_size() override for an imported-design plugin
unless you have a runtime-computed dimension (rare; usually a custom
non-script UI). Hand-rolling reintroduces the per-plugin maintenance
burden the SDK args were designed to eliminate.
DesignIR v1 asset manifest lane
When a user asks for canonical IR or an import pipeline handoff, prefer
pulp import-design --emit ir-json. The output is a versioned DesignIR v1
envelope with a deterministic assetManifest sidecar. Local images, SVGs,
font URLs, CSS url(...) values, and data URIs are recorded by default.
HTTP(S) assets are resolve-only unless the user explicitly passes
--allow-network-fetch; fetched assets use --asset-cache, honor
--asset-timeout-ms, and can be pinned with repeated
--asset-hash <uri=sha256> flags.
For --url imports, relative asset references resolve against the original
source URL, not the temporary downloaded file. The manifest keeps the authored
value in original_uri, stores the resolved fetch target in source_url, and
nodes keep their raw URI attributes plus a stable companion such as
srcAssetId or backgroundImageAssetId.
DesignIR v1.5 also carries document-level provenance (capture_method,
settle_rounds, fallback_reason, source_adapter, source_version,
imported_at) and structured top-level diagnostics. Parse APIs return the
shared normalized form, including interactive-frame promotion from the Pulp
view library.
The shared native-resolution layer lives in
core/view/src/design_import_native_common.{hpp,cpp}. It consumes normalized
DesignIR plus IRAssetManifest and returns a deterministic
ResolvedNativeNode tree for later baked-native/baked-cpp materializers.
Mapping precedence is audio_widget first, then IRNode.type, then HTML
subtype attributes such as input[type=range], with unsupported nodes and
properties degrading to diagnostics instead of throwing. When resolving frozen
DesignIR JSON, keep the embedded assetManifest fallback intact; do not
iterate IRNode.attributes directly when diagnostic order matters because it
is unordered. Use sorted attribute keys for stable output.
The baked-native materializer is the direct View-tree lane:
build_native_view_tree(const DesignIR&, const IRAssetManifest&, const NativeMaterializeOptions&) is public in
<pulp/view/design_import.hpp>. It calls the shared native resolver, returns a
detached std::unique_ptr<View> subtree, and catches API-boundary failures into
diagnostics instead of throwing. Keep image assets routed through
IRAssetManifest::resolve(asset_id); never interpolate raw filesystem paths
from IR attributes.
Self-contained JS export (relative asset paths). The emitted ui.js
never references decode-time locations: after resolve_sprite_skins stamps
absolute paths, localize_ir_assets (sprite_skins.cpp) copies every
referenced image/font into assets/ NEXT TO the --output file and rewrites
attributes["asset_path"] / font.resolved_path to output-relative
assets/<file> before codegen. This is load-bearing for the .fig lane,
whose scratch dir ($TMPDIR/pulp-fig-*) is deleted when the run exits — an
export that kept absolute paths would silently lose all images on any later
render. Renderers resolve the relative form via
WidgetBridge::set_script_base_dir(<script dir>) (set by --validate,
pulp-screenshot, and pulp-design-tool; unset base = historical
CWD resolution). It resolves setImageSource / setKnobSpriteStrip /
registerFont / loadFont only, and unlike set_asset_roots() it never
restricts loadAsset. Skipped for --dry-run (must not write files) and
baked emits (cpp codegen takes no asset paths). make_scratch_dir also
sweeps stale (>24h) pulp-<tag>-* siblings — runs killed mid-decode leak
their scratch dirs, and hundreds had accumulated before the sweep existed.
When testing emitted JS, note the unquoted // Source: header comment still
names the decode-time input; assert on QUOTED paths ('assets/...') when
pinning "no absolute references".
Windows path-separator gotcha (asset/font paths baked into generated JS):
when the CLI's asset pass (resolve_sprite_skins in sprite_skins.cpp) resolves an asset_ref/font asset_id to a path
that is stamped into attributes["asset_path"] / font.resolved_path (and from
there into setImageSource(...) / registerFont(...) in the generated JS),
convert it with fs::path::generic_string(), NOT .string(). On Windows
.string() emits native backslashes, which (a) bakes non-portable separators
into the generated UI and (b) breaks tests that assert on assets/...
substrings. generic_string() always emits /, and Windows file APIs accept
forward slashes. On POSIX the two are identical, so the change is a no-op there. The materialization call site itself should not run JS, but
do not market this as "no JS engine" globally because live React/parity lanes
still use the JS runtime. Baked/native consumers can link pulp::view-core;
live import, ScriptEngine, WidgetBridge, and scripted UI consumers should
link pulp::view-script or the full compatibility target, pulp::view.
The baked-cpp exporter emits native C++ source from the same resolved tree via
generate_pulp_cpp(const DesignIR&, const IRAssetManifest&, const CppExportOptions&). CLI usage is pulp import-design --mode baked --emit cpp --output imported_ui.cpp; the tool writes the sibling .hpp by default.
Generated code should contain direct widget construction, stable anchor IDs,
token/asset constants, bake_asset_manifest(), and TODO comments for unresolved
audio parameter or meter bindings. Knob current value and reset default are
distinct: emitted set_value(...) may come from the normalized value