Automattic / Automattic/blocks-engine
[Tracking] Kiwi → IR → HTML field coverage matrix (data parity foundation)
- Dominant language
- PHP
- Stars
- 14
- Forks
- 2
- Avg merge
- 2h 10m
- Merged PRs (30d)
- 561
Description
## 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. **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.
2. **Normalized** — `figma-transformer/src/Scenegraph/ScenegraphNormalizer.php` maps the decoded field into the normalized IR contract.
3. **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:
- **#316** — wire Kiwi per-corner radius field names (`rectangleTopLeftCornerRadius` etc.) → per-corner `border-radius` ✅
- **#317** — apply gradient transform to compute the real CSS linear-gradient angle ✅
- **#318** — support `textCase` (→ `text-transform`/`font-variant`) and capture `paragraphSpacing` ✅
- **#319** — skip invisible (`visible:false`) nodes and their subtree during emission ✅
- **#320** — emit node-level `blendMode` as `mix-blend-mode` ✅
- **#322** — wire Kiwi-format auto-layout field names (`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:
- **#332** — the 🔴 decoded-but-dropped trio: `paragraphIndent` → `text-indent`, `textAutoResize` → content-sizing (`fit-content`/`height:auto`/`overflow:hidden`), `stackCounterSpacing` → two-value `gap` for wrapping layouts ✅
- **#333** — node effects: added `effects` + `Effect` struct to the policy → drop/inner shadow (`box-shadow`) and layer/background blur (`filter`/`backdrop-filter`); Kiwi `FOREGROUND_BLUR` bridged to REST `LAYER_BLUR` in the normalizer ✅
- **#334** — inline styled text segments: added `characterStyleIDs` + `styleOverrideTable` to the `TextData` policy → per-character bold/colored/linked spans; Kiwi `styleOverrideTable` is a `NodeChange[]` (not a map), bridged in the normalizer ✅
- **#335** — stroke weight/align/dashes: added stroke fields + per-side weights → real border weights and INSIDE/CENTER/OUTSIDE align ✅; dashes ship as `border-style:dashed` (exact dash lengths residual 🟡)
- **#336** — hyperlinks + reaction URL/node-navigation links: added `hyperlink`, `prototypeInteractions`, `reactions`, `transitionNodeID` + supporting struct policies → real ``; scope is link extraction only, prototype animation/overlay/swap data stays undecoded ✅
- **#330** — angular gradient → CSS `conic-gradient`, reusing the #317 matrix→angle math for the `from` angle + 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 `.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 (#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
Contributor guide
Research direction
Start with the coverage matrix and the three named gates: FigKiwiDecoder.php, ScenegraphNormalizer.php, and StaticHtmlEmitter.php. Compare each remaining partial, dropped, or undecoded row with the cited policy and implementation locations, then validate changes with the real-Kiwi-binary fixtures and FSE Pilot render described in the issue. Done means the selected field’s decode, normalization, and emission status is updated with evidence.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- css, html, php
- Domain
- tooling, web-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100