Skip to content

Figma capture: turn a running Pinwheel screen into an editable Figma design - #22

Merged
3lvis merged 261 commits into
mainfrom
elvis/figma-capture-spike
Jul 10, 2026
Merged

Figma capture: turn a running Pinwheel screen into an editable Figma design#22
3lvis merged 261 commits into
mainfrom
elvis/figma-capture-spike

Conversation

@3lvis

@3lvis 3lvis commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Turns a running Pinwheel screen into the JSON the Figma plugin imports, so the app becomes an editable Figma design. Source code is the design source of truth; Figma is the playground.

What it captures

  • @Pinnable components (PinButton/PinLabel) rebuild as real Figma masters + instances, with fills bound to token variables and fonts to text styles.
  • Native controls, images, and SF Symbols rasterize to image nodes (window-crop, since ImageRenderer can't draw UIKit controls).
  • Whole screens: eager content below the fold, lazy PinList rows (grouped, and reused as one component per row shape), nested auto-layout (imported frames reflow), and light + dark (captured by sweeping the simulator in both appearances and merging).
  • A plugin catalog list to import any swept component without relaunching, and a rate-limit-free inspect that reads the built Figma file back to verify it.

What lands where

  • Library (Sources/Pinwheel): a small capture seam — PinComponentStyle, pinCaptured / pinCapturedContainer / pinCapturedRasterized, PinCaptureLayout, and PinList.Row self-capture. Inert when nothing is capturing, so it doesn't affect ordinary rendering.
  • Demo (Demo/FigmaCapture): the capture host, rasterization, the screens, and Scripts/sweep.sh.
  • Plugin (figma-plugin/): the "Pinwheel Capture Import" Figma plugin and the local serve it reads from.

Design + what's verified: Demo/FigmaCapture/SPIKE-FIGMA-CAPTURE.md.

3lvis added 30 commits July 4, 2026 01:43
Eager scroll content lays out in full, so anchors already carry real
below-the-fold positions; size the frame to the content extent instead of
the viewport. Demo checkout scrolls past the fold.
Best-effort POST to http://localhost:8787/capture.json on capture, so the
plugin's Import layers always gets the latest render. Info.plist allows the
loopback call via NSAllowsLocalNetworking.
capture-all.sh renders each catalog item in isolation and pushes its capture
(keyed by id + metadata) to the serve, which the plugin lists and imports.
Exposes PinwheelItem.swiftUIView() so a consumer can host any item. Records
lazy-list capture as considered-and-declined for a mocked-data catalog.
A List/UITableView only lays out visible rows, but the data source is finite:
scroll the backing scroll view page by page, window-crop each, stitch into one
tall image sized to contentSize. Proven on a 30-row list (-PinwheelTableCapture).
A lazy List only lays out visible rows, but the data source is finite: render
the same PinList.Row values eagerly (VStack) and every row captures its
PinLabels as editable text, below the fold included (60 nodes, -PinwheelListCapture).
Scroll-stitch stays as the rasterized fallback for UIKit tables.
pinCapturedContainer marks a row a group; the host nests captured nodes by
geometric containment so each row rebuilds as one Figma frame holding its
labels and its native bits (Toggle/chevron, rasterized). Verified on 18 rows.
Row.body applies .pinCapturedContainer, so a real PinList laid out eagerly
captures each row as a grouped frame of editable labels with no capture code
at the call site. No-op when nothing reads the preference.
PinList.Row marks its chevron/switch with .pinCapturedRasterized (a pure-SwiftUI
marker, no window-capture in the library); the host photographs each marker's
on-screen frame. Real rows now round-trip with labels + switch/chevron images.
A row's capture name is its structure, so identical rows share a component: the
first is the master, the rest instances overriding only text. The native bit is
captured once on the master and inherited — below-fold repeated rows need no photo.
…rames

A container carries a PinCaptureLayout (axis, spacing, padding); the host emits
it and the plugin builds a hugging auto-layout frame that reflows. Proven on a
column card (-PinwheelAutoLayoutCapture). Rolling it through nested rows remains.
The row is a space-between HStack auto-layout holding a Labels column (title/
subtitle) and a Trailing row (detail/chevron); the switch row splits label from
switch the same way. So an imported row reflows — labels stay left, accessory right.
A SwiftUI HStack centers cross-axis by default; the capture hardcoded flex-start,
so rows imported top-aligned (visible on toggle rows where the switch is taller).
Carry the alignment: rows center, label VStacks lead.
Resolve each color token in both appearances and emit both; the plugin binds
them to Light/Dark variable modes so the imported design reskins on mode toggle.
After the light crop pass, force the window dark and re-photograph each
rasterization marker, emitting imageDark alongside image. The plugin's Dark
toggle then shows dark chevrons/switches, not light pixels on a dark canvas.
Toggle draws taller than its intrinsic frame (28pt) — 31pt — so the rasterization
crop clipped its rounded top/bottom. A 56x34 frame contains it, so the captured
switch keeps its real aspect (1.66) instead of a squished 2.2.
A SwiftUI Toggle right-aligns the switch in its frame and draws taller than it,
so a sized frame pinned the switch to the crop edge and cut its cap. Horizontal/
vertical padding keeps the whole control inside the captured bounds.
.spacingS / .spacingXS instead of raw 8 / 4.
layoutIfNeeded lays out newly-revealed cells synchronously (a real hook), so the
per-page settle only needs to cover their draw. The CADisplayLink frame-wait
experiment was dropped — it regressed the appearance flip and didn't beat sleeps.
…eset)

Dark colors work (tokens/toggle); the imageDark two-pass often captures light
because SwiftUI's WindowGroup resets overrideUserInterfaceStyle. Durable fix is
.preferredColorScheme, not window flipping — noted as not-yet-done.
… flip

Drops the two-pass overrideUserInterfaceStyle flip (SwiftUI's WindowGroup reset
it, so dark crops came back light) and the imageDark field. Native bits are now
photographed in whatever appearance the sim is set to (simctl ui … appearance
dark) — a real dark render — which also removes the flip settle.
3lvis added 29 commits July 9, 2026 18:48
…s booted

resolve_udid now finds (or creates) a persistent 'Pinwheel Sweep' sim and resolves it by UDID, ignoring
every other booted device. Previously it grabbed the first booted sim, so a stray device (or a stale
incremental build in its derived data) meant the sweep built and captured against the wrong target and
the serve silently kept an old capture — the phantom bug behind the Color rows never updating despite a
correct, unit-proven fix. Created on the newest available iOS runtime + newest iPhone, reused across runs.
Modeled on tienda-ios's screenshot-harness. Documents the stale-build reset (rm -rf the derived data).
A UIStackView captured with primarySizing/counterSizing AUTO let the plugin hug the frame to its widest
child; a center-aligned column (the UIKit button demo) then kept its leading origin and drifted the whole
stack off-center. stackFrame now fixes the cross axis (counterAxisFixed) so .center/.trailing alignment
and .fill children position within the real captured width. Red-first: testCenteredStackKeepsItsWidth...
failed at counterSizing AUTO before the fix.
… as auto-layout

A UITableView captures as absolute rows (PinUIKitListCapture), so the Color demo had zero auto-layout
while the SwiftUI VStack version was auto-layout. Rebuilt it as a UIStackView of colored rows (black +
white title labels), matching SwiftUI; the table demos still cover the UITableView capture path.
stackFrame now also captures a UIStackView's backgroundColor as fill and its layoutMargins as padding,
so a colored row lands like the SwiftUI Row. Red-first: testColoredStackCapturesItsBackgroundFillAndMargins.
…d captures tokenized

The UITextView set no font, so it rendered in UIKit's untyped system default while the capture assumed
the theme font (SF Pro Rounded 17) — a size/family mismatch and a 'Theme is law' violation. Giving it
.body makes the sim and capture agree (17/body, tokenized). Audited the other demos: this was the only
untyped text view; everything else uses UIKitPinLabel.
Both worlds now use identical copy (main + all three tweak results: 'You chose Option 1/2.', 'Option 3
is on/off.') and Option 3 carries the same 'Toggle-backed option' description. The UIKit demo wraps its
label in a centered UIStackView instead of a bare fill-pinned label, so it captures as an auto-layout
column matching the SwiftUI screen — in the main state and after any tweak (the tweaks only swap text).
Like Color, the demo used a UITableView only as a static list, so it captured with zero auto-layout
while the SwiftUI Typography is a VStack. Rebuilt it as a left-aligned UIStackView (spacingL horizontal,
spacingM top/bottom, 2×spacingM between rows) so it captures as an auto-layout column with the same
geometry as SwiftUI — column, pad [12,16,12,16], rowGap 24, 10 left-aligned rows at x=16, identical
y-positions and style tokens. The genuine table demos (uikit-tableview, dataSource-tableview) stay tables.
…t labels)

The Color demo's white contrast label captured colorToken=primaryBackground because literal white equals
that token's light value; on a dark-mode import the text then flipped to primaryBackground's dark value
(near-black), so both contrast labels read dark. A text color now only binds a text-role token — a
background match (a literal white/contrast color) stays untokenized and static. Red-first: white text
bound primaryBackground before the fix.
…/tweaks

Each appearance dir now holds the component roots plus a tweaks/ subfolder for the tweak-variant
screenshots (dark tweaks in dark/tweaks); no more _contact-sheet.png.
…stays centered)

The UIKit text nodes emitted no textAlign, so a full-width (fillWidth) centered label — the Tweakable
label — left-aligned in the plugin instead of centering. textNode/textViewNode now emit textAlign from
the view's textAlignment, matching the SwiftUI path. Red-first: a centered label captured textAlign nil.
…r verification

Plugin bug: the tag=text path set textAlignHorizontal but never applied fillWidth, so a full-width
centered label (UIKit Tweakable, whose .fill stack marks the label fillWidth) kept a tight box — centering
had no visible effect and it sat at the column's leading edge (left). It now sets layoutSizingHorizontal
FILL for a fillWidth text under an auto-layout parent. Red-first plugin test added.

Also adds 'npm run verify [id]': runs the live serve captures through the plugin's own build() via the
test mock and reports each text's alignment/sizing — so render bugs are caught in-process on real data
without importing into Figma.
…lors

A dark import ran solid() through a darkByToken static branch, so tokenized colors imported as literal
values (the Tweakable text became a static white FFFFFF, not a primaryText reference). Now solid() always
binds the token variable, and a dark import sets the frame's Dark variable mode so the binding resolves
dark. Red-first: the new dark-import test asserted the text fill binds a variable (and the frame sets Dark
mode) — both undefined before the fix. Verified on the real capture: uikit-tweakable dark binds
color/primaryText. verify.mjs gains --dark and reports token binding per text.
A hook reminds at the moment of editing Pinwheel/Sources/Pinwheel/Capture/** or figma-plugin/code.ts —
where bug fixes land — that a failing test must precede the fix. Fires only on those paths (demos/tests
don't trigger it), injects a reminder without blocking. Committed so it guards anyone working in the repo.
…ostic import

Restores binding token variables in dark (+ setting the frame's Dark mode), and records a DARK MODE PROBE
into the debug trace: whether setExplicitVariableModeForCollection ran/threw, the collection + darkModeId,
and the frame's explicit/resolved variable modes. One dark import surfaces the real reason the mode isn't
resolving dark (read /debug.json), so the fix is evidence-based, not a guess.
…Dark mode without a paid plan)

Evidence from the DARK MODE PROBE: darkModeId came back null on every dark import — collection.addMode('Dark')
throws because free/starter plans limit variable collections to one mode. So there's no Dark mode to bind
to; the tokenized-dark attempt fell back to the only (light) mode and rendered light. solid() goes back to
baking the token's dark value as a literal in dark mode (renders correctly, just not a token reference), with
a comment recording why so it isn't re-attempted. Removed the probe. Red-first: the static-dark test failed
(the fill was bound) before the fix. Verified in-process: uikit-tweakable dark = centered, static dark value.
…plan-gated

The probe confirmed addMode('Dark') throws 'Limited to 1 modes only' — free/starter Figma caps a variable
collection at one mode, so dark can't be a token binding. Removed the probe (its job is done), kept the
static-dark bake, and recorded the constraint in the decisions log so it isn't re-attempted. Light stays
tokenized, dark stays static (correct dark values). 28/28 plugin tests green.
… no variable mode needed

Multi-mode auto-switching needs a paid plan, but tokenization doesn't: syncTokens now creates two variables
per colour token — color/light/<name> and color/dark/<name>, each holding its value in the single mode — and
solid() binds the theme-appropriate one (dark imports reference color/dark/<token>). Dark colours are now
editable token references instead of raw hex. Dropped the by-value fallback (it re-tokenized literal contrast
colours and, post-split, bound the wrong theme) so a .custom black/white label stays a static paint. Removed
the dead darkByToken/colorVars/colorKey. Red-first: dark-binds-color/dark, light-binds-color/light, the token
split, and untokenized-literal-stays-static all failed first. Verified in-process on real captures.
Route the capture engine on PinwheelItem.isUIKitHosted so a .figma-tagged
UIKit demo takes the UIView-walk path instead of the DisplayList path. A plain
UICollectionView grid then captures with zero cooperation: token fills, radii,
centered labels, dark merge.
It hosts a raw UICollectionView, not a Pinwheel component, so the Pin name
overclaimed. UIKitPinCollectionViewDemo -> UIKitCollectionViewDemo.
It's UILabel, not UIKitLabel, so the UIKit twin of a Pin component is UIPin*,
not UIKitPin*. The Pin token keeps it clear of Apple's UI* namespace. UIKit
stays only as a descriptive qualifier (PinUIKitCapture, PinwheelUIKit*,
isUIKitHosted). The raw-control demo, having no Pin token, becomes
CollectionViewGridDemo so it can't read as an Apple type.
Same-class, same-structure cells now share a component key, so the plugin
imports the first as a Figma main component and the rest as instances — edit the
master, the copies follow. Cells with a live crop (a switch/icon) stay
independent since an instance's text/fill override can't reproduce them. The
collection demo now shows two card templates, each a component with copies.
Extend the Figma mock with createInstance/findAllWithCriteria and add a plugin
test: repeated same-key cells import as one main component + instances. The
multi-instance path existed but was never exercised (the mock had no
createInstance).
Bring the component/instance grouping to the SwiftUI DisplayList path: a
post-pass stamps sibling frames that share a structural signature (everything
but text content and per-instance fill) with one component key. No cell class
here, so the signature carries the discrimination; it includes size, so a
grouping is faithful (an instance overrides only text/fill). New Cards demo
shows two SwiftUI card templates, each a component with four instances.
The tweakable and Numbers demos changed their text this branch but the UI
tests still asserted the old strings; update them to the current copy (and to
the stable 'Spacing' title). Pass the capture sink as an explicit closure so it
satisfies the @mainactor @sendable environment type — clears the first-party
warning that tripped the warnings gate.
testSwiftUISecondActionTweakStillUpdatesContent asserted the old 'Chosen Option
1/2' strings; update to the current 'You chose Option 1/2.' copy.
GitHub's macOS runners flake on the hostless capture tests (a UIWindow activated
in a hostless XCTest process crashes only there, never on any local simulator)
and Actions minutes are scarce, so ci.yml's push/pull_request triggers are
commented out (manual workflow_dispatch only). The merge gate is now local: run
unit + UI via xcodebuild and merge only when the commit states they ran green.

Tests: unit 76/76 + UI 9/9 green (local xcodebuild)
@3lvis
3lvis merged commit 1830db2 into main Jul 10, 2026
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