You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Complete, lossless .fig parsing is the foundation for composable importing of any Figma file — data parity begets visual parity. The figma-transformer decodes Figma's Kiwi-encoded binary into a normalized IR and emits static HTML/CSS. Most "visual gaps" are not rendering bugs; they are fields that exist in the Kiwi binary but get dropped at one of three gates before they can become CSS. Every field that reaches the IR faithfully turns a rendering guess into a deterministic mapping. This issue makes that surface visible: a tracked, swarmable checklist of every visually-meaningful field and where it currently stands.
The three gates a field must pass
Decoded — figma-transformer/src/FigFile/FigKiwiDecoder.php whitelists the field for selective decoding (defaultScenegraphFieldPolicy(), lines 378-436). The decoder is selective: any field not named in the policy is silently skipped (skipField(), lines 200-216). Fields absent from the policy are gate-1 blind spots even though the binary carries them.
Normalized — figma-transformer/src/Scenegraph/ScenegraphNormalizer.php maps the decoded field into the normalized IR contract.
Emitted — figma-transformer/src/Html/StaticHtmlEmitter.php turns the IR field into HTML/CSS.
A field can clear gate 1 but die at gate 2 (decoded, normalizer ignores it = pure data loss, usually a one-line fix), or clear gates 2-3 but never arrive because gate 1 starves it (the normalizer/emitter machinery exists and is fully written, but the decoder never hands it the data). The most valuable finding in this audit is the second pattern: rich, finished normalizer + emitter code is being starved by a one-word omission in the decoder whitelist.
Status legend
✅ Full — decoded, normalized, emitted correctly
🟡 Partial — present with a specific named gap
🔴 Blind (decoded, dropped) — Kiwi decodes it, normalizer/emitter ignores it — pure data loss, cheap fix
⬛ Not decoded — absent from the field policy — needs a decoder-whitelist change first
➖ N/A — no reasonable static-CSS target
Status counts
Across the 70 tracked field rows below: ✅ 53 · 🟡 4 · 🔴 2 · ⬛ 8 · ➖ 3. Batch 2 cleared the 🔴 decoded-but-dropped trio, moved nine ⬛ rows to ✅ (effects ×2, strokes ×2, styled text, links ×3), and promoted the angular gradient from 🟡 to ✅. The remaining ⬛ rows (full prototype fidelity beyond links, component/variant properties, layoutGrids, masks, maxLines/truncation) are the structurally harder IR-design work aligned with epic #242 — not whitelist one-liners.
Recently closed (this matrix reads as progress, not just backlog)
First batch
These six just-merged PRs fixed exactly the Kiwi-name / decoded-but-dropped pattern this matrix tracks:
Second batch (continued progress — same pattern, deeper coverage)
Six more just-merged PRs closed the highest-impact ⬛/🔴/🟡 rows, mostly by lighting up normalizer/emitter code that was already written and only waiting on a decoder-whitelist entry:
Every batch-2 decoder PR also added a real Kiwi-binary decode fixture (synthetic schema + encoded message run through the actual FigKiwiDecoder), proving gate 1 directly rather than just the normalizer bridge.
FSE Pilot validation (real .fig run)
After both batches landed, the transformer was re-run on the FSE Pilot Build Theme .fig and the emitted output was measured directly:
Confirmed emitting on real data (counts are occurrences in the emitted output):
text-transform — 16 (was 0 pre-batch)
per-corner border-*-radius — 8
fit-content from textAutoResize — 32
max-width from min/max-size — 8
box-shadow from effects — 2
borders with real colors (real stroke weight/color, not the 1px default)
Proven by real-Kiwi-binary decode contract fixtures, but not surfaced on this render's planned pages: layer/background blur, linear/angular gradient, hyperlinks, inline text spans, node blendMode. The source .fig carries these on nodes (6 effect nodes, 1 linear gradient, 2 hyperlinks, 9 inline-span nodes) that live on pages the planner does not surface in this render — their absence from the output is "not on the rendered page," not a regression. Each is covered by a real-Kiwi-binary decode contract fixture.
Coverage matrix
Evidence cites origin/trunk line numbers. Decoder policy lives at FigKiwiDecoder.php:378-436 (NodeChange list: 384-406).
.fig has no absoluteBoundingBox; computed from size+transform
Normalizer.php:3416-3456
Vector geometry
fillGeometry / strokeGeometry / vectorData
✅
✅
✅
✅
→ inline SVG
Normalizer.php:2318
Boolean operation
booleanOperation
✅
➖
✅
✅
consumed at emit
Emitter.php (5 refs)
Locked
locked
⬛
➖
➖
➖
editor-only, no visual effect
—
Export settings
exportSettings
⬛
➖
➖
➖
no static-CSS target (could drive asset export later)
—
Annotations
annotations
⬛
➖
➖
➖
dev-note metadata, no visual target
—
Prioritized remaining work (swarmable — each item is one PR-sized change)
The cheap-win 🔴 trio and the already-finished-but-starved normalizer/emitter machinery (effects, strokes, styled text, links, angular gradient) all shipped in batch 2. What remains is the structurally harder IR-design work — new normalizer and emitter mapping, not whitelist one-liners — aligned with epic #242.
🔴 Cheap wins — decoded, just wire the normalizer (one-line / small)
stackReverseZIndex → child paint order — low impact, mechanical.
useAbsoluteBounds → bounds-source flag — low impact, mechanical.
⬛ Structurally harder — new normalizer + emitter mapping (not just a whitelist add) — ordered by visual impact
exportSettings — no static-CSS target; relevant only if/when the transformer drives asset export.
annotations — dev-note metadata, not a visual property.
variantProperties / componentProperties — the selected variant is already materialized in the instance's node subtree, so static output renders correctly without them; useful only for round-trip/editing fidelity.
Diamond gradient — no faithful native CSS primitive (radial approximation is the realistic ceiling).
This matrix is the data-parity companion to the maintainability/swarm-refactor epic #242. Where #242 decomposes the emitter and formalizes the finding contract so work can be parallelized safely, this issue defines what that parallel work should target: each 🔴/⬛/🟡 row above is sized to become its own PR, matching the swarm model. The two together make the transformer both maintainable and provably complete.
AI assistance
AI assistance: Yes
Tool(s): Claude Sonnet 4.6 via Claude Code
Used for: Field coverage audit and matrix authoring
Thesis
Complete, lossless
.figparsing is the foundation for composable importing of any Figma file — data parity begets visual parity. Thefigma-transformerdecodes Figma's Kiwi-encoded binary into a normalized IR and emits static HTML/CSS. Most "visual gaps" are not rendering bugs; they are fields that exist in the Kiwi binary but get dropped at one of three gates before they can become CSS. Every field that reaches the IR faithfully turns a rendering guess into a deterministic mapping. This issue makes that surface visible: a tracked, swarmable checklist of every visually-meaningful field and where it currently stands.The three gates a field must pass
figma-transformer/src/FigFile/FigKiwiDecoder.phpwhitelists the field for selective decoding (defaultScenegraphFieldPolicy(), lines 378-436). The decoder is selective: any field not named in the policy is silently skipped (skipField(), lines 200-216). Fields absent from the policy are gate-1 blind spots even though the binary carries them.figma-transformer/src/Scenegraph/ScenegraphNormalizer.phpmaps the decoded field into the normalized IR contract.figma-transformer/src/Html/StaticHtmlEmitter.phpturns the IR field into HTML/CSS.A field can clear gate 1 but die at gate 2 (decoded, normalizer ignores it = pure data loss, usually a one-line fix), or clear gates 2-3 but never arrive because gate 1 starves it (the normalizer/emitter machinery exists and is fully written, but the decoder never hands it the data). The most valuable finding in this audit is the second pattern: rich, finished normalizer + emitter code is being starved by a one-word omission in the decoder whitelist.
Status legend
Status counts
Across the 70 tracked field rows below: ✅ 53 · 🟡 4 · 🔴 2 · ⬛ 8 · ➖ 3. Batch 2 cleared the 🔴 decoded-but-dropped trio, moved nine ⬛ rows to ✅ (effects ×2, strokes ×2, styled text, links ×3), and promoted the angular gradient from 🟡 to ✅. The remaining ⬛ rows (full prototype fidelity beyond links, component/variant properties,
layoutGrids, masks,maxLines/truncation) are the structurally harder IR-design work aligned with epic #242 — not whitelist one-liners.Recently closed (this matrix reads as progress, not just backlog)
First batch
These six just-merged PRs fixed exactly the Kiwi-name / decoded-but-dropped pattern this matrix tracks:
rectangleTopLeftCornerRadiusetc.) → per-cornerborder-radius✅textCase(→text-transform/font-variant) and captureparagraphSpacing✅visible:false) nodes and their subtree during emission ✅blendModeasmix-blend-mode✅stack*) +minSize/maxSize+ constraint translation ✅Second batch (continued progress — same pattern, deeper coverage)
Six more just-merged PRs closed the highest-impact ⬛/🔴/🟡 rows, mostly by lighting up normalizer/emitter code that was already written and only waiting on a decoder-whitelist entry:
paragraphIndent→text-indent,textAutoResize→ content-sizing (fit-content/height:auto/overflow:hidden),stackCounterSpacing→ two-valuegapfor wrapping layouts ✅effects+Effectstruct to the policy → drop/inner shadow (box-shadow) and layer/background blur (filter/backdrop-filter); KiwiFOREGROUND_BLURbridged to RESTLAYER_BLURin the normalizer ✅characterStyleIDs+styleOverrideTableto theTextDatapolicy → per-character bold/colored/linked spans; KiwistyleOverrideTableis aNodeChange[](not a map), bridged in the normalizer ✅border-style:dashed(exact dash lengths residual 🟡)hyperlink,prototypeInteractions,reactions,transitionNodeID+ supporting struct policies → real<a class="figma-link">; scope is link extraction only, prototype animation/overlay/swap data stays undecoded ✅conic-gradient, reusing the figma-transformer: apply gradient transform to compute real CSS gradient angle #317 matrix→angle math for thefromangle + center ✅ (diamond gradient remains diagnostic — no faithful CSS primitive)Every batch-2 decoder PR also added a real Kiwi-binary decode fixture (synthetic schema + encoded message run through the actual
FigKiwiDecoder), proving gate 1 directly rather than just the normalizer bridge.FSE Pilot validation (real
.figrun)After both batches landed, the transformer was re-run on the FSE Pilot Build Theme
.figand the emitted output was measured directly:Confirmed emitting on real data (counts are occurrences in the emitted output):
text-transform— 16 (was 0 pre-batch)border-*-radius— 8fit-contentfromtextAutoResize— 32max-widthfrom min/max-size — 8box-shadowfrom effects — 2Proven by real-Kiwi-binary decode contract fixtures, but not surfaced on this render's planned pages: layer/background blur, linear/angular gradient, hyperlinks, inline text spans, node
blendMode. The source.figcarries these on nodes (6 effect nodes, 1 linear gradient, 2 hyperlinks, 9 inline-span nodes) that live on pages the planner does not surface in this render — their absence from the output is "not on the rendered page," not a regression. Each is covered by a real-Kiwi-binary decode contract fixture.Coverage matrix
Evidence cites
origin/trunkline numbers. Decoder policy lives atFigKiwiDecoder.php:378-436(NodeChange list: 384-406).Geometry / Layout
sizesize.x/ytransform(Matrix)m02/m12→ x/y, fullmatrix()transform)rotationis REST-onlyhorizontalConstraint/verticalConstraintstackModedisplay:flex+ direction (#322)stackPadding/stackPadding{Left,Right,Top,Bottom}/stack{Horizontal,Vertical}PaddingstackSpacinggapstackCounterSpacinggapfor wrapping layouts (#332)stackPrimarySizing/stackCounterSizingstackChildPrimaryGrowflex-growstackChildAlignSelfstackPrimaryAlignItems/stackCounterAlignItemsjustify-content/align-itemsstackWrapflex-wrapstackPositioningminSize/maxSize(OptionalVector)min/max-width/heightstackReverseZIndexuseAbsoluteBoundslayoutGridsVisual / Box
fillPaintsfillPaints(GRADIENT_LINEAR/RADIAL)fillPaints(GRADIENT_ANGULAR)conic-gradient; reuses #317 matrix→angle math for thefromangle + center (#330)fillPaints(GRADIENT_DIAMOND)fillPaints+imageScaleModebackgroundPaintsbackground-colorviabackgroundkeystrokePaintsstrokeWeight(+ per-side)strokeAligndashPatternborder-style:dashedonly — exact dash lengths need SVG/background (#335)strokeCap/strokeJoincornerRadiusrectangle{TopLeft,TopRight,BottomLeft,BottomRight}CornerRadiusopacityblendModemix-blend-mode(#320)effectseffects+Effectstruct to the policy →box-shadow(#333)effectsfilter/backdrop-filter; KiwiFOREGROUND_BLURbridged to RESTLAYER_BLURin the normalizer (#333)maskText
fontName(family/postscript/style)fontSizefontName.style→ weightlineHeight(Number value/units)letterSpacingtextAlignHorizontaltextAlignVerticaltextCasetext-transform/font-variant(#318)textDecorationline-through, Emitter.php:4031)paragraphSpacingparagraphIndenttext-indent(#332)textAutoResizefit-content/height:auto/overflow:hidden) (#332)characterStyleIDs/styleOverrideTableTextDatapolicy; KiwistyleOverrideTableis aNodeChange[](not a map), bridged in the normalizer (#334)maxLines/textTruncationlineTypes/lineIndentationsComponent system
symbolDatasymbolData.symbolOverridescomponentPropertiesvariantPropertiesInteractivity / Prototype
reactions/prototypeInteractionsa.figma-link; only link extraction in scope — prototype animation/overlay/swap data stays undecoded (#336)hyperlinkhyperlink+Hyperlink{url,guid}struct → linked text (#336)transitionNodeIDPrototypeActionstruct → node-navigation links (#336)scrollBehavior/fixedPositionConstraint/scrollDirectionMetadata / Dev / Bounds
visiblevisible:falseskips node+subtree (#319)namedevStatus/sectionStatus/handoffStatusisClipoverflow:hiddensize+transform.fighas noabsoluteBoundingBox; computed from size+transformfillGeometry/strokeGeometry/vectorDatabooleanOperationlockedexportSettingsannotationsPrioritized remaining work (swarmable — each item is one PR-sized change)
The cheap-win 🔴 trio and the already-finished-but-starved normalizer/emitter machinery (effects, strokes, styled text, links, angular gradient) all shipped in batch 2. What remains is the structurally harder IR-design work — new normalizer and emitter mapping, not whitelist one-liners — aligned with epic #242.
🔴 Cheap wins — decoded, just wire the normalizer (one-line / small)
stackReverseZIndex→ child paint order — low impact, mechanical.useAbsoluteBounds→ bounds-source flag — low impact, mechanical.⬛ Structurally harder — new normalizer + emitter mapping (not just a whitelist add) — ordered by visual impact
mask) and scroll/fixed/sticky (fixedPositionConstraint,scrollBehavior,scrollDirection) — clipping + sticky/fixed positioning.lineTypes/lineIndentations) and maxLines/truncation (maxLines/textTruncation) — ordered/unordered lists and ellipsis/line-clamp.layoutGrids) — column/row grid reconstruction.reactions/prototypeInteractionspast link extraction).componentProperties/variantProperties) — round-trip/editing fidelity; the resolved variant already renders correctly in static output.🟡 Residual gaps in already-shipped paths
border-style:dashedships today (figma-transformer: decode stroke weight/align/dashes so borders render at design width #335); precise dash geometry needs an SVG/background path.textDecorationSTRIKETHROUGH — emitter expectsline-throughbut the Kiwi enum yieldsstrikethrough(Emitter.php:4031); add the token mapping.TILE/CROPscale modes — refine beyond the cover/100% approximation (Emitter.php:4511).Out of scope / N/A
locked— editor-only; no rendered effect.exportSettings— no static-CSS target; relevant only if/when the transformer drives asset export.annotations— dev-note metadata, not a visual property.variantProperties/componentProperties— the selected variant is already materialized in the instance's node subtree, so static output renders correctly without them; useful only for round-trip/editing fidelity.Relationship to epic #242
This matrix is the data-parity companion to the maintainability/swarm-refactor epic #242. Where #242 decomposes the emitter and formalizes the finding contract so work can be parallelized safely, this issue defines what that parallel work should target: each 🔴/⬛/🟡 row above is sized to become its own PR, matching the swarm model. The two together make the transformer both maintainable and provably complete.
AI assistance