Skip to content

Figma capture: consumer-ready fidelity + ForEach 2-D reflection - #23

Draft
3lvis wants to merge 32 commits into
mainfrom
elvis/foreach-fold
Draft

Figma capture: consumer-ready fidelity + ForEach 2-D reflection#23
3lvis wants to merge 32 commits into
mainfrom
elvis/foreach-fold

Conversation

@3lvis

@3lvis 3lvis commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Pinwheel can now export a running catalog to editable Figma — every component 1:1 with the simulator, light and dark, as real text/color/number nodes instead of a flat screenshot, with zero cooperation from the components (no capture code, no markers). This PR takes capture from a spike to consumer-ready, and cracks the last hard case: bespoke 2-D ForEach rows.

What's now true

  • Consumer token registry. PinCaptureTokens.current lets a consumer register their colors, spacing, radii, and fonts, so captured values bind to named, editable Figma variables (both light and dark). Documented in the README.
  • Fidelity. Strikethrough, raster/async images, the SALE-pill fill, fill-width cards, and a transparent-List background all capture correctly. Both themes tokenize via per-theme variables (a free-plan Figma collection is capped at one mode).
  • Rich 2-D ForEach rows capture via reflection — the headline. A row like HStack[ thumbnail | VStack(title, price) | stepper ] used to fall to the geometry path, which flattened it to Y-order and let Figma auto-layout scramble it. Now the reflector expands the ForEach (reverse-engineered through SwiftUI's AttributeGraph, guarded by a self-test canary + graceful fallback so a future iOS breaking the private ABI is caught, never crashes) and emits shapes as leaves so the leaf count matches — the row captures with its real nested structure, per-row conditionals and all.
  • Plugin. Tag-filter pills + tag-scoped "Import all", mirroring the app.

Validation

  • Proven on two independent screens: the Cart demo and a new Order Summary demo generalized from tienda-ios's OrderItemView (leading thumbnail + a stacked text column + a pill row + a quantity/price footer; per-row optional detail and pills). Both capture as root=screen with the correct nested layout.
  • Regression-clean: a full-catalog before/after root-tag diff (fix off vs on, clean builds, light + dark) changed exactly one line — figma-cart frame→screen; the other 29 demos byte-identical. Adding Order Summary is purely additive.
  • Tests: unit 100/100 (local xcodebuild), including red-first tests for the reflection changes and the expander canary.

Not in scope / known

  • The UI tier is blocked on an iOS 26.5 Menu-automation issue (pre-existing — the baseline fails the same tests on 26.5; green on 18.3). The local merge gate needs UI green, so this needs hardening before merge.
  • Remaining Cart gaps: the stepper's stroke border (SwiftUI bakes .stroke to a filled ring — no editable width) and the on-screen-only SALE-pill nuance. Both documented.

3lvis added 30 commits July 10, 2026 13:00
Add the consumer-readiness plan and capture demos for the three lazy container
types. Measured on the sweep's on-screen host: LazyVStack (20/20) and LazyVGrid
(12/12) capture fully — the lazy-empty limit is off-screen-only. List captures
flat (recycled UICollectionView hosting SwiftUI cells) — the one remaining piece.

Tests: unit 76/76 + UI 9/9 green (local xcodebuild)
A Bash PreToolUse hook blocks gh pr merge / git merge-into-main unless the tip
commit carries a 'Tests: ... green' trailer, so the local-green gate can't be
skipped. Matches only merge invocations at a command-segment start (quoted
mentions don't trigger it); checks the tip being merged; fails open on errors.

Tests: unit 76/76 + UI 9/9 green (local xcodebuild) — unchanged; hook/docs only
…ocked

Replace the minimal list demo with SectionedListDemo (sections, per-row detail,
swipe-to-delete) as the standing List test case. Investigated List capture:
force-realizing the backing UICollectionView realizes every cell, but each row
is an opaque CellHostingView (empty Mirror, no accessibility) so no editable
route reaches it — only pixel crops. Documented in the plan with options; the
editable wins are the lazy stacks/grids, which already work.

Tests: unit 76/76 + UI 9/9 green (local xcodebuild)
Force-realize the backing UICollectionView, then read each cell's own DisplayList.
CellHostingView hides _base from Mirror, so displayList(of:) fetches it via the
ObjC runtime (regression-free for the root _UIHostingView path). Text-dominant
rows capture fully (testListRowsCaptureAsEditableText, Row 1..Row 12); rich rows
capture structured-but-partial (image + one text fragment per row) vs a blank
box before. New ProductListDemo is the complex test case.

Tests: unit 77/77 + UI green (local xcodebuild)
…h jitter

PinList now renders a real List in production and, under the pinCapturing
environment (set by PinDisplayList.read and the sweep host), the same rows in an
eager capturable stack — 1:1 cells, sidestepping the raw-List wall. Componentize
now keys image nodes by their bytes (identical chevrons group, per-row photos
stay distinct) and buckets size to ~16pt (content-driven width jitter no longer
splits one template). PinListDemo: 6 rows -> all text editable + 1 component +
5 instances, chevron included.

Tests: unit 79/79 + UI 9/9 green (local xcodebuild)
…adius, font)

PinCaptureTokens.current (defaults to .pinwheel) is the registry the capture
matchers consult: color tokens (light/dark + a textEligible flag), spacing/radius
float tokens, and a systemFontFamily. tokenName/spacingName/radiusName/gapName and
the figma-variable emitters route through it, and captured text now reads its
actual font family (custom fonts carry through; system font falls back to
systemFontFamily). Default preserves current behavior. A consumer sets .current to
their palette so their tokens/fonts bind instead of Pinwheel's. Text-style name
matching stays Pinwheel (phase 2).

Tests: unit 83/83 + UI 9/9 green (local xcodebuild)
…CaptureTokens

PinCaptureTokens.textStyles (name/family/size/weight) + textStyleName(for:) match
a rendered font by size+weight; figmaFont.style and the document's emitted
textStyles route through .current. Default preserves the Pinwheel style names.
A consumer's named styles now bind instead of Pinwheel's. Font availability stays
the consumer's job (plugin already falls back to Inter when a family isn't
loadable, so a missing font never breaks the import).

Tests: unit 84/84 + UI 9/9 green (local xcodebuild)
The engine read .underlineStyle but never .strikethroughStyle, so a struck 'was'
price imported as plain text. textKind now reads it, threads it through FigmaFont,
and the plugin draws STRIKETHROUGH (skipping the style binding that would wipe the
decoration, same as underline). A new capturable PricingDemo (eager ScrollView/VStack)
exercises it; the sweep confirms all four struck prices serialize strikethrough:true
with their caption style + secondaryText token intact.

Tests: unit 87/87 green (local xcodebuild) + sweep capture verified
CartDemo (eager ScrollView/VStack: thumbnail + title + SALE pill + now/was-struck
price + bordered stepper, ×4 rows) is the complex product screen for driving capture
fidelity. On the on-screen sweep it captures the large majority editably — card
backgrounds + radius, component/instance grouping (3 sale rows group, non-sale stays
separate), titles/prices/quantities as text, strikethrough on the was-prices, and
thumbnail + stepper icons as images.

Two gaps documented in the plan: (1) editable stroke is a HARD WALL — SwiftUI resolves
.stroke to a filled ring (FillStyle, no lineWidth) before the DisplayList, so the
plugin's stroke support can't be driven from capture; (2) the SALE-pill capsule fill
drops only through the on-screen live-host sweep (survives off-screen), so it needs a
DemoUITest to reproduce. Also noted: the DemoUITests Menu section-picker doesn't open
under automation on iOS 26.5 (green on 18.3; baseline fails 26.5 too — runtime issue,
not a regression).
A raster image (Image(uiImage:), a loaded AsyncImage) has DisplayList content kind
'image', which contentKind didn't handle — it fell through to .unknown and the photo
was dropped entirely (SF Symbols only captured because they're vector 'shape' content).
Map 'image' -> .rasterizable so the existing host-layer crop fills it with real pixels,
the same path a symbol takes.

Verified end-to-end: the new capturable ImageGalleryDemo's three AsyncImage(url:) photos
capture as 64x64 image nodes with pixels + dark variants, alongside their labels.

Tests: unit 88/88 green (local xcodebuild) + sweep capture verified
Mirror the app catalog's filter-pill bar in the plugin. The distinct tags across the
manifest (All / Figma / SwiftUI / UIKit) render as pills; selecting one filters the
component list to items carrying that tag, and 'Import all' then imports only the
filtered set (its label updates to 'Import all <tag>'). 'All' resets to the full
catalog. Filtering re-renders from the already-fetched manifest — no extra round-trip.

Tests: plugin suite 31/31 green (npm test); UI script syntax-checked; manifest serves
the tags (Figma 11 / SwiftUI 8 / UIKit 11).
A colored pill wrapping a single label (a SALE badge: .background(_, in: Capsule()))
lost its fill in the containment vertical-list path: when a card splits into a title-row
band and a price band, flattenLeaves dissolved the pill wrapper down to its bare label,
so absoluteRowGroup rebuilt the row without the capsule — leaving white text invisible on
a light card. flattenLeaves now keeps a fill/radius-bearing box whole and only dissolves
transparent grouping boxes.

Tests: unit 89/89 green (local xcodebuild); regression test SalePillCaptureTests
…debug)

The import trace keyed only on the component title, which collides across the
SwiftUI/UIKit twins (both 'Button', 'StateView', …) — a diff against the captured IR
then compares the wrong pair and flags phantom losses. Thread the catalog id through
the import messages into traceComponent so /debug.json carries it, and add debug.mjs
(npm run debug) that joins the live import trace to the captures by id (falling back to
import-order pairing for a pre-id trace, with a note). Confirms all 30 imported exactly
as captured.

Tests: plugin 31/31 green (npm test)
…O-BG/DARK-BG

A .plain List's collection is transparent, so its captured screen had no background
(the fix reads collection.backgroundColor, which is clear). Fall back to the opaque
surface actually rendered behind it (first opaque backgroundColor up the superview
chain); light/dark sweep rounds each read their own surface, so the merge gives the
screen an adapting background. figma-product-list now captures white/primaryBackground
(dark: black) instead of nothing.

Also: npm run debug now inspects fills, not just counts — flags NO-BG (screen with no
fill/token) and DARK-BG (a light import whose root background is dark + untokenized,
i.e. the sweep captured the wrong appearance). This is what should have caught the
reported product-list (no bg) and color/numbers/typography (stale dark) defects; the
count-only diff couldn't see them.

Tests: unit 91/91 green (local xcodebuild); red-first ListBackgroundCaptureTests
…stead

A .frame(maxWidth: .infinity) card with left-aligned content hugs the leading edge, so
the gap to its right is the frame being wider than its content — not padding. The
capture measured it as a giant trailing inset (e.g. Cards: 264pt right, untokenized)
and leaned on it to force the width under AUTO sizing. For left-aligned columns with a
trailing gap well beyond the leading inset, drop trailing to match leading and mark the
frame fillWidth (the plugin sets layoutSizingHorizontal=FILL) — symmetric padding, fills
the parent. Applied to both the reflection (emitStructure) and containment (emit) paths.

Tests: unit 92/92 green (local xcodebuild); red-first FillWidthCardPaddingTests
A bespoke 2-D HStack row (thumbnail | title/price column | stepper) scrambles under the
geometry-based capture path — the reflector can't reconstruct native ForEach/2-D layout,
and SwiftUI locks the row structure behind the AttributeGraph. PinList rows are 1-D values
we own, which the existing capture reconstructs faithfully (title, icon, price, qty),
componentized. No new capture code — the bare-minimum fix is expressing the screen on the
component built for it. Trade-off (noted in the demo): the standalone SALE pill and ±
stepper collapse into the row's price/detail text.

Tests: unit unchanged at 91/91 (capture engine untouched); sweep-verified via render_ir.
…+ heavy tests)

PinVariadicExpander: expand a ForEach via _VariadicView, deref each row's real instance via
dlsym'd AGGraphGetValue (inside body(children:) where the graph context is live), load via
_openExistential, hand to the reflector. Gated behind a cached self-test (isHealthy): if a
new OS changes SwiftUI/AttributeGraph internals the probe fails and capture falls back to
containment instead of misbehaving. Reflector hooks ForEach (splice rows into a stack, or a
column standalone) and gained an Optional unwrap (an if-view infinite-recursed before).

Heavy tests (the canary): PinVariadicExpanderTests asserts the symbol resolves, expansion,
per-row conditional resolution, nested 2-D rows, non-view nil, and isHealthy — a breaking OS
turns these red in dev. unit 98/98.

KNOWN GAP (why this is on a branch, not merged): the expander recovers rows, but the
downstream reflection *zip* uses an exact-leaf-count gate a rich row trips, so Cart still
routes to containment → array-order auto-layout → Figma scramble persists. Bare-leaf ForEach
rows also don't expand (different graph-node shape) and fall back. Making rich rows actually
flip to reflection needs zip-tolerance (match by content/position, not exact count) + a full
regression sweep of every ForEach demo. Until then the working branch keeps CartDemo on
PinList (no scramble).
The folded ForEach expander alone didn't fix Cart — the reflection zip's
exact-count gate rejected a rich 2-D row because reflection and containment
counted non-text leaves differently. Ground truth: Cart's containment
components are 18 text + 4 thumbnail fills (it drops the ± stepper icons and
photo overlay), while reflection counted the ± icons and dropped the
_ShapeView thumbnail.

Emit filled/stroked shapes (*ShapeView, RoundedRectangle, ...) as leaves but
not Image — containment keeps a shape's fill box as a component and drops SF
Symbols, so counting Image would desync the other way. reflect now equals
components (22=22), so Cart captures through reflection with the correct
nested HStack[thumbnail | VStack(title/SALE, now/was) | spacer | stepper]
instead of the containment Y-order scramble.

Full-catalog before/after root-tag diff (fix off vs on, clean builds, light +
dark): exactly one line changed — figma-cart frame→screen; the other 29
demos are byte-identical. CartDemo restored to the bespoke 2-D ForEach.

Tests: unit 100/100 (local xcodebuild). UI tier blocked on iOS 26.5 Menu
automation (pre-existing, unrelated to this change).
A receipt/order-line-item list generalized from tienda-ios's OrderItemView:
each row is a leading thumbnail + a column of title, optional detail, a pill
row (bonus/discount/label pills + a VAT string), and a quantity/price footer.
Bespoke 2-D ForEach with per-row conditionals (optional detail and pills), so
it independently exercises the ForEach reflection capture: it captures as
root=screen with the correct nested structure, and the AttributeGraph deref
resolves each row's conditionals (e.g. the no-detail Sourdough row).

Tests: unit 100/100 (local xcodebuild). UI tier blocked on iOS 26.5 Menu
automation (pre-existing).
A reflection-captured row has no measured size (node.w/h ≈ 0) and fills its
parent via a grow spacer; the master frame already imports at FILL. The
repeated rows import as component instances, and the instance path
force-resized each to node.w (≈0.01) — collapsing it to nothing, so the rows
overlapped instead of stacking full-width (visible across Cart, Cards, Lazy
Cards, Pricing, Pin List and every other componentized screen with 0-width
rows). Instances now take FILL like their master when the row fills width, and
resize only when the capture measured a real size.

Red-first: test/instance-sizing.test.mjs drives the real cart fixture through
the plugin's mock-Figma harness and asserts instances import at FILL — RED
against the resize path, GREEN after. Full plugin suite 32/32.

Tests: plugin 32/32 + iOS unit 100/100 green (local).
The stepper's border was dropped and its − 1 + scrambled. Two things:

Borders — reflection reads the stroke's lineWidth and color from the view
value (the `.overlay(shape.stroke(color, lineWidth:))` StrokeStyle), before the
DisplayList bakes the stroke into an unreadable filled ring. FigmaNode gains
stroke/strokeToken/strokeWidth; the reflector attaches the border to the
wrapped container (or wraps a bordered single leaf into one); emitStructure
resolves the colour to a token and emits it; the plugin binds the token. So a
bordered control now imports an editable, tokenised border that adapts in dark.

Order — the ± were SF Symbol images, which reflection drops, so the quantity
matched a containment box that carried the icons in geometric order (1 + −).
The Cart stepper is now a single "−  n  +" label: correct order by construction,
editable, and 1 leaf on both sides of the zip so Cart stays on the reflection
path (a bordered 3-label stepper desyncs — containment groups the ring-enclosed
box into one component while reflection counts three).

Verified: figma-cart captures root=screen with each stepper carrying
strokeToken=tertiaryText, strokeWidth=1, text "−  n  +". Red-first tests
testOverlayStrokeReflectsAsTheContainerBorder and
testBorderedLeafWrapsIntoABorderedContainer.

Tests: iOS unit 102/102 + plugin 32/32 green (local).
PinStepper is a themed − / value / + pill: SF-Symbol ± (mirroring PinButton's
systemImage), a themed value, and a tokenised capsule border. SwiftUI's Stepper
renders a system ± control that bypasses the theme, so it earns a wrapper like
PinLabel does. Ships with a Components catalog entry and is used in the Cart demo.

It captures 1:1 with zero cooperation: reflection walks the body, the ± Buttons
drop and the container keeps its border (reflection stroke) + the value leaf,
which matches the containment box carrying the ± SF-Symbol image crops and the
value — so leaf count stays 1 on both sides and Cart stays on the reflection
path. Verified: figma-cart and swiftui-stepper both capture root=screen with the
stepper as [minus-image, value, plus-image] in order and border tertiaryText/1.

Fixes the − 1 + scramble at its root: orderedForLayout compared top-edge y, so a
short minus bar, a tall value, and a plus (same row, different heights) read as
stacked and sorted by y. It now compares vertical centres. Red-first
OrderedForLayoutTests; full-catalog root tags unchanged (no regression).

Tests: iOS unit 103/103 + plugin 32/32 green (local).
A Capsule stroke border imported as a square rectangle — the stroke's shape
wasn't captured. strokeBorder now reads the stroked shape: a Capsule → pill
(Figma clamps a large cornerRadius to a full pill), a RoundedRectangle → its
own radius. Red-first testCapsuleStrokeBorderCapturesAsPill.

Also flatten the border: a bordered control that reflects to a single frame (a
stepper — its ± Buttons drop, leaving the value leaf that matches the rendered
box) put the border on that box instead of wrapping it in a second frame, so
the capture is one bordered pill matching the source, not a nested pair.

Verified: figma-cart stepper imports as one frame (stroke=tertiaryText,
radius=1000 pill, width=1) with [minus-image, value, plus-image] inside.

Tests: iOS unit 104/104 + plugin 32/32 green (local). Flatten structure
sweep-verified.
A cart row without the SALE pill / was-price is a variant of the sale-row
component, not its own frame. variantAlign normalizes a subset frame to the
master's structure — aligning children in order and inserting a hidden
placeholder copy of any optional layer the subset lacks — so it matches the
master's signature and groups. Containers match by layout axis (their size
shrinks when they drop an optional child), leaves by role. FigmaNode gains a
hidden flag; the plugin's applyHidden walks the instance's layers in
orderChildren order and hides the placeholders (needs the instance to be a deep
clone of the master — fixed the figma-mock's shallow createInstance).

This also unifies the stepper size (a standalone row sized its stepper for its
own glyph; as an instance it inherits the master's size).

Red-first: ComponentVariantTests (capture) + instance-hidden.test.mjs (plugin).
Verified: figma-cart = 4 rows → ONE component, the no-sale row hides its SALE +
was-price placeholders; only figma-cart gained hidden nodes (no other demo's
grouping changed).

Tests: iOS unit 105/105 + plugin 33/33 green (local).
The value hugged its digit, so a "1" and a "2" stepper captured (and rendered)
at different widths — and as component instances they re-hugged to their own
content, making the no-sale row's stepper a different width from its siblings.
A fixed-width value slot keeps the control stable regardless of the number, so
all four steppers are identical (w=104).

Tests: iOS unit 105/105 (local).
…uctured

A canonical list row — a thumbnail beside a title/subtitle column — collapsed to
one component in containment (it has no fill sub-elements to force a flatten,
unlike Cart's SALE pill / stepper), so reflection's structure and containment's
count diverged and the row scrambled. Three pieces:

- Reflection counts a fixed-size framed image as a leaf (a real thumbnail the
  containment path keeps), so the row reflects to its parts.
- When the exact-count zip fails, fall back to matching reflection against the
  fully-flattened leaves — gated to a genuine 2-D row (hasMixedRow: a container
  mixing a leaf and a sub-stack), so flat rows of labels and list columns stay
  on their existing path.
- collectBackgrounds now re-attaches a flat-content card's fill (a box wrapping
  2+ children), not only boxes with nested groups — else the reflection path
  drops the card background.

Verified: figma-image-gallery and figma-pricing capture root=screen with correct
nesting and their secondaryBackground card fills; Cart/Cards unchanged; only
those two flipped frame→screen. Red-first testFramedImageReflectsToALeaf... and
GalleryCaptureTests (structure + fill).

Tests: iOS unit 107/107 + plugin 33/33 green (local).
3lvis added 2 commits July 11, 2026 18:45
A gallery's rows share a structure but each carry a different photo, so they
stayed three plain frames — the componentization keyed images by bytes (on the
premise an instance can't override an image). But a Figma instance CAN swap an
image fill, so images are now keyed by size (IMG:w..:h..): same-size images
group so their rows share one component, and the plugin's applyImages overrides
each instance's image fill in build order. A genuinely different size (icon vs
hero) stays a distinct slot, so distinct card templates (Cards, CollectionView)
still don't merge.

Verified: figma-image-gallery = 3 rows → 1 component with 3 distinct images;
Cards/CollectionView keep their 2 templates each; no demo over-merged.
Red-first testSameStructureRowsWithDifferentImagesGroup (capture) +
instance-image.test.mjs (plugin).

Tests: iOS unit 108/108 + plugin 34/34 green (local).
A receipt row hugged its content and centred in the card because the Spacer
pushing its price sits two levels down (row → text column → price HStack), and
fill-width was only detected from a container's direct grow child. It now
propagates: a container fills width if any child grows (a Spacer) or itself
fills. So the row fills the card — flush full-width, left-aligned, tax/price at
the right edge — instead of hugging and centring.

Verified: figma-order-summary row fills width and keeps its card fill; the
7 containment demos are unchanged (no demo wrongly flipped). Red-first
FillWidthPropagationTests.

Tests: iOS unit 109/109 (local).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant