From pulp
Import designs from Figma, Stitch, v0, Pencil, or Claude Design into Pulp web-compat JS with automated visual validation. Claude Design imports also scaffold a pulp::view::EditorBridge handler file (pulp
npx claudepluginhub danielraffel/pulp --plugin pulpThis skill uses the workspace's default tool permissions.
Import a design from an external tool (Figma, Stitch, v0, Pencil, Claude Design) into this Pulp project.
Monitors deployed URLs for regressions after deploys, merges, or upgrades by checking HTTP status, console errors, network failures, performance (LCP/CLS/INP), content, and API health.
Share bugs, ideas, or general feedback.
Import a design from an external tool (Figma, Stitch, v0, Pencil, Claude Design) into this Pulp project.
Detect which design source the user wants by checking:
--from claude (no MCP — Anthropic has no public API; per pulp #468, manual file export is the supported path; Spectr's editor.html mapping is the precedent)Ask the user or detect from context:
Figma (MCP available):
com.figma.mcp to read the current file or selectionStitch (MCP available):
mcp__stitch__list_screens to show available screensmcp__stitch__get_screen to read the selected screenPencil (MCP available):
mcp__pencil__get_editor_state to check current filemcp__pencil__batch_get to read the node treemcp__pencil__get_variables for design tokensmcp__pencil__get_style_guide for style referencesv0 (URL or TSX file):
Claude Design (manual HTML export — pulp #468):
pulp import-design --from claude --file <path> — the parser delegates to the Stitch HTML pipeline and tags the IR as Claude. This is the static path — it sees only the loader-shell HTML wrapping the bundled React app (~9 elements: title, bundler placeholders, inline styles, the <script> blob).--execute-bundle to invoke the native-runtime path: Pulp parses the JSON envelope, decodes the gzip+base64 asset map, evaluates the React + React-DOM + app payloads in a headless ScriptEngine, then walks the materialized DOM into the DesignIR. Falls back to the static path on any harness failure (engine error, walker output below the 9-node loader-shell floor, JS payload too large). Use this when the user's Claude export is a real bundled-React app and they need the actual editor tree, not just the shell.bridge_handlers.cpp scaffold next to the generated JS (override path with --bridge-output, skip with --no-bridge-scaffold). The scaffold demonstrates registering pulp::view::EditorBridge handlers and attaching to a WebViewPanel (or future JsRuntime).Inline <script> evaluation in --execute-bundle (pulp #758): The harness now evaluates inline <script type="text/javascript"> (and untyped <script>) blocks AFTER the src-loaded payloads, then compiles + evaluates inline <script type="text/babel"> (and text/jsx) blocks via the bundle's own Babel-standalone (looked up as globalThis.Babel.transform). After both, the harness dispatches a readystatechange → DOMContentLoaded → readystatechange(complete) → window.load sequence and pumps four message-loop / frame-callback cycles for async settling. This is what makes a real Spectr-style Claude bundle (where the actual React app lives in inline text/babel blocks, not src-loaded payloads) materialize beyond the 9-element shell. Per-script soft-fail matches the existing src-loaded payload pattern. Inline application/json (and other */json) blocks are intentionally skipped — they're config blobs, not executable code.
Gotchas that bit pulp #758 implementation:
core/view/js/web-compat.js's document is a plain object literal (not an Element), so it ships without addEventListener / dispatchEvent. The Step 3 dispatcher constructs events defensively — uses new Event(t) when available, falls back to {type, target, bubbles:false, preventDefault, stop*} literal otherwise — but bundles that call document.addEventListener('DOMContentLoaded', ...) only fire if the bundle (or some library it loads) installs addEventListener/dispatchEvent on document/window first. Real React-DOM does not do this — it attaches to the root element it controls — so the DCL dispatch is a best-effort safety net, not a guarantee. See test DOMContentLoaded dispatch runs the queued handler when document supports it in test/test_design_import_inline_babel.cpp for the exact shim shape that satisfies the contract.error_out is the fallback reason (or empty on success). Don't piggyback diagnostic warnings on it from inside the harness; on success the harness clears the slot. If you need to surface a warning that survives a successful run, push it through a different channel (e.g. add a warnings vector to ClaudeRuntimeOptions).<span> (and other text-mapped tags: p, label, h1-h6, a, strong, em, small, code) get filtered out by the text-empty pruning in json_to_ir_node. If a fixture or test relies on observing an empty <span> with attributes round-tripping through the IR, use a <div> instead — divs map to frame and survive the prune.typeof globalThis.Babel.transform === 'function' rather than typeof Babel. Some bundles install Babel as a sentinel object before the real implementation arrives, which would false-positive on the looser check.@pulp/react bundle dedup (pulp #1292 / #1295, learned 2026-05-03): when emitting React+@pulp/react consumer bundles (Spectr et al.), the consumer's bundler MUST be able to dedup React across the @pulp/react boundary. This means @pulp/react's published dist/index.mjs must externalize react, react-reconciler, react-reconciler/constants.js, and scheduler — otherwise esbuild emits TWO independent React module instances (one for user code, one for the reconciler) and ReactCurrentDispatcher.current desyncs at first commit, manifesting as "cannot read property 'useState' of null" inside the user's App(). The fix is a 1-line addition to packages/pulp-react/package.json's build script: --external:react --external:react-reconciler --external:react-reconciler/constants.js --external:scheduler. This must hold for any future package emitted from pulp import-design that pulls in @pulp/react.
Spectr's <svg><path> doesn't auto-route to <SvgPath> (pulp #994 / #1291, learned 2026-05-03): Pulp v0.69.2+ ships an <SvgPath> JSX intrinsic that maps to the C++ SvgPathWidget shipped in v0.61.0 (#965/#991). However, plugin bundles emitted from Claude-Design exports (and similar) ship raw <svg><path/></svg> markup, not <SvgPath>. There's no automatic shim — the dom-adapter (or a future pulp import-design post-process) must rewrite <svg> → <SvgPath> for inline-icon use cases. Track plugin-side adoption when bumping SDK pin past v0.69.2.
v0.69.0 closes 4 v0.68.0 audit symptoms automatically: segmented-control vertical stacking (was the most-visible Spectr UX gap) is closed by display:flex defaulting to flex-direction:row (#1167); FilterBank canvas was already auto-resolved at v0.68.1+; App-root layout-bottom-strip is closed by the same flex-direction default; click-bubble dispatch fully closed in v0.68.0 (#1008/#1073). When auditing a freshly-imported plugin against an older SDK reference, run the WebView↔Native side-by-side at idle FIRST — many "broken" rows resolve via SDK upgrade alone with zero plugin-side work. Pattern documented in spectr/planning/audit-2026-05-03-webview-vs-native-v0.69.1.md.
File-based fallback:
Use the appropriate mapping document as your translation reference:
planning/figma-to-pulp-mapping.md for Figma designsplanning/stitch-to-pulp-mapping.md for Stitch screensplanning/v0-to-pulp-mapping.md for v0 generationsplanning/pencil-to-pulp-mapping.md for Pencil designsGenerate Pulp web-compat JavaScript:
document.createElement('div') + el.style.flexDirection, etc.el.style.fontSize, el.style.fontWeight, etc.el.style.backgroundColor, el.style.color, etc.createKnob(), fader/slider → createFader(), meter/level/vu → createMeter(), xypad → createXYPad(), waveform → createWaveformView(), spectrum/analyzer → createSpectrumView()theme.colors["name"] = value, theme.dimensions["name"] = valueui.js (or user-specified output path)tokens.json in W3C Design Tokens formatAfter generating, offer to:
For interactive review of the current checkout, prefer:
pulp design
If the design tool lives in a nonstandard worktree/build setup, use:
pulp design --build-dir /path/to/build --script /path/to/design-tool.js
When run outside a Pulp checkout, automatic binding currently only works when the pulp binary
itself lives inside a Pulp build tree. In generic PATH-installed or split repo/SDK layouts, pass
--build-dir and --script explicitly.
| Figma | Pulp |
|---|---|
| Frame (auto-layout) | div with flex |
| Text | span |
| Rectangle | div with background |
| Component | JS function |
| Fill (solid) | backgroundColor |
| Fill (gradient) | background: linear-gradient(...) |
| Stroke | border |
| Drop shadow | boxShadow |
| Corner radius | borderRadius |
| Stitch | Pulp |
|---|---|
| Container | div with flex |
| Text | span |
| Button | button |
| Input | input |
| Card | Panel |
| Design system colors | theme.colors |
| v0 (Tailwind) | Pulp |
|---|---|
flex flex-col | flexDirection: 'column' |
gap-4 | gap: '16px' |
bg-slate-900 | backgroundColor: '#0f172a' |
rounded-lg | borderRadius: '8px' |
<Button> | createButton() |
<Slider> | createFader() |
| Pencil | Pulp |
|---|---|
| Frame (auto-layout) | div with flex |
| Text | span |
| Rectangle | div with background |
| Variables (COLOR) | theme.colors |
| Variables (FLOAT) | theme.dimensions |
After generating Pulp code, ALWAYS validate by comparing with the source design:
Screenshot the source design via MCP:
get_screenshot(nodeId)Render the generated JS headlessly:
pulp-screenshot --script generated.js --output render.png --width W --height H
Compare reference vs render:
pulp import-design --from X --file input --validate --reference source.png --diff diff.png
Review the diff image — red highlights show differences
Iterate if needed — adjust the generated code and re-render until similarity is acceptable (>85%)
height, min_height, or flex_growmin_height (14px for normal text, 12px for small)min_width >= 40px for thumb renderingmin_width >= 20px for bar visibilitymin_size >= 56px for arc renderingcreateCol/createRow for containers (NOT createPanel which adds glass overlay)The deterministic import tool is also available:
pulp import-design --from figma --file design.json
pulp import-design --from stitch --file screen.html
pulp import-design --from v0 --file component.tsx
pulp import-design --from pencil --file design.json
pulp import-design --from claude --file design.html # writes ui.js + tokens.json + classnames.json + bridge_handlers.cpp (static parser — loader-shell only)
pulp import-design --from claude --file design.html --execute-bundle # runs the bundled React app in QuickJS, walks the materialized DOM (#468)
# With validation
pulp import-design --from pencil --file design.json --validate --reference source.png --diff diff.png
# Override or skip the bridge scaffold (claude only)
pulp import-design --from claude --file design.html --bridge-output editor/handlers.cpp
pulp import-design --from claude --file design.html --no-bridge-scaffold
# Override or skip the classnames artifact (claude only — pulp #1035)
pulp import-design --from claude --file design.html --classnames editor/classnames.json
pulp import-design --from claude --file design.html --no-emit-classnames
Use --dry-run to preview without writing files.
Detect-only directory inputs (pulp import-design --detect-only --directory <dir>) prefer
code.html, then index.html, then the first sorted .html / .htm payload. Keep fixture
tests on that deterministic order; raw std::filesystem::directory_iterator order differs
between macOS and Linux.
For --from claude, the CLI emits a starter C++ file demonstrating how to wire pulp::view::EditorBridge so the imported design's editor JS can postMessage into the C++ processor:
MyPluginEditor placeholder with the editor class that owns the WebViewPanel.bridge_.add_handler("type", ...) per message type your editor emits. Use EditorBridge::get_float / get_uint / get_string for safe payload reads, and EditorBridge::ok_response() / ok_response(extras) / err_response(msg) for replies.bridge_.attach_webview(*panel_) to route WebView messages through the dispatcher.attach_webview(...) for bridge_.attach_native_runtime(runtime, "<handler_name>") once the runtime exposes its postMessage primitive.See docs/reference/editor-bridge.md for the full API and the standard envelope-level error vocabulary (malformed_json, unknown_type, missing_field, wrong_type, internal_error).
For --from claude, the CLI also emits classnames.json mapping
classname → { cssProp(camelCase): cssValue, ... } for every plain-classname <style> rule it finds in the export. Mirrors the output of Spectr's tools/extract-html-bundle/extract.mjs so downstream consumers (@pulp/css-adapt, dom-adapter) can merge class-based styles into inline before forwarding to bridge calls — no separate Node-side extraction script needed.
What the extractor honours:
<script type="__bundler/template"> is present, the extractor walks both the loader shell and the unwrapped template HTML's <style> blocks.<style> blocks cascade: later blocks override earlier ones per-property; unrelated declarations from earlier blocks are preserved..btn-primary, .btn-secondary { ... }) emit one entry per classname with identical declarations.font-family → fontFamily).What it skips:
:root, .scheme-* rules — those are theme-mode token overrides handled upstream as tokens.json artifacts..foo:hover), attribute selectors (.foo[data-x]), descendant combinators (.foo .bar, .foo > .bar) — anything that isn't a plain .classname { ... } rule.@media / @keyframes / other at-rule wrappers.<style> blocks whose first 200 chars contain @font-face (those carry only font-face rules, no classnames).This skill must stay aligned with the view-bridge skill — view-bridge covers editor lifecycle (create_view, open/notify_attached/resize/close), this skill covers message dispatch over that lifecycle.
pulp import-design ships a three-layer version model so the CLI surface stays stable as external tools evolve their export formats:
parser-version — Pulp's parser implementation for a given source.format-version — the export shape Pulp recognises.compat-schema-version — the schema of compat.json itself.The matrix is declared in compat.json and consumed by pulp import-design --detect-only. See docs/reference/imports/index.md for the full vocabulary, recognized matrix, and "add a new format-version" workflow.
When the user hands you an unknown export, run detection first before guessing the source:
# File or directory; --detect-only prints (source, format-version,
# parser-version, match-count, confidence) and exits.
pulp import-design --detect-only --file <path>
pulp import-design --detect-only --directory <path>
Exit codes: 0 = match, 1 = usage error, 2 = no match.
If confidence is below 80%, the CLI emits a warning and an invitation to run --report-new-format:
pulp import-design --file <path> --report-new-format > stitch-2026-XX.json
Hand-edit the resulting JSON into a new entry under compat.json[imports/<source>/detected-formats]. The notes field is mandatory — describe the upstream change in one line.
Every new format-version needs a fixture so the detection gate covers it:
mkdir -p test/fixtures/imports/<source>/<format-version>/expected.json sidecar with the assertion shape from existing fixtures (source, format-version, parser-version, matched-clauses, total-clauses, min-confidence-pct, fingerprint-kinds).ctest --test-dir build -R pulp-test-cli-import-detect to confirm the fixture loop picks up the new row.The detector module lives at tools/import-design/import_detect.{hpp,cpp} and is intentionally free of pulp::view / pulp::state link deps so the test target compiles fast and the unit tests don't drag the full design-import pipeline along.
When translating browser <canvas> + Canvas2D code to Pulp's native bridge (canvas* globals), several spec-conforming browser idioms silently break against the bridge contract because the bridge surface is more limited and more direct than the HTML5 spec. The following rules were paid for in production debugging cycles on Spectr's analyzer port (pulp #1346/#1348/#1368/#1372 + Spectr canvas2d-shim.ts); the importer must emit code that respects them.
ctx.arc() does NOT add to a path on its own — synthesize as line segmentsSpec: ctx.arc() adds an arc sub-path; subsequent ctx.fill() / ctx.stroke() operate on it.
Bridge reality: canvasArc(id, x, y, r, sa, ea, fillColor) strokes immediately and returns. It does not contribute to the active path, so ctx.beginPath() → ctx.arc() → ctx.fill() renders a stroked outline ring (just the arc's stroke), not a filled circle. Radial-gradient cap-emission patterns degenerate into hollow ellipse outlines.
Importer rule: when emitting arc() translation, emit a polyline approximation (~32 segments scaled by radius, 8..64) via canvasLineTo. This makes the sub-path closeable and ctx.fill() honors any active fillStyle / gradient.
arc(x, y, r, sa, ea, ccw) {
const segs = Math.max(8, Math.min(64, Math.ceil(r * 1.2)));
const sweep = ccw ? -((sa - ea + 2*Math.PI) % (2*Math.PI)) : ((ea - sa + 2*Math.PI) % (2*Math.PI));
for (let i = 0; i <= segs; i++) {
const a = sa + sweep * (i / segs);
canvasLineTo(id, x + Math.cos(a) * r, y + Math.sin(a) * r);
}
}
Same applies to arcTo() (rounded-rect corners), ellipse(), and roundRect().
(color, pos) args — NOT a JSON stringSpec: grad.addColorStop(pos, color) accumulates stops; the gradient is opaquely passed via ctx.fillStyle = grad.
Bridge reality: canvasSetLinearGradient(id, x0, y0, x1, y1, color1, pos1, color2, pos2, ...) and canvasSetRadialGradient(id, cx, cy, radius, color1, pos1, ...) read each pair via positional args.get<>(). Passing stops as a single JSON string makes i+1 < args.numArgs false on the first iteration → zero stops parsed → bridge dispatch skipped → fillStyle = grad falls through to canvasSetFillColor(id, "[object Object]") → parseColor returns default white → uniform white fill instead of the rainbow ramp.
Importer rule: when serializing gradient stops, spread as variadic args:
const stopArgs: (string|number)[] = [];
for (const s of grad.stops) stopArgs.push(s.color, s.offset);
canvasSetLinearGradient(id, x0, y0, x1, y1, ...stopArgs);
Spec: createRadialGradient(x0,y0,r0,x1,y1,r1) — two circles, with the gradient interpolating in the cone between them.
Bridge reality: canvasSetRadialGradient(id, cx, cy, radius, ...stops) only takes the single outer circle. Inner-circle / off-axis ring patterns degrade.
Importer rule: map JS 6-numeric form to bridge's 3-numeric form using the OUTER circle (x1, y1, r1). For Spectr-style center-bloom (r0=0, same center) this is visually identical to the spec; for true two-point gradients, file a Pulp issue rather than emitting silently-wrong output.
ctx.clearRect() was a parent-surface eraser pre-#1372Reality (pre-pulp v0.74.1): ctx.clearRect() used SkBlendMode::kClear / CGContextClearRect directly on the parent surface — NOT on a per-canvas backing layer. JS code that clears its own canvas would erase pixels another <canvas> sibling had just painted. Symptom: first sibling renders correctly, gets wiped by second sibling's clearRect at the start of its frame.
Now (pulp v0.74.1+): each CanvasWidget::paint is wrapped in save_layer, isolating clearRect / Porter-Duff to that canvas's own buffer. Importer can emit <canvas> siblings without worrying about cross-erasure. Pin SDK >= 0.74.1.
ctx.measureText() — bridge has canvasMeasureText but the shim should fall back to a per-char approximation (~6.5px for 10px monospace, ~px*0.6 for proportional) when not availablectx.strokeText() — bridge has no stroke path; fall back to canvasFillText and accept the visual gapctx.createPattern() — not implemented; emit a solid-color fallback from the pattern's first stopctx.createConicGradient() — bridge has no canvasSetConicGradient registration even though SkiaCanvas::set_fill_gradient_conic exists; either file a Pulp follow-up or fall back to a flat solid| Capability | Min SDK |
|---|---|
canvasSetLinearGradient / canvasSetRadialGradient | v0.72.4 (pulp #1348) |
| Gradient stops actually applied to fills | v0.72.5 (pulp #1353) |
set_blend_mode on Skia (GPU) honored | already wired; CG/CPU is silent no-op (pulp #1371) |
Per-canvas save_layer isolation (no sibling clearRect erase) | v0.74.1 (pulp #1372) |
Canvas paint instrumentation (PULP_LOG_CANVAS_PAINT=1) | v0.75.0 (pulp #1370) |
Reject importer output that targets earlier SDK versions for canvas-heavy designs — the visual gaps will be silent and look like Pulp bugs.
Always pixel-sample after rendering — visual inspection misses uniform-fallback bugs. A spectrum that renders "uniform light gray" instead of "rainbow gradient" looks roughly right at thumbnail scale but is structurally broken (every color stop resolved to white by the parseColor fallback). Sample horizontal cross-sections at the expected gradient axis and assert color variance > some threshold.
registerPointer(id) AND don't bubbleSpec: addEventListener('pointerdown', fn) plus React synthetic-event bubbling: a click on a child reaches the parent's handler unless stopPropagation is called.
Bridge reality: Pulp gates pointer dispatch behind an explicit registerPointer(id) call (parallel to registerClick(id) and registerHover(id)). @pulp/react's prop-applier currently only wires registerHover for mouseenter/leave, so onPointerDown/Move/Up listeners are installed in the JS dispatch table but never fired by the native View — the JS handler appears registered (on(id, 'pointerdown', fn)) yet clicks never invoke it. Additionally, pulp dispatches pointer events to the hit-test target only — there is no synthetic-event bubbling. A handler on a parent <div> will not fire when the click lands on a child <canvas> that visually overlays it.
This was the root cause of Spectr's "FilterBank renders rainbow but band drag is dead" symptom (spectr #32 / commit b7ba2b8). Confirmed by __spectrLog probe at the top of onPointerDown: handler does NOT fire on cliclick c:600,400 even though on(pr_3, pointerdown, ...) is registered.
Importer rule:
onPointerDown / onPointerMove / onPointerUp / onPointerLeave / onWheel handler, also emit a registerPointer(id) call against the same widget. Do this in the ref-mount callback (or its equivalent post-mount hook) so the bridge wires on_pointer_event into the View. Idempotent on the bridge side; safe to call on every remount.getBoundingClientRect() for coord math so the same function works on every binding.// Bind on parent + every interactive child:
<wrap onPointerDown={onPD} onPointerMove={onPM} onPointerUp={onPU}>
<canvas onPointerDown={onPD} onPointerMove={onPM} onPointerUp={onPU} ... />
<canvas onPointerDown={onPD} onPointerMove={onPM} onPointerUp={onPU} ... />
</wrap>
// In the ref-mount callback:
const id = inst.id;
if (typeof globalThis.registerPointer === 'function') globalThis.registerPointer(id);
The cleaner long-term fix is for @pulp/react's prop-applier to call registerPointer automatically when it sees any pointer-event prop (parallel to its existing registerHover wiring) — track that as a follow-up Pulp issue rather than an importer-side workaround if you encounter it on a fresh import.