Figma capture: consumer-ready fidelity + ForEach 2-D reflection - #23
Draft
3lvis wants to merge 32 commits into
Draft
Figma capture: consumer-ready fidelity + ForEach 2-D reflection#233lvis wants to merge 32 commits into
3lvis wants to merge 32 commits into
Conversation
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).
…ist-only resolution)
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).
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ForEachrows.What's now true
PinCaptureTokens.currentlets 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.Listbackground all capture correctly. Both themes tokenize via per-theme variables (a free-plan Figma collection is capped at one mode).ForEachrows capture via reflection — the headline. A row likeHStack[ 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 theForEach(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.Validation
OrderItemView(leading thumbnail + a stacked text column + a pill row + a quantity/price footer; per-row optional detail and pills). Both capture asroot=screenwith the correct nested layout.figma-cartframe→screen; the other 29 demos byte-identical. Adding Order Summary is purely additive.Not in scope / known
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..stroketo a filled ring — no editable width) and the on-screen-only SALE-pill nuance. Both documented.