| 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.
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.
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 \
--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 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 — 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.
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.
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.
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) and the Python REST
lane (figma_rest_export.py) bake recognition at CAPTURE time for the
built-in library only. They are NOT yet wired to a user manifest (the TS lane
runs in the Figma sandbox; feeding it a user manifest needs plumbing through
the plugin UI). Follow-up: accept the user manifest in those two lanes too.
Until then, the C++ CLI lane is the single source for user-manifest recognition.
-
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
object
layout.padding = {top,right,bottom,left}. parse_ir_layout in
design_ir_json.cpp only understood a uniform float / camelCase per-side keys,
so the nested form was dropped (→ 0, content hugged the edge). The parser now
accepts all three forms; the native container path already emitted per-side
setFlex(id,'padding_*',…), so once parsed it renders.
- Text wrap. A text node's
style.width was emitted only as min_width, so a
long subtitle ran off the panel. Emit a hard setFlex(id,'width',w) +
setMultiLine(id,true) ONLY when the design box is taller than one line
(height > font_size*1.6) — that's the design's own signal that the string is a
wrapping paragraph. A one-line title (height ≈ one line) is deliberately NOT
bounded: forcing its hug-width as a wrap box makes it wrap when Pulp's font
metrics run a hair wider than Figma's.
- Knob value taper. The native silver knob maps a 0..1 value LINEARLY to its
[-135°,+135°] sweep, so the imported value must already encode the param taper.
The knob path emitted the RAW
audio_default (e.g. 880), which set_value
clamps to 1.0 (and a linear normalise put 880 Hz at ~0.04 — indicator pointing
the wrong way). Now: for a frequency-unit knob (units == hz/khz) use a LOG
normalise so 880 Hz in [20,20000] lands ~0.55 (indicator ~straight up, matching
the design); other units fall back to linear (value-min)/(max-min). The fader
already normalised; the knob was the outlier.
- font_weight/font_family were ALREADY emitted on the native text path — no
fix needed; a regression test now pins them.
- Fader empty-track outline. The captured empty track has a faint lighter
edge.
derive_fader_skin first tries to RECOVER it (brightest low-sat pixel on
a dark track row vs the row-centre fill). Gotcha: the importer's in-tree
minimal PNG decoder (decode_png_rgba in pulp_import_design.cpp) FLATTENS the
sub-pixel anti-aliased rim — it reads the whole thin track column as uniform
fill, so the edge is unrecoverable from those pixels even though PIL sees it.
Fallback: SYNTHESISE the rim by lightening the sampled dark track colour
(lum < 90 → +30 per channel); a light/flat track stays borderless. Emitted
via setFaderTrackBorder(id,'#rrggbb') → Fader::set_skin_track_border_color,
which strokes the track rect in Fader::paint. Still derived from the captured
track colour — no hardcode.
- Knob bevel depth (the silver knob reads more heavily 3D than the flatter
captured disc) is a
WidgetRenderStyle::silver cosmetic gap, NOT data-driven —
left as a follow-up rather than guessing gradient constants that would affect
every imported knob.
The Phase 5/7/9 benchmark harness lives at pulp-design-import-bench and is driven
by tools/scripts/design_import_benchmark.py. Run it under no-launch env
(PULP_DISABLE_PLUGIN_EDITOR=1 PULP_HEADLESS=1 PULP_TEST_MODE=1 PULP_INSPECTOR_NO_LAUNCH=1). It compares live, baked-native, and
baked-cpp lanes, emits startup/idle/interactive metrics, and computes the
Phase 9 gate from linked text+data section size using size/llvm-size; after
the target split it tracks live-runtime objects under pulp-view-script. Do
not use Debug object-file byte counts as the gate input. A valid report must
record the explicit binary-size delta and whether JS evaluation churn is
actually the dominant bottleneck for the measured fixture. Keep the legacy
top-level comparison entry pointed at baked-native, and add per-lane
results under comparisons for both baked lanes.
Vector shape primitives → synthesized SVG path
vector/path/svg_path nodes carrying an authored path_data (d) already
lower to a native SvgPathWidget (createSvgPath+setSvgPath).
The shape PRIMITIVES — rect/rectangle/svg_rect, line/svg_line,
ellipse/circle, polygon, star — usually arrive with NO d, so they used
to drop to an empty frame (caught by the dropped-vector invariant).
synthesize_primitive_paths (in core/view/src/design_import.cpp, declared in
design_import.hpp) now derives a d from geometry and stamps it onto the node
so codegen lowers it like any other path. Key facts / gotchas:
- Runs as a codegen pre-pass, on a copy.
generate_pulp_js copies the IR
root in the native arm, runs synthesize_primitive_paths, then emits AND runs
the dropped-vector fidelity walk over that copy — so both see the synthesized
path_data. The caller's IR and the web-compat arm are untouched. (Putting it
in the parse pipeline instead would miss the [object-coverage] drift guard,
which calls generate_pulp_js directly on hand-built nodes.)
- Drop-case ONLY — zero behavior change for renderable nodes. It fires only
when a primitive has no
path_data, no children, no visible fill
(background_color/gradient/image), no asset_path, and is not an audio widget.
A filled rect still renders via the generic-frame branch; a rect with children
keeps them. Don't widen this to filled/childful nodes — converting them to a
terminal SvgPath would drop their children / box-shadow / border.
svg_fill is forced to "none". SvgPathWidget's default fill is OPAQUE
BLACK (has_fill_=true, {0,0,0,1}). A synthesized shape with no IR fill must
emit setSvgFill(id,'none') (→ clear_fill()) or it paints a phantom black
box. A border becomes svg_stroke/svg_stroke_width.
- Geometry only — source-agnostic. Paths are derived from
width/height,
per-corner border-radius (rounded rect via SVG arcs; the SvgPathWidget
parser supports H/V/A), and optional pointCount (polygon default 3,
star default 5) / innerRadius ratio (star default 0.5) ATTRIBUTES — never a
layer name. A line may have one zero extent (a horizontal/vertical rule).
MSVC gotcha: the polygon/star angle math must NOT use M_PI — MSVC does
not define it without _USE_MATH_DEFINES, which broke the Windows CLI build
(and the whole release pipeline) once. Use the local kSynthPi constant.
- Release-runner toolchain gotcha (C++20 P0960): the GitHub-hosted macOS
release runner's Apple clang is OLDER than the self-hosted PR-lane clang and
does NOT implement parenthesized aggregate initialization (
Type p(arg) for
a ctor-less aggregate), even at -std=c++20. So JsonParser p(snap.html_text)
in import_detect.cpp compiled on PR CI but failed the release build ("no
matching constructor"), silently breaking every GitHub Release from ~v0.371 to
v0.391 (the tag-triggered sign-and-release.yml / release-cli.yml Build step
died; tags kept getting created so the breakage was invisible until the
release-cadence watchdog flagged the tags-without-Releases). Always brace
aggregate init (Type p{arg}) in CLI/import code — it's valid on every
toolchain. (PR CI cannot catch this class; it only surfaces on the older
release runner.)
polyline is intentionally NOT synthesized — it is an open run of explicit
points that geometry alone can't reconstruct; it stays codegen: missing
(carry path_data or rasterize at export).
is_vector_kind is the shared classifier. Exposed from
design_fidelity.hpp; codegen's is_path_kind and the dropped-vector
invariant both call it so they never disagree about what is a path node.
- Source of truth:
compat.json imports/object-coverage (these 9 types are now
codegen: handled) + the [object-coverage] drift guard + the
[view][import][codegen][vector] tests.
Figma resize constraints → flex/position
Figma layout constraints (a node's resize behavior relative to its parent)
parse into IRLayout.h_constraint / v_constraint (normalized tokens
left|right|center|scale|stretch and top|bottom|center|scale|stretch) and
lower to flex at codegen. Facts / gotchas:
- Parse (
design_ir_json.cpp, parse_ir_node): reads constraints: {horizontal, vertical} at node level, also under a figma{} block, also
inside layout{} — first non-empty wins. Figma's MIN/MAX/CENTER/STRETCH/ SCALE normalize to the token set (normalize_h_constraint /
normalize_v_constraint); unrecognized → unset. Source-agnostic.
- Codegen map (
design_codegen.cpp, emit_layout_constraints, folded into
emit_position_if_absolute so it fires at every create site, depth>0 only):
center → margin_left/right (or top/bottom) 'auto'; right/bottom →
leading margin_*: 'auto' (push to trailing edge); scale → flex_grow:1;
stretch (pin both edges) → align_self:'stretch'; left/top → flex
default (emit nothing). The bridge setFlex accepts 'auto' only for
margin_* (not padding).
- Best-effort, hence
codegen: partial: axis-exact scale/stretch
depends on the parent's main axis, which the child doesn't carry. Stays inside
Flexbox primitives — do NOT add block/table/float to make it axis-perfect
(CLAUDE.md "Layout Model — Flex + Grid only").
- Native arm only so far; web-compat (
generate_node) constraint emission is a
follow-up. compat.json features.constraints tracks this (parsed handled,
codegen partial); features rows are documented-only (not probed by the
[object-coverage] drift guard). Tests: [view][import][constraints].
Grid containers → native grid bridge (NOT Yoga grid)
Pulp's engine has its own grid layout (LayoutMode::grid + layout_grid()
in view.cpp, driven by the createGrid/setGrid bridge + GridStyle) — the
vendored Yoga (v3.2.1) has no grid API (no YGDisplayGrid; grid only exists
on Yoga's unreleased main). So "wire grid" for design-import is not a Yoga
task — it's emitting createGrid/setGrid instead of createCol/createRow.
Facts / gotchas:
- Parse (
design_ir_json.cpp, parse_ir_layout): display:grid,
gridTemplateColumns/Rows, gridAutoFlow, and per-item gridColumn/Row
(camelCase + snake_case) → IRLayout.grid_template_columns/_rows/
_auto_flow/grid_column/grid_row (raw CSS strings).
- Codegen (
design_codegen.cpp): is_grid = is_container && (display=="grid" || a track template present). Emits createGrid + setGrid(id, 'template_columns'|'template_rows'|'auto_flow', …) + setGrid(id,'gap',…)
(NOT setFlex gap), and suppresses flex justify_content/align_items
(guarded with !is_grid — they're meaningless for grid; do NOT wrap the child
recursion loop, it's interwoven with the flex nudge heuristics and must run for
grid too). Per-item placement (emit_grid_item_placement, folded into
emit_position_if_absolute): grid_column/grid_row "N / M" → setGrid(id, 'column_start'/'column_end'/'row_start'/'row_end', N).
- Span:
"<start> / span <n>" resolves to column/row_end = start + n.
Still deferred (auto-placed): span-WITHOUT-a-start-line, named lines, and
minmax() track sizing — setGrid column/row_start/end take ints only.
Stays within Flex+Grid (CLAUDE.md layout-model contract).
- Native arm only;
compat.json features.grid-container tracks it (parsed
handled, codegen partial). Tests: [view][import][grid].
Radial / conic background gradients
Linear gradients were end-to-end; radial/conic used to round-trip the CSS string
but the renderer flattened them to the first stop color. The canvas + Skia +
CoreGraphics backends ALREADY implement radial/two-circle/conic — the gaps were
only the CSS parser, the View paint dispatch, and the Figma exporter:
- Bridge parser (
widget_bridge.cpp, setBackgroundGradient): now parses
radial-gradient([circle][at X% Y%], stops…) and conic-gradient([from <angle>][at X% Y%], stops…) in addition to linear (shared parse_stops
lambda). Center defaults to 50% 50%; conic from is offset by −90° because
CSS measures from the top while the canvas sweep starts at +x.
- View (
view.hpp/view.cpp): set_background_gradient_radial/_conic
store kind (bg_gradient_type_ 2/3) + center/radius/angle; View::paint
dispatches to canvas.set_fill_gradient_radial/_conic. radius_frac
defaults to ~farthest-corner (0.7071 × max(w,h)). CSS radial extent keywords
(closest-side/closest-corner/farthest-side/farthest-corner) map to an
approximate radius_frac in the bridge parser — exact per-keyword geometry
(needs w/h+center) and explicit px radii stay deferred (codegen partial).
- Figma export (
figma_rest_export.py): GRADIENT_RADIAL/GRADIENT_DIAMOND
→ radial-gradient(...), GRADIENT_ANGULAR → conic-gradient(...) (diamond
approximated by radial). Falls back to flat only when there are no stops.
- Design-import codegen is unchanged — it already emits the gradient string
verbatim to
setBackgroundGradient; radial/conic now render instead of
flattening.
- Tests:
[view][widget-bridge][gradient] (parser→kind), [view][gradient] [render] (radial/conic differ from a flat fill — proves not the fallback),
[view][import][codegen][gradient], and the figma exporter python tests.
compat.json features.radial-angular-diamond-gradient: parsed handled,
codegen partial.
Background gradients: the JS lane and the native/baked-C++ lane are SEPARATE
The section above is the JS / scripted-UI lane (setBackgroundGradient,
emitted by generate_pulp_js). There is a second, independent lane — the
native materializer + baked C++ codegen — and until 2026-06 it dropped
background_gradient entirely (linear included), even though IRStyle
carried it and View could paint it. Fixing one lane does NOT fix the other;
they share no code unless you make them.
- Shared parser (
core/view/src/css_gradient.cpp,
apply_css_background_gradient): the CSS linear/radial/conic parser was
lifted out of widget_bridge.cpp into this free function. All three lanes now
route through it — the JS bridge (setBackgroundGradient delegates), the
native materializer, and baked C++ codegen — so gradients resolve identically.
- Native materializer (
design_import_native_common.cpp,
apply_visual_style): now calls apply_css_background_gradient(view, …) right
after set_background_color. The gradient paints over the solid base color.
- Baked C++ codegen (
design_cpp_codegen.cpp, emit_visual_style): emits a
verbatim pulp::view::apply_css_background_gradient(*var, "linear-gradient(…)")
runtime call plus #include <pulp/view/css_gradient.hpp> in the generated
source prologue.
- Why it mattered: this was the dominant ELYSIUM dark/light parity gap. The
light "hero" panel (
Rectangle 5, a linear-gradient(to bottom,#e4edf6, #b7c8db)) and the cube/prism/tuning illustration fills are all CSS gradients.
With them dropped, the panel rendered dark #1c1d1d and the position_cylinder
GPU-ROI similarity was 0.055; after wiring it jumped to 0.979 (range_prism
0.029→0.78, grains_knob_cap 0.15→0.79). Verify with
pulp-test-mac-platform-harness (PULP_DESIGN_GPU_DUMP_DIR=… dumps ROIs).
- Gotcha — stale CLI binary: after touching
emit_visual_style, rebuild
pulp-import-design before re-emitting --emit cpp; the tool links
pulp-view-core statically and a stale binary silently emits the old output
(no gradient call), which reads as a codegen bug that isn't there.
- Tests:
[view][import][native-materializer][gradient] (materializer applies
it), [gradient] cpp-emit section in the always-built pulp-test-import-design-tool
(so the codegen path is covered even when the planning-gated cpp-codegen target
is skipped), plus the gated pulp-test-design-import-cpp-codegen.
Per-range text styles → nested <span>s
A text node used to take the FIRST-CHAR dominant style only (one run); mixed
text (a bold word, a colored span, a different size mid-string) lost its
per-range styling. Now:
- IR:
IRNode.text_runs — an ordered list of IRTextRun{start,end + optional font_size/font_weight/font_style/color/letter_spacing/ text_decoration}. [start,end) are offsets into text_content. The dominant
style stays the node default; runs override.
- Parse (
design_ir_json.cpp): reads a runs/textRuns array (start/end +
the per-run fields, plus italic:true → font_style:"italic"). Source-
agnostic.
- Figma export (
figma_rest_export.py, extract_text_runs): groups
consecutive characterStyleOverrides ids into ranges and resolves each
through styleOverrideTable (fontWeight, fontName.style→italic,
letterSpacing.value, textDecoration, fills→color). Emitted as the node's
runs.
- Codegen — web (primary):
emit_web_text_runs emits the covered ranges as
styled <span style=…> children and the gaps as plain createTextNode (so
gaps inherit the dominant style). Single-run text keeps the plain
.textContent path (no regression).
- Codegen — native: now wired. The native arm emits
setTextRuns(id, [...]); the bridge builds a canvas::AttributedString from the runs over the
Label's dominant style, and Label::paint_attributed_ draws each span with
its own font/color (advancing x by measure_text) for a SINGLE-LINE label.
Multi-line mixed text still degrades to the dominant single-style path (the
span loop is single-line), so compat.json features.text-per-range-styles
stays codegen partial. Label::set_attributed_string + the setTextRuns
bridge fn are the wiring (offsets are UTF-8 byte offsets, same as the web arm).
- Offsets are UTF-8 BYTE offsets into
text_content. The Figma exporter
builds a UTF-16-code-unit → UTF-8-byte map (characterStyleOverrides is
UTF-16-indexed: a BMP char is 1 unit, an astral char / emoji is a surrogate
pair = 2 units) so a run after an emoji lands on the right byte — a plain
code-point slice was off by a byte-position per astral char. emit_web_text_runs
slices by byte and snaps boundaries forward to the next codepoint start
(continuation bytes are 10xxxxxx) so a stray mid-codepoint offset never emits
invalid UTF-8. Tests: [view][import][text] (incl. a multibyte case) + the
figma exporter python tests (incl. an emoji/surrogate-pair case).
Multi-frame components & swap-link toggles (mode frames)
A component with more than one state frame (e.g. a keyboard's typing vs piano
mode, switched by an in-design toggle) maps to DesignFrameView's multi-frame
support, NOT a crop or a parallel view:
- Export each state sub-frame standalone (
figma_rest_export.py --node <sub-frame-id>). When the design stacks the states in one spec frame to show
them at once, the sub-frames are the individual states — import each as its
own faithful SVG. MusicalTypingKeyboard (nodes 187:15 typing / 187:349
piano) is the reference: DesignFrameView(svg0, …) + add_frame(svg1, …).
set_active_frame(i) swaps the rendered SVG AND the intrinsic size (the host
re-lays-out), releasing any held momentary key first.
- The in-design toggle button is a
DesignFrameElement::Kind::swap element with
target_frame set — a click calls set_active_frame. This is the importer's
swap link (see planning/2026-06-17-figma-interaction-linking-vocabulary.md
for the swap / resize / modal / popover / navigate verb set).
- Hit-rects are per-frame, in the sub-frame's own coords. Pull them from the
Figma node's
absoluteBoundingBox minus the frame origin; the standalone SVG
export adds a uniform shadow margin (6px for these frames). Do NOT transcribe
combined-frame coordinates — the standalone export re-origins everything.
Design-import IR round-trip + review-hardening gotchas
Lessons from the di-1..di-5 closeout review — keep these invariants:
- Serialize every new IR field.
serialize_design_ir →
write_ir_layout_json / write_ir_node_json must emit any field
parse_ir_layout / parse_ir_node reads, or a frozen .pulp / --emit ir-json round-trip silently drops it. Constraints (constraints:{horizontal, vertical}), grid (gridTemplate*/gridAutoFlow/gridColumn/gridRow), and
text runs are all written now; mirror this for future IR additions and add a
[serialization] round-trip test.
- Pencil stroke lives in an attribute. Shape stroke can arrive as
attributes["stroke_color"] (+ stroke_width/stroke-width), NOT
style.border_color. synthesize_primitive_paths consumes both, else a
stroked Pencil rect/ellipse synthesizes to an invisible fill:none path.
- Grid needs a column track. The native grid engine drops all children when
its column list is empty, so a
display:grid node with no
gridTemplateColumns gets a default '1fr' column at codegen (don't emit a
bare createGrid with no template).
- Web-compat run children don't inherit. Pulp's web-compat
<span> Labels
don't inherit typography from the parent, so emit_web_text_runs copies the
node's dominant style onto each run child before applying the run override.
A STRETCH constraint now also emits min_width/min_height: '100%' so it
fills its axis even when the node has an explicit cross-axis size (Yoga clamps
up to the min) — no longer a no-op. (Text-run UTF-16→byte offset conversion is
also handled now — see the per-range text section.)
Faithful-vector lane (Plan B): faithful_svg render mode → DesignFrameView
A parallel, newer rendering strategy to the per-widget sprite/native lanes
above: instead of recognizing each widget and rebuilding it, render the
node's own SVG export pixel-faithfully and overlay native interaction.
This is the lane that makes an imported frame look identical to the source
(gradients, multi-layer drop shadows, masks) while staying interactive.
This lane is the DEFAULT across every producer (REST exporter, plugin UI,
headless runner). A plain import yields the faithful frame WITH live overlays
(knobs, search field, dropdowns, steppers, tab groups). The legacy flat,
static node-tree export is opt-OUT: --no-faithful-vector (REST / headless)
or extractScene(nodes, {faithfulVector:false}) (plugin API). Forgetting an
opt-IN flag was the old failure mode — a fresh import came through static /
non-interactive — so the default is flipped. When no frame SVG is obtainable
(e.g. --node-json with no token and no --frame-svg) the lane degrades
gracefully to the flat export with a warning.
Pieces, source-of-truth → runtime:
- Rendering —
Canvas::draw_svg (SkiaCanvas, Skia SkSVGDOM, libsvg.a)
renders an SVG document pixel-faithfully. Knob animation = wrap the needle
<path> in <g transform="rotate(a cx cy)"> and re-render; the rest of the
chrome stays pixel-exact. DesignFrameView (core/view) renders the SVG,
auto-crops to the panel (largest in-frame <rect>, frac 0.15–0.97 of the
frame), and overlays interaction from a TYPED element list — it does NOT
guess widgets from SVG structure.
- IR — a node opts in with
render_mode = NodeRenderMode::faithful_svg,
points svg_asset_id at an IRAssetManifest entry (mime image/svg+xml),
and carries interactive_elements[] (IRInteractiveElement: cx, cy,
hit_radius, svg_patch_d, default_value, source_node_id, label). These are
source-side semantics filled by the importer, NOT inferred from the SVG.
InteractiveElementKind is deliberately separate from AudioWidgetType.
source_node_id now survives to the live element. to_frame_elements()
copies it onto DesignFrameElement, exposed at runtime via
DesignFrameView::element_source_node_id(i). So a live overlay can be mapped
back to its design node — the dev inspector's Wiring tab (Cmd-I) uses this
to flag controls that came from Figma but aren't wired up, and to fetch the
matching frame. Previously only stable_anchor_id ("figma:<id>") survived.
- Element labels (§2.1 auto-labeling) —
label is the human-readable
parameter NAME a host shows (embed ABI v5 PulpEmbedParamInfo.name), taken
from the control's source Figma layer name when meaningful. The REST lane's
_node_label() is deliberately conservative — it returns "" (→ consumer
falls back to the binding key, no regression) for auto-generated names
(Ellipse 12, Frame 41, bare numbers) AND structural/kind words
(Dropdown, Search, Knob, Value, …), because a WRONG name is worse than
the synthetic key. _label_elements() assigns it: overlays resolve via their
source_node_id; geometry knobs (no node link) match the named node whose
frame-local center lands within the knob's hit radius (same coordinate
convention as _name_override_knobs). unit/range are a follow-up — only the
name flows today. Plugin-lane (TS) parity is the remaining lockstep item.
- Regex hardening (watch out): the import codegen scans the source for JS
keyboard shortcuts (
extract_keyboard_shortcuts, design_import_shortcuts.cpp).
Because the faithful-vector envelope embeds a ~1 MB base64 SVG data URI, any
unbounded leading-identifier regex over that source catastrophically
backtracks (the \b(\w{1,64}) bound fixes it). When adding new source-scanning
regexes to the importer, bound/anchor the quantifiers — a 1 MB embedded asset
is the realistic input, not a few KB of JSX.
- Producer (REST lane) —
figma_rest_export.py (faithful-vector default-on;
--no-faithful-vector for the legacy flat export) fetches
the frame's own SVG (/images?format=svg, or --frame-svg FILE offline),
embeds it as a data:image/svg+xml;base64 asset (so the importer always
resolves it — no dependency on local_path stamping), sets the root's
render_mode/svg_asset_id, and attaches interactive_elements from
parse_frame_knobs(svg). That detector is the geometry auto-detect ported
from the vector-knob PoC: a knob DOME is a gradient <circle> (fill="url(",
r≥8); its NEEDLE is a thin light-stroked (white or #ABABAB — dark
ticks are #506274) short vertical <path d="Mx1 y1Lx2 y2"> just above the
dome; pair each needle to its nearest dome and emit the EXACT d as
svg_patch_d so the runtime can rotate that one path. --knob-name SUBSTR
(repeatable) is the name override: it supplements geometry with any
node whose name contains the substring (frame-local center from its abs
bbox), but those carry NO svg_patch_d (hit + value, no visual rotation),
the honest fallback for a knob geometry missed.
- Rate limits (REST lane) — every Figma GET in
figma_rest_export.py routes
through figma_get(), which honors the 429 Retry-After header (capped
exponential backoff when absent), retries transient 5xx + read-phase
timeouts/resets, and on a terminal 429 raises with the diagnostic headers
(rate-limit type / plan tier / upgrade link) instead of a traceback. Watch
out: /images?format=svg (frame SVG) and the PNG captures are Figma Tier-1
endpoints whose budget depends on the plan of the file being requested — a
Starter-plan file can throttle a Full-seat token hard. Don't fire ad-hoc
validation curls against /images next to an export; if you already have the
SVG/nodes JSON, feed them back via --frame-svg / --node-json to spend zero