Skip to content

[Tracking] Kiwi → IR → HTML field coverage matrix (data parity foundation) #328

Description

@chubes4

Thesis

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

  1. Decodedfigma-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.
  2. Normalizedfigma-transformer/src/Scenegraph/ScenegraphNormalizer.php maps the decoded field into the normalized IR contract.
  3. Emittedfigma-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).

Geometry / Layout

Field Kiwi name Dec Norm Emit Status Notes / gap Evidence
Size size width/height from size.x/y Normalizer.php:3441
Position / transform transform (Matrix) m02/m12 → x/y, full matrix() Normalizer.php:3190,3449 / Emitter.php:3507
Rotation (in transform) derived from matrix; flat rotation is REST-only Emitter.php:3480
Constraints H/V horizontalConstraint / verticalConstraint Kiwi MIN/MAX/STRETCH → REST vocab Normalizer.php:3657,3720
Auto-layout mode stackMode display:flex + direction (#322) Normalizer.php:3484
Padding stackPadding / stackPadding{Left,Right,Top,Bottom} / stack{Horizontal,Vertical}Padding Normalizer.php:3563-3591
Item spacing stackSpacing gap Normalizer.php:3598
Counter-axis / wrap spacing stackCounterSpacing → two-value gap for wrapping layouts (#332) Decoder.php:404
Axis sizing (HUG/FILL/FIXED) stackPrimarySizing / stackCounterSizing bridged onto physical axes (#322) Normalizer.php:3512-3541
Grow stackChildPrimaryGrow flex-grow Normalizer.php:3629
Align self stackChildAlignSelf Normalizer.php:3635
Primary/counter align stackPrimaryAlignItems / stackCounterAlignItems justify-content/align-items Normalizer.php:3543-3560
Wrap stackWrap flex-wrap Normalizer.php:3607
Absolute positioning stackPositioning ABSOLUTE escape Normalizer.php:3619
Min/max size minSize / maxSize (OptionalVector) min/max-width/height Normalizer.php:3673
Reverse z-index stackReverseZIndex 🔴 🔴 🔴 decoded, never read; "last on top" order lost (low impact) Decoder.php:404
Use absolute bounds useAbsoluteBounds 🔴 🔴 🔴 decoded, never read; bounds-source flag (low impact) Decoder.php:391
Layout grids layoutGrids not in policy; column/row grids not reconstructed Decoder.php:384-406

Visual / Box

Field Kiwi name Dec Norm Emit Status Notes / gap Evidence
Fill — solid fillPaints Normalizer.php:2155 / Emitter.php:5510
Fill — gradient linear/radial fillPaints (GRADIENT_LINEAR/RADIAL) real angle (#317) Normalizer.php:2228 / Emitter.php:5516,5587
Fill — gradient angular fillPaints (GRADIENT_ANGULAR) conic-gradient; reuses #317 matrix→angle math for the from angle + center (#330) Emitter.php:5516
Fill — gradient diamond fillPaints (GRADIENT_DIAMOND) 🟡 🟡 🟡 no faithful CSS primitive; radial approximation is the ceiling — kept diagnostic Emitter.php:5516
Fill — image fillPaints + imageScaleMode FILL/FIT/STRETCH; TILE/CROP approximate (🟡) Normalizer.php:2176 / Emitter.php:4511
Background paint backgroundPaints background-color via background key Normalizer.php:2083 / Emitter.php:5480
Stroke — color strokePaints Normalizer.php:2083 / Emitter.php:4093
Stroke — weight strokeWeight (+ per-side) added to policy with per-side weights; borders now use the real weight (#335) Emitter.php:4100
Stroke — align strokeAlign INSIDE/CENTER/OUTSIDE now arrive (#335) Emitter.php:4111
Stroke — dashes dashPattern 🟡 🟡 CSS border-style:dashed only — exact dash lengths need SVG/background (#335) Emitter.php:4091
Stroke — caps/joins strokeCap / strokeJoin vector stroke detail (low impact) Decoder.php:384-406
Corner radius — uniform cornerRadius Normalizer.php:3198
Corner radius — per-corner rectangle{TopLeft,TopRight,BottomLeft,BottomRight}CornerRadius (#316) Normalizer.php:3206-3232
Opacity opacity Normalizer.php:3176 / Emitter.php:2788
Node blend mode blendMode mix-blend-mode (#320) Normalizer.php:3180 / Emitter.php:2792
Effects — drop/inner shadow effects added effects + Effect struct to the policy → box-shadow (#333) Normalizer.php:3244 / Emitter.php:4122
Effects — layer/background blur effects filter/backdrop-filter; Kiwi FOREGROUND_BLUR bridged to REST LAYER_BLUR in the normalizer (#333) Normalizer.php:3276 / Emitter.php:4149
Mask mask masked groups not clipped Decoder.php:384-406

Text

Field Kiwi name Dec Norm Emit Status Notes / gap Evidence
Font family / postscript fontName (family/postscript/style) Normalizer.php:1558
Font size fontSize Normalizer.php:1573
Font weight fontName.style → weight derived from style token Normalizer.php:1565,1646
Line height lineHeight (Number value/units) PIXELS/RAW/PERCENT Normalizer.php:1579
Letter spacing letterSpacing px / em Normalizer.php:1590
Text align horizontal textAlignHorizontal Normalizer.php:1549
Text align vertical textAlignVertical → flex on text box Emitter.php:4021
Text case textCase text-transform/font-variant (#318) Normalizer.php:1618
Text decoration textDecoration 🟡 🟡 underline emitted; STRIKETHROUGH enum token unmatched (emitter checks line-through, Emitter.php:4031) Normalizer.php:1551
Paragraph spacing paragraphSpacing 🟡 🟡 captured + diagnostic, not emitted as CSS by design (#318) Normalizer.php:1639 / Emitter.php:3840
Paragraph indent paragraphIndent text-indent (#332) Decoder.php:398
Text auto-resize textAutoResize → content-sizing (fit-content/height:auto/overflow:hidden) (#332) Decoder.php:399
Styled segments (inline spans) characterStyleIDs / styleOverrideTable added both to the TextData policy; Kiwi styleOverrideTable is a NodeChange[] (not a map), bridged in the normalizer (#334) Normalizer.php:1684,1751
Max lines / truncation maxLines / textTruncation ellipsis/line-clamp lost Decoder.php:415
List / bullet data lineTypes / lineIndentations ordered/unordered lists render as flat text Decoder.php:415

Component system

Field Kiwi name Dec Norm Emit Status Notes / gap Evidence
Component definitions component/symbol keys Normalizer.php:571
Instances symbolData resolved + cloned Normalizer.php:632,932
Instance overrides symbolData.symbolOverrides per-child override application Normalizer.php:851,1168
Component properties componentProperties 🟡 normalizer reads it (556) but not in policy; mostly metadata — variant is already resolved in the instance subtree Normalizer.php:556 / Decoder.php:384-406
Variant properties variantProperties resolved variant already lives in the node tree (low visual impact) Decoder.php:384-406

Interactivity / Prototype

Field Kiwi name Dec Norm Emit Status Notes / gap Evidence
Reactions / navigation (links) reactions / prototypeInteractions URL + node-nav links → a.figma-link; only link extraction in scope — prototype animation/overlay/swap data stays undecoded (#336) Normalizer.php:2012
Hyperlinks (text) hyperlink added hyperlink + Hyperlink{url,guid} struct → linked text (#336) Normalizer.php:1949
Transition target transitionNodeID decoded via PrototypeAction struct → node-navigation links (#336) Normalizer.php:2035
Scroll / fixed / sticky scrollBehavior / fixedPositionConstraint / scrollDirection sticky headers / fixed elements lost Decoder.php:384-406

Metadata / Dev / Bounds

Field Kiwi name Dec Norm Emit Status Notes / gap Evidence
Visible visible visible:false skips node+subtree (#319) Emitter.php:630
Name name drives classification/semantics Decoder.php:390
Dev status devStatus / sectionStatus / handoffStatus surfaced as findings (#280), not CSS Decoder.php:389 / ScenegraphDevStatus.php
Clips content isClip overflow:hidden Normalizer.php:3639 / Emitter.php:2746
Bounds size + transform .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)

  1. stackReverseZIndex → child paint order — low impact, mechanical.
  2. useAbsoluteBounds → bounds-source flag — low impact, mechanical.

⬛ Structurally harder — new normalizer + emitter mapping (not just a whitelist add) — ordered by visual impact

  1. Mask (mask) and scroll/fixed/sticky (fixedPositionConstraint, scrollBehavior, scrollDirection) — clipping + sticky/fixed positioning.
  2. List/bullet (lineTypes/lineIndentations) and maxLines/truncation (maxLines/textTruncation) — ordered/unordered lists and ellipsis/line-clamp.
  3. Layout grids (layoutGrids) — column/row grid reconstruction.
  4. Full prototype fidelity beyond links — animation/overlay/swap action data (the remainder of reactions/prototypeInteractions past link extraction).
  5. Component/variant properties (componentProperties/variantProperties) — round-trip/editing fidelity; the resolved variant already renders correctly in static output.

🟡 Residual gaps in already-shipped paths

  1. Diamond gradient — no faithful native CSS primitive; radial approximation is the realistic ceiling (kept diagnostic).
  2. Stroke dashes — exact dash lengths — CSS border-style:dashed ships today (figma-transformer: decode stroke weight/align/dashes so borders render at design width #335); precise dash geometry needs an SVG/background path.
  3. textDecoration STRIKETHROUGH — emitter expects line-through but the Kiwi enum yields strikethrough (Emitter.php:4031); add the token mapping.
  4. Image TILE/CROP scale 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.
  • Diamond gradient — no faithful native CSS primitive (radial approximation is the realistic ceiling).

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

  • AI assistance: Yes
  • Tool(s): Claude Sonnet 4.6 via Claude Code
  • Used for: Field coverage audit and matrix authoring

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions