Figma capture: turn a running Pinwheel screen into an editable Figma design - #22
Merged
Conversation
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.
…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.
…tatic colors" This reverts commit 1802128.
…static colors" This reverts commit 2020890.
…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)
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.
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
@Pinnablecomponents (PinButton/PinLabel) rebuild as real Figma masters + instances, with fills bound to token variables and fonts to text styles.ImageRenderercan't draw UIKit controls).PinListrows (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).What lands where
Sources/Pinwheel): a small capture seam —PinComponentStyle,pinCaptured/pinCapturedContainer/pinCapturedRasterized,PinCaptureLayout, andPinList.Rowself-capture. Inert when nothing is capturing, so it doesn't affect ordinary rendering.Demo/FigmaCapture): the capture host, rasterization, the screens, andScripts/sweep.sh.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.