| 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.
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 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.
- 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.
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.
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. The .fig
decoder lowers this by moving the masked siblings into a synthetic
<mask name> (mask scope) wrapper (spans the parent, audio_widget: 'none',
node_id <maskKey>/mask-scope) whose style.clip_path = path("<d>") carries
the mask outline in PARENT space — geometryToClipPath (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, and box-model masks
(rect/ellipse, no geometry blobs) get a synthesized outline. The chain
downstream already existed end-to-end (parse_ir_style('clipPath') →
codegen setClipPath → SkPath::FromSVGString clip); only the extractor
never emitted it. Siblings BELOW the mask stay outside the wrapper (Figma's
scope), soft/image alpha masks and auto-layout parents degrade with a
mask-approximated warning — but the mask itself is never painted, in any
branch. 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).
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
vectorStrokeBand = resolved.paint === 'stroke' and skips the border for those
nodes only; a real filled vector with a separate stroke (paint === 'fill')
keeps its border. Covered by a materialize-level test in fig/paths.test.mjs
(stroke band → no border; fill+stroke → border). One general fix cleared the
triangle weight AND the transport △○□ button rings at once.
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.
Windows path-separator gotcha (asset/font paths baked into generated JS):
when pulp_import_design.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
attribute, while set_default_value(...) must come from normalized
audio_default. Preserve duplicate token names even when their values alias,
and emit non-hex semantic color tokens as strings rather than trying to parse
them as colors.
figma-plugin binding → canonical pulp* binding contract
The figma-plugin extractor (tools/figma-plugin/) exports a recognized Pulp
Library control as an audio widget (audio_widget) plus a single free-form
attributes["binding"] string (e.g. "filter.cutoff_hz"). The whole native
binding pipeline — the materializer (design_import_native_common.cpp,
NativeBindingMetadata::parse) and the binding-manifest codegen
(design_cpp_codegen.cpp) — only consumes the pulp*-prefixed contract, so a
raw binding string is invisible to them on its own.
parse_ir_node (design_ir_json.cpp, normalize_figma_plugin_binding)
normalizes that string into the canonical pulp* contract at the IR-ingest
boundary, so there is exactly ONE downstream binding consumer. Rules to keep in
mind when touching this:
- Recognized-widget gate. Synthesis only fires when
audio_widget != none.
A generic/visual frame that happens to carry a binding attribute gets NO
synthesized binding — it stays a generic node. Don't loosen this gate.
- Opt-in LAYER-NAME binding (
figma_binding_from_layer_name). A recognized
widget can declare its binding WITHOUT the explicit binding component
property, via a sigil-prefixed layer name — param:, bind:, or meter:
followed by "<module>.<param>" (e.g. a knob layer named
param:filter.cutoff_hz). normalize_figma_plugin_binding resolves the binding
from the binding attribute FIRST, then falls back to the layer-name sigil;
the rest of the lowering (module/param split, param-vs-meter routing by
audio_widget, pulp* synthesis) is identical either way. The sigil is
load-bearing: a bare/ordinary layer name is NEVER treated as a binding (no
false positives from names like "Big Cutoff Knob") — only a control the designer
explicitly tagged binds by name. An explicit binding attribute always wins
over the name. Case-insensitive on the sigil. This is the recognized-widget
path; binding a geometry-detected faithful-vector overlay (a knob the importer
found by geometry, not a Pulp Library component) by layer name is a separate
param_key-on-IRInteractiveElement path — keep the sigil convention identical
across both when you wire it. Pin any change with a
[layer-name-binding] case in test_design_import_sources.cpp.
- Name→kind resolution matches whole WORD TOKENS, not substrings — in ALL
THREE lanes. The C++
detect_audio_widget, the TS
audioWidgetKindFromName (extract-pure.ts), and the Python
widget_kind_from_name (figma_rest_export.py) all tokenize the layer name on
non-alnum + camelCase (acronym-aware, so VUMeter→{vu,meter}) + letter↔digit,
then match whole tokens (simple plural tolerated: Knobs→knob). The TS/Python
lanes were substring-based until the P2 resolver unification; all three now
share the boundary rule (mirrored, not one function — each has its own
vocabulary/return enum). This is deliberate: the old find()/includes()/in
substring match promoted Dialog/Radial→knob and Parameter/Diameter→meter
(gap survey). Don't revert any lane to substring matching; add new keywords as
tokens + a false-positive regression case (test_design_import.cpp,
audio-widget-name.test.ts, test_figma_rest_export.py). Lockstep is on the
VOCABULARY too, not just the boundary rule: the meter/waveform/spectrum aliases
(level, oscilloscope, analyzer/analyser) must exist in all three lanes —
the Python lane silently lagged on these until an adversarial-review follow-up,
so when you add a token to one lane, add it to the other two and pin it in each
lane's test in the SAME change. The faithful-vector
overlay lane's kindFromName (resolve-control.ts, P7) shares the same
whole-word convention for its own (InteractiveElementKind) vocabulary.
Component content is designer art — the audio_widget: "none" opt-out
Name-token recognition must NEVER fire inside a component's own content: a
layer literally named "knob base" / "knob ring" IS the designer's knob art, and
promoting it paints Pulp's built-in silver knob over the design. The contract
(same across lanes):
- The explicit opt-out: an envelope node with
audio_widget: "none" is a
real statement, not an absence — parse_ir_audio_widget
(design_ir_json.cpp) treats a literal "none" as explicit and skips
detect_node_audio_widget for that node. Only "none" opts out; unknown
strings still fall through to detection. The recognition resolver still runs
afterwards (it is keyed on component identity, not names), so a matched
library component becomes a real widget regardless.
- The
.fig lane (tools/import-design/fig/scene.mjs) expands INSTANCE
nodes into their master SYMBOL subtree (override guidPaths resolve by guid OR
overrideKey; multi-segment paths forward into nested instances), stamps the
instance + every expanded node "none", and emits
figma.component_key / main_component_name from the master.
- The REST lane (
figma_rest_export.py::walk) stamps "none" on (1) any
INSTANCE / COMPONENT and its whole subtree — identity comes from the /nodes
components + componentSets maps, preferring the SET key (that is what the
resolver tables are keyed by); (2) any DETACHED copy — a widget-named frame
that directly owns raw shapes (_owns_shape_art) — one drawn widget whose
parts are art; and (3) any widget-named CONTAINER it declined to promote —
the pin matters because asset capture can collapse child containers into leaf
images, and the C++ heuristic re-run on that DEGRADED envelope would
re-promote the parent without it. Name promotion survives only for EMPTY /
text-only placeholder frames, where there is no art to destroy.
- Known asymmetry (follow-up): the TS plugin lane (
extract.ts) and the
C++-side detect on .fig DETACHED copies do not stamp the opt-out yet, so a
detached knob's shape leaves can still be name-promoted there. Fix belongs in
the shared detect gate or per-lane stamping — mirror the REST rules.
- Pinned by:
test_figma_rest_export.py (instance/detached/pin cases),
fig.test.mjs (expansion + opt-out contract), and the
explicit audio_widget 'none' suppresses name-based widget detection case in
test_design_import_sources.cpp (with a promoted control sibling).
KEY-based recognition + the recognition-resolver merge module
NAME-token recognition (above) is a fallback. The AUTHORITATIVE recognition
signal is the Figma component identity — a component_set_key. This is a
SEPARATE mechanism from the 3-lane name-token vocabulary; do not conflate them.
-
The merge module is the single source of truth. core/view/.../recognition_resolver.{hpp,cpp}
(RecognitionResolver) is the ONE place that combines recognition SOURCES
into a merged component_set_key → kind (and → factory_id) table. Sources,
in precedence order (later wins on key collision):
- built-in Pulp Figma Library (
RecognitionResolver::with_builtin_library(),
mirrored in code from tools/figma-plugin/library-manifest.json and pinned
against the JSON by a drift-guard test),
- the user's
--recognition-manifest (flat library-manifest shape),
- installed-package
design_controls fragments (custom controls) —
gathered by discover_package_design_controls() (walks up for the project's
packages.lock.json, then reads the registry at tools/packages/registry.json
— the canonical CLI layout from find_registry_path, NOT a lockfile sibling;
a wrong path silently merges zero packages — builds ONE RecognitionSource
per installed package that declares any design_controls, named by package id)
and added via add_source(...) ONCE, in the same resolver-build block, NOT
by threading a third lookup through the importer lanes. Do not scatter the
merge. Any new recognition source becomes one more add_source call.
A package fragment carries factory_id (no built-in kind), so a match
routes to the custom-control materialize path below. With no custom-control
package installed this contributes zero sources, so behavior is unchanged.
-
--recognition-manifest <path> lets a designer map their OWN component-set
keys / name prefixes to Pulp kinds. Shape (mirrors library-manifest.json):
{ "widgets": { "<name>": { "kind"?, "component_set_key", "name_prefix"?, "factory_id"? } } }.
kind defaults to the widget's map key. factory_id (no kind) is the
custom-control path: a match resolves to a registered native overlay instead
of a built-in widget (same shape installed-package design_controls use).
Harvest keys from the Figma MCP search_design_system.
-
Which lane is wired (authoritative): the C++ CLI figma-plugin lane. The
plugin envelope carries each instance's figma.component_key /
main_component_name EVEN when the in-Figma TS plugin did not recognize it
(a third-party component) — parse_ir_node stamps these into
attributes.figmaComponentKey / figmaMainComponentName. After parse, the
CLI (pulp_import_design.cpp, figma / figma-plugin sources only) builds the
resolver (built-in + optional user manifest) and calls
apply_recognition_resolver(ir.root, ...), which stamps audio_widget on any
instance that matched but was not already recognized. This is the lane that
turns a pixel-faithful-but-0-controls third-party design (the live Ink &
Signal "NumberBox" case) into a wired one.
-
The TS plugin (extract.ts → widgetKindByLibraryKey) bakes recognition
at CAPTURE time for the built-in library only; it is NOT yet wired to a
user manifest (it runs in the Figma sandbox; feeding it a manifest needs
plumbing through the plugin UI). The Python REST lane no longer bakes
widget kinds for components at all — it emits each instance's
figma.component_key / main_component_name (from the /nodes components +
componentSets maps, SET key preferred) and lets the C++ resolver do all
key-based recognition, so --recognition-manifest works for URL imports.
Follow-up: accept the user manifest in the TS lane too.
-
Never-silent-knob (P7) holds. A component instance present in the design
but matched by NO source is NEVER guessed into a kind — apply_recognition_resolver
collects it into UnmatchedComponent[], which the CLI surfaces as an
unmapped-component import diagnostic. Additive guarantee: no manifest + no
resolvable third-party key ⇒ behavior unchanged; an already-stamped
audio_widget is never overridden.
-
Custom-control materialize half (the package lane's runtime side). A
custom-factory match has no built-in audio_widget to stamp — instead the
resolver records the recognitionFactoryId node attribute. The CLI then runs
materialize_recognized_custom_controls(ir.root) (same module), which converts
every such node into a kind=custom IRInteractiveElement carrying that
factory_id + the node's geometry. The native materializer
(make_faithful_svg_frame → to_frame_elements) builds the overlay via the
factory the package registered with register_design_control_factory. An
unregistered factory renders inert (the baked SVG still shows) AND emits the
native-materialize-custom-factory-unregistered diagnostic — never a silent
knob. The conversion is idempotent and additive (a node with no
recognitionFactoryId is untouched).
-
Merge ordering is deterministic and pinned. Package sources are gathered
in lockfile order; the resolver merges later sources OVER earlier ones, so on
a component_set_key (or name_prefix) collision the LAST-added package wins.
Tests pin this so a re-order is a visible change, not a silent one.
-
Module/param split. Split on the FIRST .: "filter.cutoff_hz" →
pulpBindingModule="filter", pulpBindingParam="cutoff_hz",
pulpParamKey="filter.cutoff_hz". No dot → empty module, whole string is the
param. param_key (non-empty) is what drives both the manifest entry and the
codegen bind_knob/bind_fader helper gate.
-
Meters differ. knob/fader/waveform map to a writable param
(pulpParamKey); meter/spectrum map to pulpMeterSource +
pulpMeterChannel (no pulpParamKey), since a meter reads a metering input.
-
pulpRouteId is required for the codegen helper to emit a live bind call;
it's synthesized deterministically as "figma-plugin:<binding>".
-
No-overwrite / no-regression. Synthesis routes through
NativeBindingMetadata::serialize() (skip-empty, no-overwrite) and bails if
any canonical binding attr is already present, so a JSX/Claude node that
already carries pulp* is untouched. The raw attributes["binding"] is
always preserved — never delete the source evidence.
-
This is a generalizable importer rule, not a per-fixture patch: it reads
the figma-plugin data and produces the contract for ANY recognized widget.
Skinned fader/meter width derivation
Recognized faders/meters must render their track/fill/bar at the captured art's
NARROW inset width, not the full node box. The sampler in
core/view/src/widget_skin_derive.cpp recovers horizontal extents from the
captured PNG pixels (row_art_bounds scans opaque pixels OUTWARD from the centre
column cx, so disjoint label glyphs on the same row never widen the result):
- Meter:
derive_meter_skin reports bar_width_px = median opaque row width
inside the bar's OWN vertical region [top, bottom) (found via find_art_region
on cx). That excludes the label text below the bar.
- Fader:
derive_fader_skin reports thumb_width_px (widest opaque row = the
silver slab) and track_width_px (median of the NARROW rows ≤ ~40% of the
widest = the thin track/fill column).
Gotchas learned wiring this:
- The widest row in the whole asset is usually the label text, not the thumb.
find_art_region/cx scoping is what keeps the thumb measurement honest — do
not measure the widest row over the entire image.
pulp_import_design.cpp divides art px by asset_scale = img.width / node_box_width_px (figma-plugin exports at 2×, but DERIVE it, don't hardcode
2). It stamps shape_width = thumb/bar width (→ widget width) and
skin_track_width (fader only). The column min_width keeps the box width so
the narrow widget centres.
- Render path: meter codegen reads
shape_width → widget width (already wired);
fader needs BOTH — shape_width → widget/thumb width AND setFaderTrackWidth
→ Fader::set_skin_track_width, which makes Fader::paint draw the track at
exactly that thin centred width instead of 0.18 * box.
- Verify by reference-diff, never by eyeball. Measure visible-art width as a
% of the node box in BOTH the captured asset and the rendered PNG; target
within ~15%. For the smoke export the derived values were fader-track 5px
(5.2% box), fader-thumb 28px (29% box), meter-bar 18px (26% box), all matching
the reference within 3%.
- Everything is derived from sampled pixels / node data — NO per-instance or
hardcoded pixel constants (repo rule: every visual importer fix must be a
generalizable rule reading the design data).
Native codegen fidelity gaps
The render uses generate_native_node in core/view/src/design_codegen.cpp
(createCol/createRow/createLabel/createKnob/setFlex…), NOT the generate_node
DOM path. Several styles only existed on the DOM path; the native path needs its
own emission. Fixes landed here, each grounded in the export data:
- Nested padding. The figma-plugin export sends container padding as a nested