Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
993951e
Prove lazy SwiftUI containers capture on-screen; scope Blocker 1
3lvis Jul 10, 2026
23b802b
Enforce the green-commit merge gate with a PreToolUse hook
3lvis Jul 10, 2026
fa65c99
Add a feature-rich List demo; record that editable List capture is bl…
3lvis Jul 10, 2026
2c424b9
Capture SwiftUI List rows via the _base ObjC bridge
3lvis Jul 10, 2026
78effd7
PinList captures fully via a pinCapturing switch; group images + widt…
3lvis Jul 10, 2026
b4b4907
Consumer-supplied token/font registry for capture (colors, spacing, r…
3lvis Jul 10, 2026
b008fec
Consumer text-style registry: match + emit named text styles from Pin…
3lvis Jul 10, 2026
327799c
Capture strikethrough on text (was/now pricing)
3lvis Jul 10, 2026
b65e579
Add CartDemo complex capture example + map fidelity gaps
3lvis Jul 10, 2026
3b0dbb1
Capture raster + async images (were dropped as unknown content)
3lvis Jul 10, 2026
89717e9
Plugin: tag-filter pills + tag-scoped Import all
3lvis Jul 10, 2026
1ee4468
Keep a pill's fill through the vertical-list capture path
3lvis Jul 10, 2026
e075e66
Doc: SALE-pill fill fixed; note the render_ir fidelity-diff flow
3lvis Jul 10, 2026
7ce1042
Debug trace carries the stable id; add id-keyed import diff (npm run …
3lvis Jul 10, 2026
836e65a
Capture a background behind a transparent (plain) List; debug flags N…
3lvis Jul 10, 2026
ebce306
Fill-width cards: drop the bogus trailing padding, fill the parent in…
3lvis Jul 10, 2026
23bd2c7
Port CartDemo to PinList so it captures faithfully
3lvis Jul 11, 2026
1182b33
Doc: complex rows use PinList; ForEach/AttributeGraph reflection is a…
3lvis Jul 11, 2026
c203d82
Fold the ForEach AttributeGraph deref into the reflector (capability …
3lvis Jul 11, 2026
55b3507
Route rich ForEach rows through reflection: emit shapes as leaves
3lvis Jul 11, 2026
2bfcc8f
Doc: bespoke 2-D ForEach captures via reflection (supersedes the PinL…
3lvis Jul 11, 2026
b99688c
Add OrderSummary demo (generalized from tienda-ios OrderItemView)
3lvis Jul 11, 2026
3088732
README: document Figma capture + the PinCaptureTokens registry
3lvis Jul 11, 2026
6b0b01f
Plugin: width-filling component instances import at FILL, not collapsed
3lvis Jul 11, 2026
6fd0a3f
Capture control borders via reflection; fix the Cart stepper order
3lvis Jul 11, 2026
0af789b
Add PinStepper (migrated from tienda Kolibri KStepper); captures 1:1
3lvis Jul 11, 2026
27965ba
Stepper border: capture the Capsule as a pill; flatten the wrapper frame
3lvis Jul 11, 2026
861da5c
Variant componentization: group rows that differ only by optional layers
3lvis Jul 11, 2026
0b4494a
PinStepper: fixed-width value so the stepper doesn't jitter per digit
3lvis Jul 11, 2026
0b2a92e
Tolerant zip: capture plain image+text-column rows (ImageGallery) str…
3lvis Jul 11, 2026
90facec
Componentize gallery rows: key images by size, override per instance
3lvis Jul 11, 2026
e938d93
Propagate fill-width up from nested spacers (Order Summary rows)
3lvis Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .claude/hooks/green-commit-gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# PreToolUse gate (Bash): block landing code on `main` unless the commit(s) being merged CLAIM tests ran
# green (a `Tests: ... green` trailer). GitHub Actions CI is paused (runner-only flake + scarce minutes),
# so per AGENTS.md -> "Local green-commit gate" the commit's claim is the merge signal. This hook does not
# run the tests (too slow for a hook); it enforces the claim is present so the convention can't be skipped.
# Fail-open on any error; deny (exit 2) only when the claim is confirmed absent.
import json, re, subprocess, sys


def run(args, timeout=15):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout).stdout
except Exception:
return ""


def has_green_claim(message):
return bool(re.search(r"(?is)tests?:.{0,100}\bgreen\b", message)
or re.search(r"(?is)\bgreen\b.{0,40}xcodebuild", message))


def main():
try:
command = (json.load(sys.stdin).get("tool_input") or {}).get("command", "") or ""
except Exception:
sys.exit(0)

# Only treat a merge invocation that STARTS a command segment as a real merge — so a mention inside a
# quoted argument (an echo, a grep, a test payload) doesn't false-trigger the gate.
segments = [segment.strip() for segment in re.split(r"[;\n]|&&|\|\|?", command)]
is_pr_merge = any(re.match(r"gh\s+pr\s+merge\b", segment) for segment in segments)
is_git_merge = any(re.match(r"git\s+merge\b", segment) for segment in segments) \
and re.search(r"--(abort|continue|quit)", command) is None
if not (is_pr_merge or is_git_merge):
sys.exit(0)

# `git merge` only lands on main when main is checked out; merging main INTO a feature branch is fine.
if is_git_merge and not is_pr_merge:
if run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).strip() != "main":
sys.exit(0)

# Check the tip commit being merged. `git merge <ref>` on main lands <ref>'s tip; otherwise (the
# common `gh pr merge` flow, run from the branch just committed + pushed) the local HEAD is that tip.
message = ""
if is_git_merge and not is_pr_merge:
ref = re.search(r"git\s+merge\s+(?:--\S+\s+)*(\S+)", command)
if ref:
message = run(["git", "log", "-1", "--format=%B", ref.group(1)])
if not message.strip():
message = run(["git", "log", "-1", "--format=%B"]) # HEAD
if not message.strip():
sys.exit(0) # can't determine the tip -> fail open

if has_green_claim(message):
sys.exit(0)

sys.stderr.write(
"GREEN-COMMIT GATE (blocked): the commit landing on main does not claim tests ran green. "
"GitHub Actions CI is paused, so a merge is gated on the commit stating local results "
"(AGENTS.md -> 'Local green-commit gate'). Run both tiers with xcodebuild -- unit: "
"-scheme PinwheelTests; UI: -scheme Demo -only-testing:DemoUITests -retry-tests-on-failure "
"-test-iterations 3 -- then add a 'Tests: unit NN/NN + UI green (local xcodebuild)' trailer to "
"the tip commit (amend it or add a commit) and re-run the merge.\n"
)
sys.exit(2)


main()
6 changes: 6 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
"hooks": [
{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/red-first-guard.sh\"" }
]
},
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/green-commit-gate.py\"" }
]
}
]
}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Pinwheel-specific guidance: how we work here, testing, and the decisions log. Th
- **Local green-commit gate — GitHub Actions CI is paused.** GitHub's macOS runners flake on the hostless capture tests (a `UIWindow` activated inside a hostless `XCTest` process crashes *only* there — never on any local simulator; the exact CI command passes 24/24 in ~3.6s locally), and Actions minutes are scarce, so `.github/workflows/ci.yml`'s `push`/`pull_request` triggers are commented out (manual `workflow_dispatch` only). The merge gate is now **local**: before merging, run both tiers with `xcodebuild` —
- unit: `xcodebuild test -project Demo.xcodeproj -scheme PinwheelTests -destination "platform=iOS Simulator,id=<udid>" CODE_SIGNING_ALLOWED=NO`
- UI: `xcodebuild test -project Demo.xcodeproj -scheme Demo -only-testing:DemoUITests -parallel-testing-enabled NO -retry-tests-on-failure -test-iterations 3 CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''`
— and only merge a commit whose message **states they ran green** (a `Tests: unit NN/NN + UI green (local xcodebuild)` trailer). That claim is the merge signal. Re-enable the Actions triggers once `PinwheelTests` gets a test host (which should fix the runner-only flake) or CI moves off Actions minutes.
— and only merge a commit whose message **states they ran green** (a `Tests: unit NN/NN + UI green (local xcodebuild)` trailer). That claim is the merge signal. **Enforced** by a PreToolUse hook (`.claude/hooks/green-commit-gate.py`, wired in `.claude/settings.json`) that blocks a `gh pr merge` / `git merge`-into-main whose tip commit lacks a `Tests: … green` trailer — so the gate can't be silently skipped. Re-enable the Actions triggers once `PinwheelTests` gets a test host (which should fix the runner-only flake) or CI moves off Actions minutes.

## Pinwheel — testing

Expand All @@ -42,6 +42,7 @@ Durable design decisions and why they were made.
- Add a SwiftUI `Pin*` (with a thin `UIPin*` shell) **only when** SwiftUI lacks a first-class primitive, so styling would be hand-rolled anyway (`PinButton` — pill, variants, loading, symbol, haptics), **or** there's real imperative / UIKit-hosting value to bridge (`PinStateView` as a state machine a UIKit table can drive). If SwiftUI's primitive + `PinwheelTheme` already covers it and nothing needs to host it in UIKit, don't wrap it.
- **Exception — theme footguns get a wrapper anyway.** `Label → PinLabel` because raw `Text(...).font(.body)` silently resolves to Apple's system style (see Theme below). The test is "does the raw primitive bypass the theme?", not just "does a primitive exist?".
- **Switch → `Toggle`** (no standalone `PinSwitch`; the only switch lives inside the `UIPinTableView` family). **Tokens (Font/Color/Spacing)** are *tokens*, never components, in either world.
- **`Stepper → PinStepper`** (a `−`/value/`+` pill). SwiftUI's `Stepper` renders a system `±` control that bypasses the theme and can't be the pill shape a design system wants — a theme footgun, same test as `PinLabel`. `PinStepper(value:)` + `.onDecrement/.onIncrement` modifiers; bordered capsule, SF-Symbol `±` (mirrors `PinButton`'s `systemImage:`), themed value. Migrated from tienda-ios's Kolibri `KStepper`. No `UIPinStepper` — no UIKit-hosting need yet.

### Bridging

Expand Down Expand Up @@ -75,7 +76,8 @@ These stay UIKit because no SwiftUI primitive matches their ergonomics/perf:
- **The capture toolchain is split: Swift engine in `Demo/FigmaCapture/`, Figma/JS half in `figma-plugin/`.** `figma-plugin/` (repo root, its own npm package) holds the "Pinwheel Capture Import" plugin (`code.ts` → `code.js`, `manifest.json`, `ui.html`) and `serve.mjs` — the local serve on `:8787` the sweep pushes to and the plugin reads from. It lives at the root, **not** under `Demo/` — that's a file-system-synchronized group and would bundle the JS into the app. Edit `code.ts` and `npm run build`; never hand-edit `code.js`. (The token variable collection is "Pinwheel Tokens".)
- **A Figma-captured surface must render into SwiftUI's own tree — never a UIKit-backed `List`.** Capture reads SwiftUI's DisplayList off an *off-screen* host; a `List` (UIKit-backed — `UICollectionView` on iOS 16+, `UITableView` before) builds its rows lazily in the UIKit layer, which an off-screen host with no viewport never populates. So a `List` screen captures as an empty background shape (the rows are simply not in the DisplayList). Build capturable demos/components as `ScrollView { VStack { ForEach } }` — eager, fully in SwiftUI's tree, so every row renders and captures as editable text/color nodes (Numbers, Typography, Color). `LazyVStack` is pure SwiftUI but still lazy (viewport-gated), so it's not a safe capture bet either.
- **Components capture with zero cooperation — every `Pin*` is byte-for-byte identical to `main`, no capture code, no markers.** The engine derives everything from what the component renders: structure from the DisplayList geometry, names from reflection, token bindings by value-matching the rendered `UIColor`/`CGFloat` against the `PinColorToken`/`PinFloatTokens` registries, and live UIKit controls (including a loading button's `UIActivityIndicatorView`) by cropping the on-screen render. A consumer drops their existing components in and they capture as-is — the contract that lets this scale. (The old marker apparatus — `pinCaptured*` modifiers, `PinCaptureKey`, `PinComponentStyle`, the `pinCapturing` fork — was proven dead and deleted; only `PinCaptureLayout` survives as the engine's layout IR.)
- **A `List` captures empty, and that's inherent — don't chase it.** `PinList` keeps `List` (separators, swipe, recycling) at 0-diff, so its rows never enter the capturable tree: `List` is UIKit-backed *and* lazy/recycled, so even the live layer only holds the visible window, never the full list. Neither editable nodes nor a faithful image is achievable. So capturable surfaces use the pure-SwiftUI eager stack (`ScrollView { VStack { ForEach } }`); `List` is for genuine data lists where capture isn't the goal, and its demo capturing as a background frame is a known, accepted limitation.
- **A raw `List` captures only partially; `PinList` captures fully via a capture switch.** A SwiftUI `List` is a recycled `UICollectionView` whose rows are opaque `CellHostingView` boundaries. `PinSwiftUIListCapture` force-realizes the collection and reads each cell's DisplayList through `_base` (fetched via the ObjC runtime, since Mirror hides it on `CellHostingView`) — text-dominant rows capture fully, but a rich raw-`List` row's fragments scatter across nested hosting views and only some are readable (partial). The consumer-clean path is **`PinList`**: it renders a real `List` in production but, under the `pinCapturing` environment (set by `PinDisplayList.read` and the sweep host), renders the same `Row`s in an eager stack the DisplayList reads completely — 1:1 cells, full editable capture + component/instance grouping (`PinListDemo`: 6 rows → 1 component + 5 instances, chevron included). Lazy stacks/grids (`LazyVStack`/`LazyVGrid`) already capture fully on the on-screen host with no switch needed.
- **Repeated-cell componentization keys images by bytes and buckets size to ~16pt.** An image node's signature is its byte content, so identical icons/chevrons group (an instance shares the master's identical image) while per-row photos stay distinct (an instance can't override an image); size buckets to ~16pt so content-driven width jitter doesn't split one template while a real size difference still does.
- **The capture engine is chosen by the item's hosted *world* (`PinwheelItem.isUIKitHosted`), never its display tag.** A `view:`/`viewController:` item walks the real `UIView` tree (`PinUIKitCapture`); a `content:` item reads its SwiftUI DisplayList. Routing on the `.uiKit` display chip instead misfires whenever the two diverge — a `.figma`-tagged UIKit demo (the `UICollectionView` grid in Screens) captured as one flat image because `.figma` isn't `.uiKit`, so it took the DisplayList path over a UIKit-hosted view. `isUIKitHosted` is set at construction (UIKit inits → `true`, SwiftUI → `false`), so the display tag stays a pure presentation axis. (A plain `UICollectionView` then captures with zero cooperation — force-realized cells → rounded token fills + centered editable labels — same as the UIKit table.)
- **The sweep captures from the live *on-screen* host; auto-push captures off-screen.** A UIKit-backed control (`Toggle`/`Slider`/`Picker(.segmented)`/`Stepper`/`DatePicker`, and `ProgressView`) only populates the DisplayList once it has actually rendered on a window — an off-screen `UIHostingController` renders it incompletely and its leaf drops (reflection then falls to the containment path and loses it). So `FigmaCaptureSweepView` hosts the component on-screen (`LiveCaptureHost`) and reads leaves off that real render (`PinDisplayList.leaves(fromHost:)`); `document(_:liveHost:)` is that entry. Auto-push has no on-screen surface, so it keeps the off-screen `document(_:)` path (its controls are best-effort). Build capturable component demos that render eagerly (`ScrollView { VStack }`, not `List`/`LazyVStack`) so every node is present.
- **Dark mode = two sweep rounds in the SIM's appearance, merged — a UIKit control can't be flipped in-app.** A UIKit control only paints in the *simulator's* appearance; neither `preferredColorScheme` nor a window/controller `overrideUserInterfaceStyle` repaints a SwiftUI-hosted control for the `drawHierarchy` crop (proven: window forced dark, control still cropped light). So the sweep runs the whole catalog twice — `simctl ui appearance light`, then `dark` — capturing a single-appearance document each round, and a Python step in `sweep.sh` grafts the dark round's `image`/`fill` onto the light one as `imageDark`/`fillDark`. Everything then adapts: controls, symbols, and untokenized fills via the merge; tokenized colours via the token's own light/dark value. (Round 1 must be *light* so text RGBA-matches the correct token.)
Expand Down
60 changes: 60 additions & 0 deletions Demo/Demos/SwiftUI/CartDemo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import SwiftUI
import Pinwheel

struct CartDemo: SwiftUI.View {
private struct Item: Identifiable {
let id = UUID()
let title: String
let now: String
let was: String?
let quantity: Int
var onSale: Bool { was != nil }
}

private let items = [
Item(title: "Wireless Earbuds Pro", now: "$129", was: "$159", quantity: 1),
Item(title: "LED Desk Lamp", now: "$34", was: "$49", quantity: 1),
Item(title: "Cotton Crew T-Shirt", now: "$24", was: nil, quantity: 2),
Item(title: "Insulated Water Bottle", now: "$21", was: "$28", quantity: 1)
]

var body: some SwiftUI.View {
ScrollView {
VStack(spacing: .spacingM) {
ForEach(items) { item in
HStack(spacing: .spacingM) {
RoundedRectangle(cornerRadius: .radiusM)
.fill(.secondaryBackground)
.frame(width: 56, height: 56)
.overlay(Image(systemName: "photo").foregroundStyle(.tertiaryText))
VStack(alignment: .leading, spacing: .spacingXS) {
HStack(spacing: .spacingS) {
PinLabel(item.title).font(.body)
if item.onSale {
PinLabel("SALE").font(.footnote).color(.custom(.white))
.padding(.horizontal, .spacingS)
.padding(.vertical, 2)
.background(.criticalBackground, in: Capsule())
}
}
HStack(spacing: .spacingS) {
PinLabel(item.now).font(.bodySemibold)
if let was = item.was {
PinLabel(was).font(.caption).color(.secondary).strikethrough()
}
}
}
Spacer()
PinStepper(value: item.quantity)
}
.padding(.spacingM)
.background(.secondaryBackground)
.cornerRadius(.radiusM)
}
}
.padding(.spacingL)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(.primaryBackground)
}
}
10 changes: 10 additions & 0 deletions Demo/Demos/SwiftUI/DemoPinwheelSections.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum DemoPinwheelSections {
PinwheelItem(Catalog.label) { PinLabelDemo() }.tags(.swiftUI)
PinwheelItem(Catalog.tweakable) { PinTweakableDemo() }.tags(.swiftUI)
PinwheelItem(Catalog.button) { PinButtonDemo() }.tags(.swiftUI)
PinwheelItem(Catalog.stepper) { PinStepperDemo() }.tags(.swiftUI)
PinwheelItem(Catalog.stateView) { PinStateViewDemo() }.tags(.swiftUI)
PinwheelItem(Catalog.tableView) { PinTableViewDemo() }.tags(.swiftUI)
PinwheelItem(Catalog.label, view: UIPinLabelDemo.self).tags(.uiKit)
Expand All @@ -41,6 +42,15 @@ enum DemoPinwheelSections {
PinwheelItem(Catalog.appleControls) { AppleControlsDemo() }.presentation(.fullscreen).tags(.figma)
PinwheelItem(Catalog.collectionView, view: CollectionViewGridDemo.self).tags(.figma)
PinwheelItem(Catalog.cards) { CardsDemo() }.tags(.figma)
PinwheelItem(Catalog.lazyCards) { LazyCardsDemo() }.tags(.figma)
PinwheelItem(Catalog.lazyGrid) { LazyGridDemo() }.tags(.figma)
PinwheelItem(Catalog.sectionedList) { SectionedListDemo() }.tags(.figma)
PinwheelItem(Catalog.productList) { ProductListDemo() }.tags(.figma)
PinwheelItem(Catalog.pricing) { PricingDemo() }.tags(.figma)
PinwheelItem(Catalog.cart) { CartDemo() }.tags(.figma)
PinwheelItem(Catalog.orderSummary) { OrderSummaryDemo() }.tags(.figma)
PinwheelItem(Catalog.imageGallery) { ImageGalleryDemo() }.tags(.figma)
PinwheelItem(Catalog.pinList) { PinListDemo() }.tags(.figma)
}
}
}
Expand Down
Loading