Audience: AI coding agents (and teams running them). This page is part of the deterministic agent workflow for building and verifying Velt UI customizations. Customizing by hand? Start with the Approaches and Feature Guides.
designSpec first, then apply those EXACT numbers and verify rendered output against them.
Use the same output contract whether extraction is automated or manual: exact measurements, exported SVG assets, frame-relative boxes, and a per-state checklist.
Producing the designSpec
- REST path (preferred): With a Figma token, fetch the node JSON from
api.figma.comand emitdesignSpec.jsonplus exported icon SVGs underassets/. - MCP fallback (no token): Save Figma MCP outputs (
get_variable_defs, and a node tree fromget_metadata/get_design_context) to a dump and convert it into the samedesignSpecschema. This has lower fidelity because it is limited to what the MCP exposes; prefer REST when available.
designSpec node carries cssDecls: CSS-ready, exact declarations: a box (x/y/w/h), and a frameId (the top-level block-frame it belongs to). Apply the cssDecls to the real classes from Manifest; then diff rendered computed styles and layout boxes against them. Boxes should be emitted frame-relative (boxSpace: "frame-relative"): subtract each frame’s own origin from its subtree, so box.x/y are relative to that frame’s top-left and directly comparable to both getBoundingClientRect − surface-root measurements and the per-block frame PNG.
- Why per-frame (not one root origin): a multi-state design is one section of many frames laid out across the canvas (e.g. node
1:3398= 16 sidebar frames), and each frame is exported as its own PNG at0,0. Subtracting a single section origin left every node ~1500px off, sovisual-diff --mask-text-frommislocated every mask and the visual gate went blind (passing wrong icons / structure / spacing as “matched”). Per-frame normalization fixes that. The spec also listsframes: [{id,name,type}]. - Per-block consumption: when you extract a section once and diff one block, filter to the block’s frame id so only that block’s (already frame-relative) nodes are used. Omit this filtering only when the spec is a single-frame extraction.
The mapping rules (auto-layout → CSS, from FigmaToCode)
layoutModeHORIZONTAL/VERTICAL→display:flex+flex-direction:row|column.primaryAxisAlignItems→justify-content(MIN→flex-start,CENTER→center,MAX→flex-end,SPACE_BETWEEN→space-between).counterAxisAlignItems→align-items(MIN/CENTER/MAX/BASELINE).itemSpacing→gap: suppressed whenSPACE_BETWEEN(the browser distributes the space; an explicit gap would be wrong).paddingT/R/B/L→padding, collapsed to the concise form (14px 16px).- Sizing is axis-dependent: the rule most converters get wrong (and we did): for a child of an auto-layout parent,
FILLon the parent’s primary axis →flex:1 1 0;FILLon the counter axis →align-self:stretch;HUG→ emit nothing (content-driven);FIXED→ literalpx. Getting this wrong is what squeezed the dialog. cornerRadius→border-radius;strokes+strokeWeight→border; first solidfills→background(orcoloron aTEXTnode);style→font-family/size/weight/line-height/letter-spacing.
Icons: export the design’s real SVGs (R17) + assign them to slots
Flag icon-candidate nodes (vectors, or small ≤64px vector-only subtrees), export each as an SVG (id-suffixed filename so generic names like “Icon”/“Vector” don’t collide), and assign each to a slot using the manifest’siconHint, in confidence layers (stop at the first that matches; never guess):
nearText(reliable): the icon’s adjacent label: a menu row is[icon, "Edit"], the reply affordance is[icon, "Reply"]. Auto-assign (de-duped: one SVG → one slot). Output:designSpec.iconAssignments[reactPath] = { file, by, glyph }.- name / component-signal (S3): an icon-only control is frequently a named Figma icon component (an
iconButton/Iconinstance) or a node named for its glyph (filterIcon,more,checkCircle). The resolver matches the slot’sglyph(+ synonyms:filter-lines→filter/funnel,kebab→more/ellipsis/dots,check-circle→check/resolve/tick, …) against the icon’s own name + ancestry, preferring a single name hit or a single named component. This is the layer that fixes the M2a filter + kebab misses (named components with no adjacent label). ancestryKeyword(weak): only accepted when it resolves to exactly one free icon; a broad keyword (e.g. “comment”) matches many → left unassigned, never guessed.unassignedIcons→ render-and-recognize: anything still unmatched is reported here with{ slot, hint:{glyph}, renderRecognize:true, candidates:[{file,name,isComponent,box}] }: a shortlist of the free exported SVGs (name-hits first, else the icon components). Rasterize each candidate SVG, identify the glyph visually by opening theassets/*.svgfile, then wire the one matching theglyphinto the slot. Explicit recognition: never a Velt default, never hand-drawn, never a blind guess.
ResolveButton is a check-circle; the UnresolveButton is a counter-clockwise reopen/undo arrow (synonyms: reopen / undo / restore / rotate-ccw / refresh) and it appears only in the resolved-state frame as an icon-only control. Resolve it via render-and-recognize like any icon-only control (layers 2-4); do NOT fill UnresolveButton with the resolve check, because that conflation is a recurring miss. Same rule for any visually-similar pair (filter vs sort, edit vs copy): a mustSupply slot gets the glyph for that slot, never the nearest already-resolved one.
Wire the assigned + recognized SVGs into the mustSupply icon slots as a small icons/ file of React SVG components. Never leave a Velt default icon and never hand-draw one.
Token handling
The REST path needs a Figma personal-access token; it is optional (no token → MCP fallback, never a halt). Resolution:FIGMA_TOKEN env var first, then the OS secure store: never the target repo’s .env.
- Check token presence without printing the value.
- Read tokens from STDIN or the OS secure store, never argv/history.
- Remove stored tokens when the extraction workflow is done.
The token is sent only to
api.figma.comover HTTPS, never logged in full, never written to a tracked file. A read-only / file-content-scoped PAT is recommended.

