diff --git a/.claude/hooks/green-commit-gate.py b/.claude/hooks/green-commit-gate.py new file mode 100644 index 0000000..e341445 --- /dev/null +++ b/.claude/hooks/green-commit-gate.py @@ -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 ` on main lands '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() diff --git a/.claude/settings.json b/.claude/settings.json index 5697c2d..276bb15 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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\"" } + ] } ] } diff --git a/AGENTS.md b/AGENTS.md index dd342bc..bce25d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=" 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 @@ -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 @@ -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.) diff --git a/Demo/Demos/SwiftUI/CartDemo.swift b/Demo/Demos/SwiftUI/CartDemo.swift new file mode 100644 index 0000000..63f239c --- /dev/null +++ b/Demo/Demos/SwiftUI/CartDemo.swift @@ -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) + } +} diff --git a/Demo/Demos/SwiftUI/DemoPinwheelSections.swift b/Demo/Demos/SwiftUI/DemoPinwheelSections.swift index 7aa8f3d..00dd11a 100644 --- a/Demo/Demos/SwiftUI/DemoPinwheelSections.swift +++ b/Demo/Demos/SwiftUI/DemoPinwheelSections.swift @@ -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) @@ -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) } } } diff --git a/Demo/Demos/SwiftUI/ImageGalleryDemo.swift b/Demo/Demos/SwiftUI/ImageGalleryDemo.swift new file mode 100644 index 0000000..df719e6 --- /dev/null +++ b/Demo/Demos/SwiftUI/ImageGalleryDemo.swift @@ -0,0 +1,64 @@ +import SwiftUI +import Pinwheel + +struct ImageGalleryDemo: SwiftUI.View { + private struct Photo: Identifiable { + let id = UUID() + let title: String + let subtitle: String + let image: UIImage + let fileURL: URL + } + + private let photos: [Photo] + + init() { + let specs = [ + ("Sunset Ridge", "Landscape", UIColor.systemOrange, UIColor.systemPink), + ("Ocean Deep", "Seascape", UIColor.systemTeal, UIColor.systemBlue), + ("Forest Trail", "Woodland", UIColor.systemGreen, UIColor.systemMint) + ] + photos = specs.enumerated().map { index, spec in + let image = Self.swatch(spec.2, spec.3) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("gallery-\(index).png") + try? image.pngData()?.write(to: url) + return Photo(title: spec.0, subtitle: spec.1, image: image, fileURL: url) + } + } + + private static func swatch(_ top: UIColor, _ bottom: UIColor) -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 120, height: 120)).image { context in + top.setFill(); context.fill(CGRect(x: 0, y: 0, width: 120, height: 60)) + bottom.setFill(); context.fill(CGRect(x: 0, y: 60, width: 120, height: 60)) + } + } + + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(photos) { photo in + HStack(spacing: .spacingM) { + AsyncImage(url: photo.fileURL) { image in + image.resizable() + } placeholder: { + RoundedRectangle(cornerRadius: .radiusM).fill(.secondaryBackground) + } + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: .radiusM)) + VStack(alignment: .leading, spacing: .spacingXS) { + PinLabel(photo.title).font(.bodySemibold) + PinLabel(photo.subtitle).font(.caption).color(.secondary) + } + Spacer() + } + .padding(.spacingM) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } +} diff --git a/Demo/Demos/SwiftUI/LazyCardsDemo.swift b/Demo/Demos/SwiftUI/LazyCardsDemo.swift new file mode 100644 index 0000000..244915a --- /dev/null +++ b/Demo/Demos/SwiftUI/LazyCardsDemo.swift @@ -0,0 +1,26 @@ +import SwiftUI +import Pinwheel + +struct LazyCardsDemo: SwiftUI.View { + private let items = (1...20).map { "Item \($0)" } + + var body: some SwiftUI.View { + ScrollView { + LazyVStack(spacing: .spacingM) { + ForEach(items, id: \.self) { title in + VStack(alignment: .leading, spacing: .spacingXS) { + PinLabel(title).font(.caption).color(.secondary) + PinLabel("Detail row").font(.body) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.spacingL) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } +} diff --git a/Demo/Demos/SwiftUI/LazyGridDemo.swift b/Demo/Demos/SwiftUI/LazyGridDemo.swift new file mode 100644 index 0000000..a5cf270 --- /dev/null +++ b/Demo/Demos/SwiftUI/LazyGridDemo.swift @@ -0,0 +1,25 @@ +import SwiftUI +import Pinwheel + +struct LazyGridDemo: SwiftUI.View { + private let items = (1...12).map { "Tile \($0)" } + private let columns = [GridItem(.flexible(), spacing: .spacingM), GridItem(.flexible(), spacing: .spacingM)] + + var body: some SwiftUI.View { + ScrollView { + LazyVGrid(columns: columns, spacing: .spacingM) { + ForEach(items, id: \.self) { title in + PinLabel(title) + .font(.body) + .frame(maxWidth: .infinity) + .padding(.spacingL) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } +} diff --git a/Demo/Demos/SwiftUI/OrderSummaryDemo.swift b/Demo/Demos/SwiftUI/OrderSummaryDemo.swift new file mode 100644 index 0000000..9008be3 --- /dev/null +++ b/Demo/Demos/SwiftUI/OrderSummaryDemo.swift @@ -0,0 +1,77 @@ +import SwiftUI +import Pinwheel + +struct OrderSummaryDemo: SwiftUI.View { + private struct Item: Identifiable { + let id = UUID() + let title: String + let detail: String? + let label: String? + let bonus: String? + let discount: String? + let quantity: String + let tax: String + let price: String + } + + private let items = [ + Item(title: "Organic Bananas", detail: "≈ 1.2 kg", label: nil, bonus: "Bonus", discount: nil, + quantity: "2 × kr 24,90", tax: "15% VAT", price: "kr 49,80"), + Item(title: "Whole Milk 1L", detail: "6-pack", label: "Replaced", bonus: nil, discount: nil, + quantity: "1 × kr 119,00", tax: "15% VAT", price: "kr 119,00"), + Item(title: "Sourdough Loaf", detail: nil, label: nil, bonus: nil, discount: "−20%", + quantity: "1 × kr 39,00", tax: "15% VAT", price: "kr 31,20"), + Item(title: "Free-Range Eggs", detail: "12-pack", label: nil, bonus: nil, discount: nil, + quantity: "1 × kr 54,90", tax: "15% VAT", price: "kr 54,90"), + Item(title: "Cold-Pressed Olive Oil", detail: "500 ml", label: "Not delivered", bonus: nil, discount: nil, + quantity: "1 × kr 149,00", tax: "15% VAT", price: "kr 149,00"), + Item(title: "Dark Roast Coffee", detail: "1 kg", label: nil, bonus: "Bonus", discount: "−15%", + quantity: "2 × kr 189,00", tax: "15% VAT", price: "kr 321,30") + ] + + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(items) { item in + HStack(spacing: .spacingM) { + RoundedRectangle(cornerRadius: .radiusM) + .fill(.primaryBackground) + .frame(width: 64, height: 64) + .overlay(Image(systemName: "bag").foregroundStyle(.tertiaryText)) + VStack(alignment: .leading, spacing: .spacingS) { + PinLabel(item.title).font(.bodySemibold) + if let detail = item.detail { + PinLabel(detail).font(.caption).color(.secondary) + } + HStack(spacing: .spacingS) { + if let label = item.label { pill(label, fill: .primaryBackground, text: .secondary) } + if let bonus = item.bonus { pill(bonus, fill: .actionBackground, text: .custom(.white)) } + if let discount = item.discount { pill(discount, fill: .criticalBackground, text: .custom(.white)) } + Spacer() + PinLabel(item.tax).font(.caption).color(.tertiary) + } + HStack(alignment: .bottom, spacing: .spacingM) { + PinLabel(item.quantity).font(.caption).color(.secondary) + Spacer() + PinLabel(item.price).font(.bodySemibold) + } + } + } + .padding(.spacingM) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } + + private func pill(_ text: String, fill: Color, text textColor: PinLabel.TextColor) -> some SwiftUI.View { + PinLabel(text).font(.footnote).color(textColor) + .padding(.horizontal, .spacingS) + .padding(.vertical, 2) + .background(fill, in: Capsule()) + } +} diff --git a/Demo/Demos/SwiftUI/PinListDemo.swift b/Demo/Demos/SwiftUI/PinListDemo.swift new file mode 100644 index 0000000..e5738ff --- /dev/null +++ b/Demo/Demos/SwiftUI/PinListDemo.swift @@ -0,0 +1,19 @@ +import SwiftUI +import Pinwheel + +struct PinListDemo: SwiftUI.View { + private let products: [(String, String)] = [ + ("Wireless Earbuds Pro", "$129"), + ("Cotton Crew T-Shirt", "$24"), + ("Ceramic Coffee Mug", "$12"), + ("LED Desk Lamp", "$34"), + ("Hardcover Notebook Set", "$18"), + ("Insulated Water Bottle", "$21") + ] + + var body: some SwiftUI.View { + PinList(rows: products.map { title, price in + .text(title, detail: price, chevron: true) + }) + } +} diff --git a/Demo/Demos/SwiftUI/PinStepperDemo.swift b/Demo/Demos/SwiftUI/PinStepperDemo.swift new file mode 100644 index 0000000..5ceb8fe --- /dev/null +++ b/Demo/Demos/SwiftUI/PinStepperDemo.swift @@ -0,0 +1,31 @@ +import SwiftUI +import Pinwheel + +struct PinStepperDemo: SwiftUI.View { + @SwiftUI.State private var quantity = 1 + @SwiftUI.State private var crate = 12 + + var body: some SwiftUI.View { + ScrollView { + VStack(alignment: .leading, spacing: .spacingXL) { + VStack(alignment: .leading, spacing: .spacingS) { + PinLabel("Quantity").font(.subtitleSemibold) + PinStepper(value: quantity) + .onDecrement { quantity = max(0, quantity - 1) } + .onIncrement { quantity += 1 } + } + + VStack(alignment: .leading, spacing: .spacingS) { + PinLabel("Crate size").font(.subtitleSemibold) + PinStepper(value: crate) + .onDecrement { crate = max(0, crate - 1) } + .onIncrement { crate += 1 } + } + } + .padding(.horizontal, .spacingL) + .padding(.vertical, .spacingXXL) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(.primaryBackground) + } +} diff --git a/Demo/Demos/SwiftUI/PricingDemo.swift b/Demo/Demos/SwiftUI/PricingDemo.swift new file mode 100644 index 0000000..a21e5a0 --- /dev/null +++ b/Demo/Demos/SwiftUI/PricingDemo.swift @@ -0,0 +1,41 @@ +import SwiftUI +import Pinwheel + +struct PricingDemo: SwiftUI.View { + private struct Deal: Identifiable { + let id = UUID() + let title: String + let now: String + let was: String + } + + private let deals = [ + Deal(title: "Wireless Earbuds Pro", now: "$129", was: "$159"), + Deal(title: "LED Desk Lamp", now: "$34", was: "$49"), + Deal(title: "Hardcover Notebook Set", now: "$18", was: "$25"), + Deal(title: "Insulated Water Bottle", now: "$21", was: "$28") + ] + + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(deals) { deal in + VStack(alignment: .leading, spacing: .spacingXS) { + PinLabel(deal.title).font(.body) + HStack(spacing: .spacingS) { + PinLabel(deal.now).font(.bodySemibold) + PinLabel(deal.was).font(.caption).color(.secondary).strikethrough() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.spacingL) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } +} diff --git a/Demo/Demos/SwiftUI/ProductListDemo.swift b/Demo/Demos/SwiftUI/ProductListDemo.swift new file mode 100644 index 0000000..f16ac78 --- /dev/null +++ b/Demo/Demos/SwiftUI/ProductListDemo.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Pinwheel + +struct ProductListDemo: SwiftUI.View { + private struct Product: Identifiable { + let id = UUID() + let title: String + let now: String + let was: String? + let quantity: Int + var onSale: Bool { was != nil } + } + + @SwiftUI.State private var recommended: [Product] = [ + Product(title: "Wireless Earbuds Pro", now: "$129", was: "$159", quantity: 1), + Product(title: "Cotton Crew T-Shirt", now: "$24", was: nil, quantity: 2), + Product(title: "Ceramic Coffee Mug", now: "$12", was: nil, quantity: 1) + ] + @SwiftUI.State private var deals: [Product] = [ + Product(title: "LED Desk Lamp", now: "$34", was: "$49", quantity: 1), + Product(title: "Hardcover Notebook Set", now: "$18", was: "$25", quantity: 3), + Product(title: "Insulated Water Bottle", now: "$21", was: "$28", quantity: 1) + ] + + var body: some SwiftUI.View { + List { + Section("Recommended") { + ForEach(recommended) { row($0) } + } + Section("On sale") { + ForEach(deals) { row($0) } + .onDelete { deals.remove(atOffsets: $0) } + } + } + .listStyle(.plain) + } + + private func row(_ product: Product) -> some SwiftUI.View { + 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(product.title).font(.body) + if product.onSale { + PinLabel("SALE").font(.footnote).color(.custom(.white)) + .padding(.horizontal, .spacingS) + .padding(.vertical, 2) + .background(.criticalBackground, in: Capsule()) + } + } + HStack(spacing: .spacingS) { + PinLabel(product.now).font(.bodySemibold) + if let was = product.was { + PinLabel(was).font(.caption).color(.secondary).strikethrough() + } + } + } + Spacer() + HStack(spacing: .spacingM) { + Image(systemName: "minus") + PinLabel("\(product.quantity)").font(.body) + Image(systemName: "plus") + } + .foregroundStyle(.actionText) + .padding(.horizontal, .spacingM) + .padding(.vertical, .spacingS) + .overlay(Capsule().stroke(.tertiaryText, lineWidth: 1)) + } + .padding(.vertical, .spacingXS) + } +} diff --git a/Demo/Demos/SwiftUI/SectionedListDemo.swift b/Demo/Demos/SwiftUI/SectionedListDemo.swift new file mode 100644 index 0000000..fc16eb7 --- /dev/null +++ b/Demo/Demos/SwiftUI/SectionedListDemo.swift @@ -0,0 +1,35 @@ +import SwiftUI +import Pinwheel + +struct SectionedListDemo: SwiftUI.View { + @SwiftUI.State private var tasks = ["Draft the proposal", "Review the designs", "Ship the release", "Plan the next sprint"] + + var body: some SwiftUI.View { + List { + Section("Overview") { + row("Status", "Active") + row("Owner", "You") + } + Section("Preferences") { + row("Notifications", "On") + row("Appearance", "System") + row("Privacy", "Standard") + } + Section("Tasks") { + ForEach(tasks, id: \.self) { task in + row(task, "To do") + } + .onDelete { tasks.remove(atOffsets: $0) } + } + } + .listStyle(.insetGrouped) + } + + private func row(_ title: String, _ detail: String) -> some SwiftUI.View { + HStack { + PinLabel(title).font(.body) + Spacer() + PinLabel(detail).font(.caption).color(.secondary) + } + } +} diff --git a/Demo/FigmaCapture/FigmaCaptureSweep.swift b/Demo/FigmaCapture/FigmaCaptureSweep.swift index bb209bf..560da13 100644 --- a/Demo/FigmaCapture/FigmaCaptureSweep.swift +++ b/Demo/FigmaCapture/FigmaCaptureSweep.swift @@ -93,7 +93,7 @@ private struct LiveCaptureHost: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { let container = UIViewController() container.view.backgroundColor = .clear - let host = UIHostingController(rootView: AnyView(entry.item.swiftUIView())) + let host = UIHostingController(rootView: AnyView(entry.item.swiftUIView().environment(\.pinCapturing, true))) container.addChild(host) host.view.translatesAutoresizingMaskIntoConstraints = false container.view.addSubview(host.view) @@ -127,9 +127,12 @@ private struct LiveCaptureHost: UIViewControllerRepresentable { // real pills/text never capture. (UIKit controls render only in the sim's own appearance, so the // sweep runs twice — sim light, then dark — and merges the two single-appearance documents.) let displayList = { PinDisplayListCapture.document(entry.item.swiftUIView(), name: entry.title, size: size, screenHeight: FigmaCatalog.oneScreen, liveHost: host.view) } + // A SwiftUI `List` hides its rows behind per-cell hosting views the DisplayList can't see; capture + // it via the backing-collection walk (nil for non-List SwiftUI screens, so they fall through). + let listCapture = { PinSwiftUIListCapture.document(name: entry.title, size: size, screenHeight: FigmaCatalog.oneScreen, liveHost: host.view) } guard let document = entry.item.isUIKitHosted ? (PinUIKitCapture.document(host: host.view, name: entry.title, size: size, screenHeight: FigmaCatalog.oneScreen) ?? displayList()) - : displayList() + : (listCapture() ?? displayList()) else { return } onCaptured?(document) let version = PinCaptureVersions.shared.record(id: entry.id, document: document) diff --git a/DemoCatalog/Sources/DemoCatalog/Catalog.swift b/DemoCatalog/Sources/DemoCatalog/Catalog.swift index a75643a..1d1bb4b 100644 --- a/DemoCatalog/Sources/DemoCatalog/Catalog.swift +++ b/DemoCatalog/Sources/DemoCatalog/Catalog.swift @@ -6,12 +6,22 @@ public enum Catalog: String, PinwheelComponent { case numbers = "Numbers" case label = "Label" case button = "Button" + case stepper = "Stepper" case tweakable = "Tweakable" case stateView = "StateView" case tableView = "TableView" case dataSourceTableView = "DataSource TableView" case collectionView = "CollectionView" case cards = "Cards" + case lazyCards = "Lazy Cards" + case lazyGrid = "Lazy Grid" + case sectionedList = "Sectioned List" + case productList = "Product List" + case pricing = "Pricing" + case cart = "Cart" + case orderSummary = "Order Summary" + case imageGallery = "Image Gallery" + case pinList = "Pin List" case fullscreenView = "FullscreenView" case viewController = "ViewController" case appleControls = "Apple Controls" diff --git a/Pinwheel/Sources/Pinwheel/API/PinCaptureLayout.swift b/Pinwheel/Sources/Pinwheel/API/PinCaptureLayout.swift index 8b832d9..92fd4b6 100644 --- a/Pinwheel/Sources/Pinwheel/API/PinCaptureLayout.swift +++ b/Pinwheel/Sources/Pinwheel/API/PinCaptureLayout.swift @@ -32,4 +32,7 @@ public struct PinCaptureLayout { public extension EnvironmentValues { @Entry var pinCaptureSink: (@MainActor @Sendable (String) -> Void)? = nil + // Set by the capture pipeline so capture-aware containers (PinList) render their capturable form — a + // pure-SwiftUI stack — instead of a UIKit-backed `List` the DisplayList can't see. + @Entry var pinCapturing: Bool = false } diff --git a/Pinwheel/Sources/Pinwheel/Capture/FigmaCapture.swift b/Pinwheel/Sources/Pinwheel/Capture/FigmaCapture.swift index 6579ba2..3105ba7 100644 --- a/Pinwheel/Sources/Pinwheel/Capture/FigmaCapture.swift +++ b/Pinwheel/Sources/Pinwheel/Capture/FigmaCapture.swift @@ -37,6 +37,9 @@ struct FigmaNode: Encodable { var fillDark: RGBA? var radius: Double? var radiusToken: String? + var stroke: RGBA? + var strokeToken: String? + var strokeWidth: Double? var component: String? var name: String? var font: FigmaFont? @@ -49,6 +52,7 @@ struct FigmaNode: Encodable { var grow: Bool? var ordered: Bool? var fillWidth: Bool? + var hidden: Bool? var children: [FigmaNode] } @@ -104,6 +108,7 @@ struct FigmaFont: Encodable { let colorToken: String? let style: String? let underline: Bool + var strikethrough: Bool = false } struct FigmaTextStyle: Encodable { @@ -138,25 +143,12 @@ enum PinFloatTokens { } static var radius: [(name: String, value: CGFloat)] { [("radius-m", .radiusM), ("radius-l", .radiusL)] } - static func spacingName(for value: Double) -> String? { name(for: value, in: spacing) } - static func radiusName(for value: Double) -> String? { name(for: value, in: radius) } - - // A measured gap reads a hair wider than the declared spacing (glyph/SF Symbol bearing insets the frame), so round down to the token at or just below it. - static func gapTokenName(for value: Double) -> String? { - guard value > 0.5 else { return nil } - guard let best = spacing.filter({ Double($0.value) <= value + 0.5 }).max(by: { $0.value < $1.value }), - value - Double(best.value) < 3 else { return nil } - return best.name - } - - private static func name(for value: Double, in table: [(name: String, value: CGFloat)]) -> String? { - guard value > 0.5 else { return nil } - return table.first { abs(Double($0.value) - value) < 0.5 }?.name - } - - static var tokens: [FigmaToken] { - (spacing + radius).map { FigmaToken(name: $0.name, type: "float", float: Double($0.value)) } - } + // Match/emit against the active registry (Pinwheel's by default). `spacing`/`radius` above stay the + // source the `.pinwheel` registry is built from. + @MainActor static func spacingName(for value: Double) -> String? { PinCaptureTokens.current.spacingName(for: value) } + @MainActor static func radiusName(for value: Double) -> String? { PinCaptureTokens.current.radiusName(for: value) } + @MainActor static func gapTokenName(for value: Double) -> String? { PinCaptureTokens.current.gapName(for: value) } + @MainActor static var tokens: [FigmaToken] { PinCaptureTokens.current.figmaFloatTokens } } extension RGBA { diff --git a/Pinwheel/Sources/Pinwheel/Capture/PinCaptureTokens.swift b/Pinwheel/Sources/Pinwheel/Capture/PinCaptureTokens.swift new file mode 100644 index 0000000..5298e7e --- /dev/null +++ b/Pinwheel/Sources/Pinwheel/Capture/PinCaptureTokens.swift @@ -0,0 +1,150 @@ +import UIKit + +// The design tokens the capture value-matches rendered values against and emits as Figma variables. +// Defaults to Pinwheel's own tokens; a consumer sets `PinCaptureTokens.current` to their palette so THEIR +// colors, spacings, radii, and font bind — the capture engine stays library-agnostic. (Text-style *names* +// still match Pinwheel's `PinTextStyle` — a follow-up.) +@MainActor +public struct PinCaptureTokens { + public struct ColorToken { + let name: String + let light: RGBA + let dark: RGBA + let textEligible: Bool + + /// A text color binds only to a text-eligible token — a background/surface token matched purely by + /// value (a literal white == a light background) would flip the text dark on a dark-mode import. + public init(name: String, light: UIColor, dark: UIColor, textEligible: Bool = true) { + self.name = name + self.light = RGBA(light) + self.dark = RGBA(dark) + self.textEligible = textEligible + } + + init(name: String, light: RGBA, dark: RGBA, textEligible: Bool) { + self.name = name + self.light = light + self.dark = dark + self.textEligible = textEligible + } + } + + public struct FloatToken { + let name: String + let value: Double + public init(name: String, value: Double) { + self.name = name + self.value = value + } + } + + public struct TextStyleToken { + let name: String + let family: String + let size: Double + let weight: Int + public init(name: String, family: String, size: Double, weight: Int) { + self.name = name + self.family = family + self.size = size + self.weight = weight + } + } + + public var colors: [ColorToken] + public var spacings: [FloatToken] + public var radii: [FloatToken] + public var textStyles: [TextStyleToken] + /// The design-face name for the system font (whose internal family name isn't Figma-loadable). Custom + /// (non-system) fonts capture their real family, so this only names the fallback. + public var systemFontFamily: String + + public init(colors: [ColorToken], spacings: [FloatToken], radii: [FloatToken], systemFontFamily: String, textStyles: [TextStyleToken] = []) { + self.colors = colors + self.spacings = spacings + self.radii = radii + self.systemFontFamily = systemFontFamily + self.textStyles = textStyles + } + + /// The active registry the capture matchers consult. A consumer assigns their own at launch. + public static var current: PinCaptureTokens = .pinwheel + + static var pinwheel: PinCaptureTokens { + PinCaptureTokens( + colors: PinColorToken.allCases.map { + ColorToken(name: $0.rawValue, light: RGBA($0.color, style: .light), dark: RGBA($0.color, style: .dark), + textEligible: !$0.rawValue.hasSuffix("Background")) + }, + spacings: PinFloatTokens.spacing.map { FloatToken(name: $0.name, value: Double($0.value)) }, + radii: PinFloatTokens.radius.map { FloatToken(name: $0.name, value: Double($0.value)) }, + systemFontFamily: "SF Pro Rounded", + textStyles: PinTextStyle.allCapturable.map { + let metrics = $0.captureMetrics + return TextStyleToken(name: $0.captureName, family: metrics.family, size: metrics.size, weight: metrics.weight) + } + ) + } + + // MARK: Matching (value → token name), preserving the engine's existing tolerances. + + func colorName(for color: UIColor, textRoleOnly: Bool = false) -> String? { + let target = RGBA(color) + return colors.first { token in + (!textRoleOnly || token.textEligible) && close(token.light, target) + }?.name + } + + func spacingName(for value: Double) -> String? { exactFloat(value, in: spacings) } + func radiusName(for value: Double) -> String? { exactFloat(value, in: radii) } + + // Match a rendered font to a text style by size + weight (the same size/weight a Figma text style + // carries), so captured text binds the consumer's named style. + func textStyleName(for font: UIFont) -> String? { + let weight = Self.cssWeight(font) + return textStyles.first { abs($0.size - Double(font.pointSize)) < 0.5 && $0.weight == weight }?.name + } + + static func cssWeight(_ font: UIFont) -> Int { + let traits = font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] + switch (traits?[.weight] as? CGFloat) ?? 0 { + case ..<(-0.5): return 200 + case ..<(-0.2): return 300 + case ..<0.15: return 400 + case ..<0.28: return 500 + case ..<0.37: return 600 + case ..<0.5: return 700 + default: return 800 + } + } + + // A measured gap reads a hair wider than the declared spacing (glyph/SF Symbol bearing insets the + // frame), so round down to the token at or just below it. + func gapName(for value: Double) -> String? { + guard value > 0.5, + let best = spacings.filter({ $0.value <= value + 0.5 }).max(by: { $0.value < $1.value }), + value - best.value < 3 else { return nil } + return best.name + } + + private func exactFloat(_ value: Double, in table: [FloatToken]) -> String? { + guard value > 0.5 else { return nil } + return table.first { abs($0.value - value) < 0.5 }?.name + } + + private func close(_ a: RGBA, _ b: RGBA) -> Bool { + abs(a.r - b.r) < 0.02 && abs(a.g - b.g) < 0.02 && abs(a.b - b.b) < 0.02 && abs(a.a - b.a) < 0.05 + } + + // MARK: Emission (registry → Figma variables). + + var figmaColorTokens: [FigmaToken] { + colors.map { FigmaToken(name: $0.name, type: "color", value: $0.light, dark: $0.dark) } + } + var figmaFloatTokens: [FigmaToken] { + (spacings + radii).map { FigmaToken(name: $0.name, type: "float", float: $0.value) } + } + var figmaTextStyles: [FigmaTextStyle] { + textStyles.map { FigmaTextStyle(name: $0.name, family: $0.family, size: $0.size, weight: $0.weight) } + } +} diff --git a/Pinwheel/Sources/Pinwheel/Capture/PinDisplayList.swift b/Pinwheel/Sources/Pinwheel/Capture/PinDisplayList.swift index a665c59..9e0ba22 100644 --- a/Pinwheel/Sources/Pinwheel/Capture/PinDisplayList.swift +++ b/Pinwheel/Sources/Pinwheel/Capture/PinDisplayList.swift @@ -1,12 +1,13 @@ import SwiftUI import UIKit +import ObjectiveC // Reads SwiftUI's private, undocumented DisplayList via reflection — the internals shift across // toolchains, so all of it is contained to this file. Never ships. struct DisplayLeaf { enum Kind { - case text(String, font: UIFont?, color: UIColor?, underline: Bool, alignment: NSTextAlignment) + case text(String, font: UIFont?, color: UIColor?, underline: Bool, strikethrough: Bool, alignment: NSTextAlignment) case roundedRect(radius: CGFloat, color: UIColor?) case rasterizable case color(UIColor) @@ -22,7 +23,7 @@ struct DisplayLeaf { enum PinDisplayList { // The window is returned so the caller keeps it alive. static func read(_ view: Content, size: CGSize, liveControlsOnScreen: Bool) -> (leaves: [DisplayLeaf], host: UIView, window: UIWindow)? { - let controller = UIHostingController(rootView: view) + let controller = UIHostingController(rootView: view.environment(\.pinCapturing, true)) let hostView: UIView = controller.view hostView.frame = CGRect(origin: .zero, size: size) let window = UIWindow(frame: hostView.frame) @@ -129,13 +130,22 @@ enum PinDisplayList { } private static func displayList(of hostingView: Any) -> Any? { - guard let base = child(hostingView, "_base"), + // The root `_UIHostingView` exposes `_base` via Mirror; a `List` cell's `CellHostingView` stores it + // as an ObjC ivar Mirror hides — read it through the runtime so per-cell capture reaches the same + // `viewGraph → renderer → lastList` path. + guard let base = child(hostingView, "_base") ?? ivarObject(hostingView, "_base"), let graphHost = child(base, "viewGraph"), let rendererBox = child(graphHost, "renderer"), let updater = unwrap(child(rendererBox, "renderer")) else { return nil } return child(updater, "lastList") } + private static func ivarObject(_ value: Any, _ name: String) -> Any? { + guard let object = value as AnyObject?, + let ivar = class_getInstanceVariable(type(of: object), name) else { return nil } + return object_getIvar(object, ivar) + } + private static func walk(_ list: Any, origin: CGPoint) -> [DisplayLeaf] { guard let items = child(list, "items") else { return [] } var leaves: [DisplayLeaf] = [] @@ -265,22 +275,30 @@ enum PinDisplayList { return image.pngData()?.base64EncodedString() } + static func textKind(from attributed: NSAttributedString?, fallback: String?) -> DisplayLeaf.Kind { + let string = attributed?.string ?? fallback ?? "" + let attributes = attributed.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } + let underline = (attributes?[.underlineStyle] as? Int).map { $0 != 0 } ?? false + let strikethrough = (attributes?[.strikethroughStyle] as? Int).map { $0 != 0 } ?? false + let alignment = (attributes?[.paragraphStyle] as? NSParagraphStyle)?.alignment ?? .natural + return .text(string, font: attributes?[.font] as? UIFont, color: attributes?[.foregroundColor] as? UIColor, + underline: underline, strikethrough: strikethrough, alignment: alignment) + } + private static func contentKind(_ value: Any) -> DisplayLeaf.Kind? { guard let (kind, payload) = enumCase(value) else { return .unknown(String(describing: type(of: value))) } switch kind { case "text": - let attributed = deepAttributed(payload) - let string = attributed?.string ?? deepString(payload) ?? "" - let attributes = attributed.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } - let underline = (attributes?[.underlineStyle] as? Int).map { $0 != 0 } ?? false - let alignment = (attributes?[.paragraphStyle] as? NSParagraphStyle)?.alignment ?? .natural - return .text(string, font: attributes?[.font] as? UIFont, color: attributes?[.foregroundColor] as? UIColor, underline: underline, alignment: alignment) + return textKind(from: deepAttributed(payload), fallback: deepString(payload)) case "shape": let mirror = Mirror(reflecting: payload).children.map(\.value) let color = mirror.count > 1 ? deepColor(mirror[1]) : nil if let radius = roundedRectRadius(mirror.first) { return .roundedRect(radius: radius, color: color) } return .rasterizable case "color": return .color(deepColor(payload) ?? .clear) + // A raster image (a photo, a loaded AsyncImage) resolves its pixels only on a rendered layer, so + // mark it rasterizable and let the host-layer crop fill it — same path as an SF Symbol shape. + case "image": return .rasterizable case "platformView": return .rasterizable default: return .unknown(kind) } diff --git a/Pinwheel/Sources/Pinwheel/Capture/PinDisplayListCapture.swift b/Pinwheel/Sources/Pinwheel/Capture/PinDisplayListCapture.swift index 6f600ff..8b2bb8f 100644 --- a/Pinwheel/Sources/Pinwheel/Capture/PinDisplayListCapture.swift +++ b/Pinwheel/Sources/Pinwheel/Capture/PinDisplayListCapture.swift @@ -50,19 +50,33 @@ public enum PinDisplayListCapture { let components = orderedComponents(root) let screenFill = fillColor(root.leaf.kind) - // Reflection supplies the semantic layout tree (native VStack/HStack leave no drawable to group by); zip it with the rendered leaves, falling back to containment if counts disagree. - if let structure = PinViewReflector.reflect(view), leafCount(structure) == components.count { - var pool = components - let backgrounds = collectBackgrounds(root) - let content = emitStructure(structure, host: host, backgrounds: backgrounds) { text in - // Match by text, not index — a 2D grid scrambles a positional zip; duplicates resolve first-unconsumed. - let matched = pool.firstIndex { componentText($0) == text } ?? (pool.isEmpty ? nil : 0) - return matched.map { pool.remove(at: $0) } - } - if let content { - var rootNode = screen(content, width: size.width, fill: screenFill, components: components, canvasHeight: size.height, oneScreen: screenHeight, safeAreaTop: host.safeAreaInsets.top) - rootNode.name = name - return FigmaDocument(width: size.width, height: rootNode.h, root: componentizeRepeatedChildren(rootNode), tokens: colorTokens + PinFloatTokens.tokens, textStyles: textStyles) + // Reflection supplies the semantic layout tree (native VStack/HStack leave no drawable to group by); + // zip it with the rendered leaves. Prefer the grouped components (their count usually matches the + // reflected leaves); fall back to the fully-flattened leaves when reflection is richer than + // containment's grouping — a plain image+text row collapses to one component, but reflection sees its + // parts, and the flattened leaves (image, title, subtitle) line back up. Fall through to containment + // only when neither count agrees. + if let structure = PinViewReflector.reflect(view) { + let reflectedLeaves = leafCount(structure) + let deepLeaves = components.flatMap(allLeaves) + // The flattened-leaf fallback is only for a genuinely 2-D row (a cross-axis nested stack — an + // HStack holding a VStack column) that containment collapses to one component and scrambles. A + // flat 1-D row (a colored bar of side-by-side labels) captures fine on containment and must keep + // its background fill, so it stays on the exact/containment path. + let pool: [Box]? = reflectedLeaves == components.count ? components + : (reflectedLeaves == deepLeaves.count && hasMixedRow(structure) ? deepLeaves : nil) + if var pool { + let backgrounds = collectBackgrounds(root) + let content = emitStructure(structure, host: host, backgrounds: backgrounds) { text in + // Match by text, not index — a 2D grid scrambles a positional zip; duplicates resolve first-unconsumed. + let matched = pool.firstIndex { componentText($0) == text } ?? (pool.isEmpty ? nil : 0) + return matched.map { pool.remove(at: $0) } + } + if let content { + var rootNode = screen(content, width: size.width, fill: screenFill, components: components, canvasHeight: size.height, oneScreen: screenHeight, safeAreaTop: host.safeAreaInsets.top) + rootNode.name = name + return FigmaDocument(width: size.width, height: rootNode.h, root: componentizeRepeatedChildren(rootNode), tokens: colorTokens + PinFloatTokens.tokens, textStyles: textStyles) + } } } @@ -82,28 +96,83 @@ public enum PinDisplayListCapture { // and the rest as instances. There's no cell class as on the UIKit side, so the signature carries the // discrimination — it includes size, so a grouping is faithful (an instance overrides only text/fill, // which is all that differs). Subtrees with an image leaf are excluded — a crop can't be reproduced. - private static func componentizeRepeatedChildren(_ node: FigmaNode) -> FigmaNode { + static func componentizeRepeatedChildren(_ node: FigmaNode) -> FigmaNode { var node = node node.children = node.children.map(componentizeRepeatedChildren) let signatures = node.children.map { child -> String? in - (child.tag == "frame" && child.component == nil && !hasImageLeaf(child)) ? signature(child) : nil + (child.tag == "frame" && child.component == nil) ? signature(child) : nil } var counts: [String: Int] = [:] for case let signature? in signatures { counts[signature, default: 0] += 1 } - node.children = zip(node.children, signatures).map { child, signature in - guard let signature, counts[signature, default: 0] >= 2 else { return child } - var componentized = child - componentized.component = signature - return componentized + // The master of each ≥2 group is its first member (the superset — real rows carry every optional + // child). A leftover row that's a *subset* of a master (a cart row without the optional SALE pill / + // was-price) joins that component as a variant, normalized to the master's structure with the + // missing children inserted as hidden placeholders. + let originalChildren = node.children + let masters = signatures.enumerated().reduce(into: [String: Int]()) { result, pair in + if let signature = pair.element, counts[signature, default: 0] >= 2, result[signature] == nil { + result[signature] = pair.offset + } + } + node.children = node.children.enumerated().map { index, child in + if let signature = signatures[index], counts[signature, default: 0] >= 2 { + var componentized = child + componentized.component = signature + return componentized + } + guard child.tag == "frame", child.component == nil else { return child } + for (signature, masterIndex) in masters.sorted(by: { $0.value < $1.value }) { + if let normalized = variantAlign(master: originalChildren[masterIndex], into: child) { + var componentized = normalized + componentized.component = signature + return componentized + } + } + return child } return node } + // Normalize a subset frame to a master's structure so it can be an instance of the same component: align + // children in order, inserting a hidden copy of any master child the subset lacks (an optional layer). + // Returns nil when the subset carries a child the master doesn't — then it isn't a variant of this master. + // A container matches by layout axis, not size (its size legitimately shrinks when it drops an optional + // child); a leaf matches by role (text style / same tag), since per-instance content is overridden later. + private static func variantAlign(master: FigmaNode, into subset: FigmaNode) -> FigmaNode? { + guard master.tag == subset.tag else { return nil } + switch master.tag { + case "text": return master.font?.style == subset.font?.style ? subset : nil + case "image", "spacer": return subset.children.isEmpty ? subset : nil + default: break + } + guard master.layout?.mode == subset.layout?.mode else { return nil } + var aligned: [FigmaNode] = [] + var index = 0 + for masterChild in master.children { + if index < subset.children.count, let child = variantAlign(master: masterChild, into: subset.children[index]) { + aligned.append(child) + index += 1 + } else { + var placeholder = masterChild + placeholder.hidden = true + aligned.append(placeholder) + } + } + guard index == subset.children.count else { return nil } + var normalized = subset + normalized.children = aligned + return normalized + } + private static func signature(_ node: FigmaNode) -> String { if node.tag == "text" { return "T:\(node.font?.style ?? "-"):\(node.textAlign ?? "-")" } - // Bucket size to ~4pt so sub-pixel text-height differences don't split identical cards, while a real - // size difference (a 120 vs 240 card) still lands in distinct buckets. - func bucket(_ value: Double) -> Int { Int((value / 4).rounded()) } + // Bucket size to ~16pt so content-driven width jitter (a longer price, a wider label) doesn't split + // one template, while a real size difference (a 120 vs 240 card) still lands in distinct buckets. + func bucket(_ value: Double) -> Int { Int((value / 16).rounded()) } + // An image is a swappable slot keyed by size, not bytes: same-size images (a gallery's per-row + // photos, or a shared chevron) group so their rows share one component, and the plugin overrides each + // instance's image fill. A genuinely different size (an icon vs a hero photo) stays a distinct slot. + if node.tag == "image" { return "IMG:w\(bucket(node.w)):h\(bucket(node.h))" } var parts = ["\(node.tag):w\(bucket(node.w)):h\(bucket(node.h))"] // Only the axis is stable — justify/align/gap are inferred from rendered geometry and wobble with // text width across otherwise-identical cards, so they'd falsely split one template. Instances @@ -115,8 +184,17 @@ public enum PinDisplayListCapture { return parts.joined(separator: "|") } - private static func hasImageLeaf(_ node: FigmaNode) -> Bool { - node.image != nil || node.children.contains(where: hasImageLeaf) + // A 2-D cell is a container that MIXES a leaf and a sub-stack (a gallery row: an image leaf beside a + // VStack text column) — the shape containment collapses to one component and scrambles. A flat row of + // labels (all leaves) or a list column of rows (all containers) isn't mixed and captures fine as-is. + private static func hasMixedRow(_ node: ReflectedNode) -> Bool { + guard case .container(_, let children) = node else { return false } + var hasLeaf = false, hasContainer = false + for child in children { + if case .leaf = child { hasLeaf = true } + if case .container = child { hasContainer = true } + } + return (hasLeaf && hasContainer) || children.contains { hasMixedRow($0) } } private static func leafCount(_ node: ReflectedNode) -> Int { @@ -133,6 +211,12 @@ public enum PinDisplayListCapture { return groupOrphanIcons(leaves) } + // Every leaf under a box (fully flattened, unlike `flatten` which stops at a cohesive box-of-leaves) — the + // tolerant-zip pool, so a row collapsed to one component still offers its image/title/subtitle to reflection. + private static func allLeaves(_ box: Box) -> [Box] { + box.children.isEmpty ? [box] : box.children.flatMap(allLeaves) + } + private static func flatten(_ box: Box) -> [Box] { let groupsOthers = box.children.contains { !$0.children.isEmpty } if box.children.isEmpty || !groupsOthers { return [box] } @@ -160,7 +244,7 @@ public enum PinDisplayListCapture { } private static func componentText(_ box: Box) -> String? { - if case .text(let string, _, _, _, _) = box.leaf.kind { return string } + if case .text(let string, _, _, _, _, _) = box.leaf.kind { return string } for child in box.children { if let text = componentText(child) { return text } } return nil } @@ -196,11 +280,19 @@ public enum PinDisplayListCapture { // Reflection sees a card's filled shape as a transparent container — re-attach its fill/radius/padding by matching the text set it wraps. let texts = childNodes.reduce(into: Set()) { $0.formUnion(nodeTexts($1)) } let background = backgrounds.first { $0.texts == texts } + var padding = background?.padding ?? EdgeInsets() + // A `.frame(maxWidth: .infinity)` card with left-aligned content hugs the leading edge, so its + // trailing gap is the frame being wider than its content, not real padding. Measured trailing is + // unusable (content never reaches the right), so assume symmetric padding and fill the parent + // width instead of baking the empty space in as a giant trailing inset. + let fillsWidth = container.axis == .column && container.alignment == .leading + && padding.trailing > padding.leading + 8 + if fillsWidth { padding.trailing = padding.leading } let layout = PinCaptureLayout( axis: container.axis, spacing: container.spacing ?? 8, - padding: background?.padding ?? EdgeInsets(), alignment: container.alignment, mainAxisAlignment: .leading + padding: padding, alignment: container.alignment, mainAxisAlignment: .leading ) - return FigmaNode( + var node = FigmaNode( tag: "frame", x: 0, y: 0, w: 0, h: 0, fill: background?.fill.map(RGBA.init), fillToken: background?.fill.flatMap(tokenName(for:)), radius: background?.radius.map(Double.init), @@ -208,6 +300,21 @@ public enum PinDisplayListCapture { name: container.axis == .row ? "HStack" : "VStack", layout: FigmaLayout(layout), ordered: true, children: childNodes ) + // Fill-width propagates: a container whose child fills (a Spacer, or a sub-stack that itself + // fills) must fill too, or the chain breaks — a receipt row hugs and centres because the Spacer + // pushing its price sits two levels down (row → column → price HStack). + let propagatesFill = childNodes.contains { $0.grow == true || $0.fillWidth == true } + if fillsWidth || propagatesFill { node.fillWidth = true } + if let border = container.border { + // A bordered control that reflects to a single frame (a stepper: its ± Buttons drop, leaving + // the value leaf that matches the rendered box) is one bordered pill in code — put the border + // on that box rather than wrapping it in a second frame, so the capture matches the source. + if childNodes.count == 1, childNodes[0].tag == "frame" { + return bordered(childNodes[0], border) + } + return bordered(node, border) + } + return node } } @@ -251,8 +358,11 @@ public enum PinDisplayListCapture { private static func collectBackgrounds(_ box: Box) -> [Background] { var result: [Background] = [] func visit(_ box: Box) { - let groupsOthers = box.children.contains { !$0.children.isEmpty } - if groupsOthers, let fill = fillColor(box.leaf.kind) { + // A card's fill wraps its content: either a box with nested groups, or a flat box holding 2+ + // children (a simple card — thumbnail + text column, or a title + price line). A single-child + // fill box is a pill (a SALE chip), captured through its own leaf, so it's not a card background. + let isCard = box.children.contains { !$0.children.isEmpty } || box.children.count >= 2 + if isCard, let fill = fillColor(box.leaf.kind) { let texts = box.children.reduce(into: Set()) { $0.formUnion(boxTexts($1)) } let union = box.children.map(\.leaf.frame).reduce(nil, unite) ?? box.leaf.frame result.append(Background( @@ -269,7 +379,7 @@ public enum PinDisplayListCapture { private static func boxTexts(_ box: Box) -> Set { var texts = Set() - if case .text(let string, _, _, _, _) = box.leaf.kind { texts.insert(string) } + if case .text(let string, _, _, _, _, _) = box.leaf.kind { texts.insert(string) } box.children.forEach { texts.formUnion(boxTexts($0)) } return texts } @@ -280,14 +390,30 @@ public enum PinDisplayListCapture { return texts } + private static func bordered(_ node: FigmaNode, _ border: ReflectedBorder) -> FigmaNode { + var node = node + let color = UIColor(border.color) + node.stroke = RGBA(color) + node.strokeToken = tokenName(for: color) + node.strokeWidth = Double(border.width) + // A Capsule border is a full pill; Figma clamps an oversized cornerRadius to half the shorter side, + // so a large value renders as a pill without needing the frame's measured height. + if border.isPill { node.radius = 1000 } + else if border.cornerRadius > 0 { + node.radius = Double(border.cornerRadius) + node.radiusToken = radiusTokenName(border.cornerRadius) + } + return node + } + private static func componentNode(_ box: Box, host: UIView) -> FigmaNode { let frame = box.leaf.frame if box.children.isEmpty { switch box.leaf.kind { - case .text(let string, let font, let color, let underline, let alignment): + case .text(let string, let font, let color, let underline, let strikethrough, let alignment): return FigmaNode( tag: "text", x: frame.minX, y: frame.minY, w: frame.width, h: frame.height, - font: figmaFont(font, color: color, underline: underline), + font: figmaFont(font, color: color, underline: underline, strikethrough: strikethrough), texts: [FigmaText(text: string, x: frame.minX, y: frame.minY, w: frame.width, h: frame.height)], textAlign: textAlignName(alignment), children: [] @@ -351,7 +477,7 @@ public enum PinDisplayListCapture { return screenNode } - private final class Box { + final class Box { let leaf: DisplayLeaf var children: [Box] = [] init(_ leaf: DisplayLeaf) { self.leaf = leaf } @@ -378,10 +504,10 @@ public enum PinDisplayListCapture { let frame = box.leaf.frame if box.children.isEmpty { switch box.leaf.kind { - case .text(let string, let font, let color, let underline, let alignment): + case .text(let string, let font, let color, let underline, let strikethrough, let alignment): return FigmaNode( tag: "text", x: frame.minX, y: frame.minY, w: frame.width, h: frame.height, - font: figmaFont(font, color: color, underline: underline), + font: figmaFont(font, color: color, underline: underline, strikethrough: strikethrough), texts: [FigmaText(text: string, x: frame.minX, y: frame.minY, w: frame.width, h: frame.height)], textAlign: textAlignName(alignment), children: [] @@ -417,7 +543,18 @@ public enum PinDisplayListCapture { ) } let orderedChildren = orderedForLayout(box.children) - let layout = inferLayout(orderedChildren.map(\.leaf.frame), in: frame) + var layout = inferLayout(orderedChildren.map(\.leaf.frame), in: frame) + // A left-aligned column's content hugs the leading edge, so a large trailing gap is the frame being + // wider than its content (a .frame(maxWidth:.infinity) card), not padding: drop the bogus inset to + // match leading and fill the parent width instead of baking the empty space in. + let fillsWidth = layout.axis == .column && layout.alignment == .leading + && layout.padding.trailing > layout.padding.leading + 8 + if fillsWidth { + layout = PinCaptureLayout(axis: layout.axis, spacing: layout.spacing, + padding: EdgeInsets(top: layout.padding.top, leading: layout.padding.leading, + bottom: layout.padding.bottom, trailing: layout.padding.leading), + alignment: layout.alignment, mainAxisAlignment: layout.mainAxisAlignment) + } // A leading column pins children left, so a child centered on the axis but inset from the leading edge (a spacing bar sharing the column with a header) gets a full-width centering slot. let contentMinX = orderedChildren.map { $0.leaf.frame.minX }.min() ?? frame.minX let childNodes = orderedChildren.map { child -> FigmaNode in @@ -430,7 +567,7 @@ public enum PinDisplayListCapture { let insetFromLeading = child.leaf.frame.minX - contentMinX > 1 return (centeredOnAxis && insetFromLeading) ? fillWidthCentered(node) : node } - return FigmaNode( + var node = FigmaNode( tag: "frame", x: frame.minX, y: frame.minY, w: frame.width, h: frame.height, fill: fill.map(RGBA.init), fillToken: token, radius: cornerRadius(box.leaf.kind).map(Double.init), @@ -438,11 +575,18 @@ public enum PinDisplayListCapture { name: layout.axis == .row ? "Row" : "Column", layout: FigmaLayout(layout), ordered: true, children: childNodes ) + if fillsWidth { node.fillWidth = true } + return node } - // Drop intermediate containment groups so a pre-grouped two-line row's leaves rejoin their band. + // Dissolve transparent grouping boxes so a pre-grouped two-line row's leaves rejoin their band, but keep + // a fill/radius-bearing box (the SALE pill) whole — flattening through it drops its capsule fill. private static func flattenLeaves(_ boxes: [Box]) -> [Box] { - boxes.flatMap { $0.children.isEmpty ? [$0] : flattenLeaves($0.children) } + boxes.flatMap { box in + box.children.isEmpty || fillColor(box.leaf.kind) != nil || cornerRadius(box.leaf.kind) != nil + ? [box] + : flattenLeaves(box.children) + } } // Cluster leaves into non-overlapping vertical bands (one visual row each) so the parent is unambiguously a column. @@ -498,10 +642,13 @@ public enum PinDisplayListCapture { return nil } - private static func orderedForLayout(_ children: [Box]) -> [Box] { + static func orderedForLayout(_ children: [Box]) -> [Box] { + // Compare vertical CENTRES, not top edges: glyphs on one row (a short minus bar, a tall value, a + // plus) share a centre but differ in top-edge y, so a top-edge sort reads them as stacked and + // scrambles the row (− 1 + → 1 + −). children.sorted { - abs($0.leaf.frame.minY - $1.leaf.frame.minY) > 4 - ? $0.leaf.frame.minY < $1.leaf.frame.minY + abs($0.leaf.frame.midY - $1.leaf.frame.midY) > 4 + ? $0.leaf.frame.midY < $1.leaf.frame.midY : $0.leaf.frame.minX < $1.leaf.frame.minX } } @@ -544,24 +691,31 @@ public enum PinDisplayListCapture { accumulated.map { $0.union(next) } ?? next } - static func figmaFont(_ font: UIFont?, color: UIColor?, underline: Bool) -> FigmaFont { + static func figmaFont(_ font: UIFont?, color: UIColor?, underline: Bool, strikethrough: Bool = false) -> FigmaFont { FigmaFont( - family: "SF Pro Rounded", size: Double(font?.pointSize ?? 17), weight: cssWeight(font), + family: fontFamily(font), size: Double(font?.pointSize ?? 17), weight: cssWeight(font), color: color.map(RGBA.init) ?? RGBA(r: 0, g: 0, b: 0, a: 1), colorToken: color.flatMap(textColorToken(for:)), - style: font.flatMap { PinTextStyle.matching($0)?.captureName }, underline: underline + style: font.flatMap { PinCaptureTokens.current.textStyleName(for: $0) }, underline: underline, + strikethrough: strikethrough ) } - // A text color binds only to a text-role token. A background token matched purely by value — a literal - // white equals primaryBackground's light value — would flip the text dark on a dark-mode import, so a - // contrast literal stays untokenized (static) instead. + // A custom font's real family is Figma-loadable and should carry through; the system font's internal + // family name (prefixed ".") is not, so it falls back to the registry's design-face name. + static func fontFamily(_ font: UIFont?) -> String { + guard let family = font?.familyName, !family.hasPrefix(".") else { return PinCaptureTokens.current.systemFontFamily } + return family + } + + // A text color binds only to a text-role token (the registry's `textEligible`): a background token + // matched purely by value — a literal white equals a light background's value — would flip the text + // dark on a dark-mode import, so a contrast literal stays untokenized (static) instead. private static func textColorToken(for color: UIColor) -> String? { - guard let name = tokenName(for: color), !name.hasSuffix("Background") else { return nil } - return name + PinCaptureTokens.current.colorName(for: color, textRoleOnly: true) } - static let textStyles: [FigmaTextStyle] = PinTextStyle.allCapturable.map { FigmaTextStyle($0) } + static var textStyles: [FigmaTextStyle] { PinCaptureTokens.current.figmaTextStyles } private static func cssWeight(_ font: UIFont?) -> Int { guard let font, @@ -577,20 +731,10 @@ public enum PinDisplayListCapture { } } - static let colorTokens: [FigmaToken] = PinColorToken.allCases.map { - FigmaToken(name: $0.rawValue, type: "color", value: RGBA($0.color, style: .light), dark: RGBA($0.color, style: .dark)) - } + static var colorTokens: [FigmaToken] { PinCaptureTokens.current.figmaColorTokens } static func tokenName(for color: UIColor) -> String? { - let target = RGBA(color) - for token in PinColorToken.allCases { - let candidate = RGBA(token.color, style: .light) - if abs(candidate.r - target.r) < 0.02, abs(candidate.g - target.g) < 0.02, - abs(candidate.b - target.b) < 0.02, abs(candidate.a - target.a) < 0.05 { - return token.rawValue - } - } - return nil + PinCaptureTokens.current.colorName(for: color) } } diff --git a/Pinwheel/Sources/Pinwheel/Capture/PinSwiftUIListCapture.swift b/Pinwheel/Sources/Pinwheel/Capture/PinSwiftUIListCapture.swift new file mode 100644 index 0000000..cb41289 --- /dev/null +++ b/Pinwheel/Sources/Pinwheel/Capture/PinSwiftUIListCapture.swift @@ -0,0 +1,117 @@ +import SwiftUI +import UIKit + +// A SwiftUI `List` is a recycled `UICollectionView` whose every row is its own SwiftUI hosting boundary +// (`CellHostingView`), so the root host's DisplayList never sees the rows. Force every cell to realize +// (size the collection to its `contentSize`), then capture each cell's own hosting view — its DisplayList +// is reachable once `_base` is fetched via the ObjC runtime (Mirror hides it on `CellHostingView`) — and +// compose the rows into a screen. Returns nil when the host has no backing collection, so the caller falls +// through to the normal DisplayList path for non-`List` SwiftUI screens. +@MainActor +public enum PinSwiftUIListCapture { + public static func document(name: String, size: CGSize, screenHeight: CGFloat, liveHost: UIView) -> FigmaDocument? { + guard let collection = firstCollection(in: liveHost) else { return nil } + realizeAllCells(collection) + + let rows: [FigmaNode] = orderedCells(collection).compactMap { cell in + // A row's content is split across nested hosting views, each its own DisplayList boundary. + // Capture every one and place it by its frame. Text-dominant rows capture fully; a rich row + // with embedded controls captures partially — some fragments host their content in a way that + // exposes no readable DisplayList (a known limitation, see the plan). + let fragments: [FigmaNode] = hostingViews(in: cell).compactMap { hosting in + guard let fragment = PinDisplayListCapture.document( + SwiftUI.EmptyView(), name: "Row", size: hosting.bounds.size, + screenHeight: hosting.bounds.height, liveHost: hosting + ) else { return nil } + guard !nodeTexts(fragment.root).isEmpty || !fragment.root.children.isEmpty else { return nil } + let origin = hosting.convert(CGPoint.zero, to: liveHost) + var node = shift(fragment.root, dx: Double(origin.x), dy: Double(origin.y)) + if node.tag == "screen" { node.tag = "frame" } + return node + } + guard !fragments.isEmpty else { return nil } + let frame = cell.convert(cell.bounds, to: liveHost) + return FigmaNode(tag: "frame", x: Double(frame.minX), y: Double(frame.minY), + w: Double(frame.width), h: Double(frame.height), name: "Row", children: fragments) + } + guard !rows.isEmpty else { return nil } + + let top = rows.map { $0.y }.min() ?? 0 + let lifted = rows.map { shift($0, dx: 0, dy: -top) } + let width = Double(size.width) + let contentBottom = lifted.map { $0.y + $0.h }.max() ?? Double(screenHeight) + // A `.plain` List's collection is transparent, so its screen would capture with no background; fall + // back to the opaque surface actually rendered behind it (walking up to the window). Light and dark + // sweep rounds each read their own surface, so the merge gives the screen an adapting background. + let background = collection.backgroundColor.flatMap { $0.cgColor.alpha > 0 ? $0 : nil } + ?? opaqueBackground(above: collection) + let root = FigmaNode( + tag: "screen", x: 0, y: 0, w: width, h: max(Double(screenHeight), contentBottom), + fill: background.map(RGBA.init), fillToken: background.flatMap(PinDisplayListCapture.tokenName(for:)), + name: name, children: lifted + ) + return FigmaDocument(width: width, height: root.h, root: root, + tokens: PinDisplayListCapture.colorTokens + PinFloatTokens.tokens, + textStyles: PinDisplayListCapture.textStyles) + } + + // The first opaque backgroundColor up the superview chain (including the window) — the surface a + // transparent collection is drawn on. + static func opaqueBackground(above view: UIView) -> UIColor? { + var current: UIView? = view.superview + while let candidate = current { + if let color = candidate.backgroundColor, color.cgColor.alpha > 0 { return color } + current = candidate.superview + } + return nil + } + + private static func firstCollection(in view: UIView) -> UIScrollView? { + if view is UICollectionView || view is UITableView { return view as? UIScrollView } + for sub in view.subviews { if let found = firstCollection(in: sub) { return found } } + return nil + } + + private static func realizeAllCells(_ scroll: UIScrollView) { + scroll.layoutIfNeeded() + let full = scroll.contentSize.height + guard full > scroll.bounds.height else { return } + scroll.bounds = CGRect(x: scroll.bounds.minX, y: 0, width: scroll.bounds.width, height: full) + scroll.frame.size.height = full + scroll.layoutIfNeeded() + } + + private static func orderedCells(_ scroll: UIScrollView) -> [UIView] { + let cells: [UIView] = (scroll as? UICollectionView)?.visibleCells + ?? (scroll as? UITableView)?.visibleCells + ?? [] + return cells.sorted { $0.frame.minY < $1.frame.minY } + } + + // Every hosting view in the cell, at any depth — each row fragment (title, price, stepper, image) is + // its own DisplayList boundary, so all of them are needed to reassemble the row. + private static func hostingViews(in view: UIView) -> [UIView] { + var found: [UIView] = [] + func scan(_ view: UIView) { + for sub in view.subviews { + if String(describing: type(of: sub)).contains("HostingView") { found.append(sub) } + scan(sub) + } + } + scan(view) + return found + } + + private static func nodeTexts(_ node: FigmaNode) -> [String] { + (node.texts?.map { $0.text } ?? []) + node.children.flatMap { nodeTexts($0) } + } + + private static func shift(_ node: FigmaNode, dx: Double, dy: Double) -> FigmaNode { + var moved = node + moved.x += dx + moved.y += dy + moved.texts = node.texts?.map { FigmaText(text: $0.text, x: $0.x + dx, y: $0.y + dy, w: $0.w, h: $0.h) } + moved.children = node.children.map { shift($0, dx: dx, dy: dy) } + return moved + } +} diff --git a/Pinwheel/Sources/Pinwheel/Capture/PinVariadicExpander.swift b/Pinwheel/Sources/Pinwheel/Capture/PinVariadicExpander.swift new file mode 100644 index 0000000..6cd8510 --- /dev/null +++ b/Pinwheel/Sources/Pinwheel/Capture/PinVariadicExpander.swift @@ -0,0 +1,131 @@ +import SwiftUI +import UIKit +import Darwin + +// Expands a `ForEach` (which reflects to nothing — its content is an uncallable closure) into its real row +// view *instances*, by driving SwiftUI's private variadic machinery and dereferencing each row's node out +// of the AttributeGraph. Reads content from the resolved instances, so runtime conditionals (`if onSale`) +// resolve per-row — impossible from the static type alone. +// +// Every piece here is private, undocumented SwiftUI/AttributeGraph internals pinned to a toolchain. It is +// therefore gated behind `isHealthy`, a cached self-test that expands a known fixture and checks the +// recovered structure; if a new OS changes the ABI/layout, the probe fails and capture falls back to the +// containment path instead of misbehaving. `PinVariadicExpanderTests` asserts the probe + each step so a +// breaking OS turns the suite red in development before it ships. +@MainActor +enum PinVariadicExpander { + // AGGraphGetValue(attribute: UInt32, options: UInt32, type: metadata) -> AGValue{value: void*, changed}. + // The 16-byte result returns in x0/x1 (arm64); we only need x0, so model it as the pointer. Resolved at + // runtime (AttributeGraph is already loaded by SwiftUI) so we never link the private framework. + private typealias GetValueFn = @convention(c) (UInt32, UInt32, UnsafeRawPointer) -> UnsafeMutableRawPointer? + private static let getValue: GetValueFn? = { + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AGGraphGetValue") else { return nil } + return unsafeBitCast(symbol, to: GetValueFn.self) + }() + + /// The row view instances of a `ForEach` (or any variadic content), or nil when the private path is + /// unavailable/unhealthy — the caller then falls back to containment. + static func expand(_ view: Any) -> [Any]? { + guard isHealthy, let anyView = view as? any SwiftUI.View else { return nil } + return rawExpand(anyView) + } + + /// Cached capability probe: only trust the deref if it recovers a known fixture's structure exactly. + static let isHealthy: Bool = runSelfTest() + + // MARK: Expansion + + private final class Sink { + var rows: [Any] = [] + var failed = false + } + + private struct Root: _VariadicView.MultiViewRoot { + let sink: Sink + func body(children: _VariadicView.Children) -> some SwiftUI.View { + for element in children { + guard let (attribute, type) = attributeAndType(element), + let pointer = getValue?(attribute, 0, unsafeBitCast(type, to: UnsafeRawPointer.self)) else { + sink.failed = true + break + } + sink.rows.append(loadValue(UnsafeRawPointer(pointer), as: type)) + } + // Return the children so SwiftUI actually resolves the list (returning EmptyView short-circuits). + return children + } + } + + private static func rawExpand(_ view: any SwiftUI.View) -> [Any]? { + guard getValue != nil else { return nil } + func host(_ content: V) -> [Any]? { + let sink = Sink() + let controller = UIHostingController(rootView: _VariadicView.Tree(Root(sink: sink)) { content }) + controller.view.frame = CGRect(x: 0, y: 0, width: 402, height: 1200) + let window = UIWindow(frame: controller.view.frame) + window.rootViewController = controller + window.isHidden = false + controller.view.layoutIfNeeded() + return withExtendedLifetime(window) { sink.failed ? nil : sink.rows } + } + return _openExistential(view, do: host) + } + + // MARK: Graph navigation + + // A row's node carries `view: AGWeakAttribute` (id in `_details.identifier.rawValue`) and `viewType` + // (the row's type metatype). Hunt for that pair inside the element. + private static func attributeAndType(_ value: Any, _ depth: Int = 0) -> (UInt32, Any.Type)? { + guard depth < 12 else { return nil } + let mirror = Mirror(reflecting: value) + if let weak = mirror.children.first(where: { $0.label == "view" })?.value, + String(describing: type(of: weak)) == "AGWeakAttribute", + let type = mirror.children.first(where: { $0.label == "viewType" })?.value as? Any.Type, + let details = child(weak, "_details"), + let identifier = child(details, "identifier"), + let raw = child(identifier, "rawValue") as? UInt32 { + return (raw, type) + } + for element in mirror.children { + if let found = attributeAndType(element.value, depth + 1) { return found } + } + return nil + } + + private static func child(_ value: Any, _ label: String) -> Any? { + Mirror(reflecting: value).children.first { $0.label == label }?.value + } + + // Load a value of a dynamically-known type off a raw pointer into `Any` (recover the type generically). + private static func loadValue(_ pointer: UnsafeRawPointer, as type: Any.Type) -> Any { + func project(_ t: T.Type) -> Any { pointer.load(as: T.self) } + return _openExistential(type, do: project) + } + + // MARK: Self-test + + private struct ProbeRow: SwiftUI.View { + let title: String + let extra: Bool + var body: some SwiftUI.View { + HStack { PinLabel(title); if extra { PinLabel("extra") } } + } + } + + private static func runSelfTest() -> Bool { + guard getValue != nil else { return false } + // Two rows, one with a conditional child — verifies expansion, deref, load, and per-row conditional + // resolution all at once. A layout/ABI change breaks the recovered structure and trips this. + let probe = ForEach([true, false], id: \.self) { flag in ProbeRow(title: "row", extra: flag) } + guard let rows = rawExpand(probe), rows.count == 2 else { return false } + func leafCount(_ node: ReflectedNode?) -> Int { + switch node { + case .leaf: return 1 + case .container(_, let children): return children.reduce(0) { $0 + leafCount($1) } + default: return 0 + } + } + // Row 0 (extra: true) → 2 labels; row 1 (extra: false) → 1 label. Both must reflect to real HStacks. + return leafCount(PinViewReflector.reflect(rows[0])) == 2 && leafCount(PinViewReflector.reflect(rows[1])) == 1 + } +} diff --git a/Pinwheel/Sources/Pinwheel/Capture/PinViewReflector.swift b/Pinwheel/Sources/Pinwheel/Capture/PinViewReflector.swift index 4bdf4f5..b559dba 100644 --- a/Pinwheel/Sources/Pinwheel/Capture/PinViewReflector.swift +++ b/Pinwheel/Sources/Pinwheel/Capture/PinViewReflector.swift @@ -12,6 +12,14 @@ struct ReflectedContainer { let axis: PinCaptureLayout.Axis let spacing: CGFloat? let alignment: PinCaptureLayout.CrossAxis + var border: ReflectedBorder? +} + +struct ReflectedBorder { + let color: Color + let width: CGFloat + var isPill: Bool = false + var cornerRadius: CGFloat = 0 } enum PinViewReflector { @@ -22,10 +30,16 @@ enum PinViewReflector { private static func walk(_ value: Any) -> ReflectedNode? { let typeName = String(describing: type(of: value)) + // An `if`-without-else yields an Optional view; unwrap `.some`, drop `.none`. Mirror is the only + // safe unwrap — matching the "Optional" type name and re-walking the same value loops forever. + if Mirror(reflecting: value).displayStyle == .optional { + return Mirror(reflecting: value).children.first.flatMap { walk($0.value) } + } + if typeName.hasPrefix("VStack") || typeName.hasPrefix("HStack") { let axis: PinCaptureLayout.Axis = typeName.hasPrefix("VStack") ? .column : .row let (spacing, alignment, content) = stackFields(value) - let children = content.map { flatten($0).compactMap(walk) } ?? [] + let children = content.map { expandedChildren($0) } ?? [] return .container(ReflectedContainer(axis: axis, spacing: spacing, alignment: alignment), children) } if isLeaf(typeName) { @@ -42,10 +56,27 @@ enum PinViewReflector { } if typeName.hasPrefix("ModifiedContent") { let modifier = property(value, "modifier") - let node = property(value, "content").flatMap(walk) + let rawContent = property(value, "content") + // A fixed-size frame around an image is a sized thumbnail — a component the containment path keeps, + // so reflection counts it as a leaf. (An intrinsic-size image — an SF Symbol — has no such frame and + // stays dropped, matching containment which drops those.) + if isFixedFrame(modifier), let rawContent, isImageType(rawContent) { + return .leaf(text: nil, isButton: false, fillWidth: false) + } + let node = rawContent.flatMap(walk) if isFillWidthFrame(modifier), case .leaf(let text, let isButton, _) = node { return .leaf(text: text, isButton: isButton, fillWidth: true) } + if let border = strokeBorder(modifier), let node { + if case .container(var container, let children) = node { + container.border = border + return .container(container, children) + } + // A bordered single element (a stepper drawn as one label) wraps into a bordered container so + // the border is carried and the leaf count stays 1 — matching containment, which groups a + // ring-enclosed control into one component. + return .container(ReflectedContainer(axis: .row, spacing: nil, alignment: .center, border: border), [node]) + } return node } if typeName.hasPrefix("TupleView") || typeName.hasPrefix("Group") || typeName.hasPrefix("Optional") @@ -53,7 +84,15 @@ enum PinViewReflector { let children = flatten(value).compactMap(walk) return children.count == 1 ? children.first : (children.isEmpty ? nil : .container(ReflectedContainer(axis: .column, spacing: nil, alignment: .leading), children)) } + // A ForEach not directly inside a stack (e.g. ScrollView { ForEach }) — expand its real rows into a + // column. Inside a stack, `expandedChildren` splices them as siblings instead. + if typeName.hasPrefix("ForEach"), let rows = PinVariadicExpander.expand(value) { + return .container(ReflectedContainer(axis: .column, spacing: nil, alignment: .leading), rows.compactMap(walk)) + } if isStructuralContainer(typeName) { return nil } + if isShape(typeName) { + return .leaf(text: nil, isButton: false, fillWidth: false) + } if isPrimitive(typeName) { return nil } // A SwiftUI primitive's or UIKit-bridge's `.body` traps if reached; skip so capture falls back to containment instead of crashing. if value is any UIViewRepresentable || value is any UIViewControllerRepresentable { return nil } @@ -74,6 +113,48 @@ enum PinViewReflector { return (Mirror(reflecting: modifier).children.first { $0.label == "maxWidth" }?.value as? CGFloat) == .infinity } + private static func isFixedFrame(_ modifier: Any?) -> Bool { + guard let modifier, String(describing: type(of: modifier)) == "_FrameLayout" else { return false } + let mirror = Mirror(reflecting: modifier) + let width = mirror.children.first { $0.label == "width" }?.value as? CGFloat + let height = mirror.children.first { $0.label == "height" }?.value as? CGFloat + return width != nil && height != nil + } + + private static func isImageType(_ value: Any) -> Bool { + let typeName = String(describing: type(of: value)) + return typeName == "Image" || typeName.hasPrefix("AsyncImage") + } + + // An `.overlay(shape.stroke(color, lineWidth:))` renders as a filled ring in the DisplayList (no readable + // width), but the view value still holds the `StrokeStyle` and its `Color`. Pull them out so a bordered + // control captures its border editably. Gated to overlay modifiers so a fill/background stroke elsewhere + // isn't mistaken for a border. + private static func strokeBorder(_ modifier: Any?) -> ReflectedBorder? { + guard let modifier, String(describing: type(of: modifier)).contains("Overlay") else { return nil } + var width: CGFloat? + var color: Color? + var isPill = false + var cornerRadius: CGFloat = 0 + func search(_ value: Any, _ depth: Int) { + if depth > 8 { return } + let typeName = String(describing: type(of: value)) + if let stroke = value as? StrokeStyle { width = stroke.lineWidth } + else if let strokeColor = value as? Color { color = strokeColor } + // The stroked shape sets the border's rounding: a Capsule is a full pill; a RoundedRectangle + // carries its own radius (in a `cornerSize` CGSize). Read it so the imported frame isn't square. + if typeName == "Capsule" || typeName.hasPrefix("Capsule<") { isPill = true } + else if typeName.hasPrefix("RoundedRectangle"), + let cornerSize = Mirror(reflecting: value).children.first(where: { $0.label == "cornerSize" })?.value as? CGSize { + cornerRadius = cornerSize.width + } + for child in Mirror(reflecting: value).children { search(child.value, depth + 1) } + } + search(modifier, 0) + guard let width, let color else { return nil } + return ReflectedBorder(color: color, width: width, isPill: isPill, cornerRadius: cornerRadius) + } + private static func leafText(_ value: Any) -> String? { let mirror = Mirror(reflecting: value) for label in ["title", "text"] { @@ -92,6 +173,14 @@ enum PinViewReflector { primitiveTypes.contains { typeName == $0 || typeName.hasPrefix($0 + "<") } } + // A standalone shape renders as a fill/stroke box the containment path keeps as a component, so it's a + // leaf; a filled/stroked shape is a `*ShapeView` (SwiftUI wraps `.fill()`/`.stroke()`). Image is NOT here + // — the containment path drops SF Symbols, so counting them would desync the reflected leaf total. + private static func isShape(_ typeName: String) -> Bool { + if typeName.contains("ShapeView") { return true } + return shapeTypes.contains { typeName == $0 || typeName.hasPrefix($0 + "<") } + } + private static let shapeTypes = ["RoundedRectangle", "Rectangle", "Circle", "Capsule", "Ellipse"] private static let structuralContainerTypes = ["ForEach", "List", "Section", "LazyVStack", "LazyHStack"] private static func isStructuralContainer(_ typeName: String) -> Bool { structuralContainerTypes.contains { typeName == $0 || typeName.hasPrefix($0 + "<") } @@ -114,6 +203,18 @@ enum PinViewReflector { return (spacing, alignment, content) } + // Flatten a stack's content, splicing any ForEach into its real rows (as siblings) so a ForEach-built + // list reflects as if written inline. If the private expander is unhealthy, the ForEach yields nothing + // and the whole screen falls to the containment path downstream (count mismatch) — never a crash. + private static func expandedChildren(_ content: Any) -> [ReflectedNode] { + flatten(content).flatMap { item -> [ReflectedNode] in + if String(describing: type(of: item)).hasPrefix("ForEach"), let rows = PinVariadicExpander.expand(item) { + return rows.compactMap(walk) + } + return [item].compactMap(walk) + } + } + private static func flatten(_ content: Any) -> [Any] { if String(describing: type(of: content)).hasPrefix("TupleView"), let tuple = Mirror(reflecting: content).children.first(where: { $0.label == "value" })?.value { diff --git a/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinList.swift b/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinList.swift index d601ca0..358b97b 100644 --- a/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinList.swift +++ b/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinList.swift @@ -4,6 +4,9 @@ public struct PinList: SwiftUI.View { private let state: PinState private let rows: [Row] private let onRetry: () -> Void + // A UIKit-backed `List` hides its rows behind per-cell hosting views the capture can't read, so under + // capture PinList renders the same rows in a pure-SwiftUI stack instead. Same `Row` in both — 1:1 cells. + @Environment(\.pinCapturing) private var capturing public init(state: PinState = .loaded, rows: [Row], onRetry: @escaping () -> Void = {}) { self.state = state @@ -14,23 +17,40 @@ public struct PinList: SwiftUI.View { public var body: some SwiftUI.View { switch state { case .loaded: - // No per-row id to key on; positional identity is stable because rows are a fixed value array per render. - ScrollView { - VStack(spacing: 0) { - ForEach(Array(rows.enumerated()), id: \.offset) { index, row in - row - .padding(.horizontal, .spacingM) - .padding(.vertical, .spacingS) - if index < rows.count - 1 { - Divider().padding(.leading, .spacingM) - } + if capturing { capturableStack } else { productionList } + default: + PinStateView(state, onAction: onRetry) + } + } + + private var productionList: some SwiftUI.View { + List { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + row + .listRowInsets(EdgeInsets(top: .spacingS, leading: .spacingM, bottom: .spacingS, trailing: .spacingM)) + .listRowBackground(PinwheelTheme.Colors.primaryBackground) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(.primaryBackground) + } + + // No per-row id to key on; positional identity is stable because rows are a fixed value array per render. + private var capturableStack: some SwiftUI.View { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + row + .padding(.horizontal, .spacingM) + .padding(.vertical, .spacingS) + if index < rows.count - 1 { + Divider().padding(.leading, .spacingM) } } } - .background(.primaryBackground) - default: - PinStateView(state, onAction: onRetry) } + .background(.primaryBackground) } } diff --git a/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinStepper.swift b/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinStepper.swift new file mode 100644 index 0000000..459061b --- /dev/null +++ b/Pinwheel/Sources/Pinwheel/Components/SwiftUI/PinStepper.swift @@ -0,0 +1,36 @@ +import SwiftUI + +public struct PinStepper: SwiftUI.View { + private let value: Int + private var onDecrement: () -> Void = {} + private var onIncrement: () -> Void = {} + + public init(value: Int) { + self.value = value + } + + public func onDecrement(_ action: @escaping () -> Void) -> PinStepper { + var copy = self + copy.onDecrement = action + return copy + } + + public func onIncrement(_ action: @escaping () -> Void) -> PinStepper { + var copy = self + copy.onIncrement = action + return copy + } + + public var body: some SwiftUI.View { + HStack(spacing: .spacingM) { + SwiftUI.Button(action: onDecrement) { Image(systemName: "minus") } + PinLabel("\(value)").font(.body).frame(minWidth: 20) + SwiftUI.Button(action: onIncrement) { Image(systemName: "plus") } + } + .font(PinTextStyle.body.font) + .foregroundStyle(.actionText) + .padding(.horizontal, .spacingM) + .padding(.vertical, .spacingS) + .overlay(Capsule().stroke(.tertiaryText, lineWidth: 1)) + } +} diff --git a/Pinwheel/Tests/PinwheelTests/CaptureFidelityTests.swift b/Pinwheel/Tests/PinwheelTests/CaptureFidelityTests.swift index 5113ef5..cca7fe0 100644 --- a/Pinwheel/Tests/PinwheelTests/CaptureFidelityTests.swift +++ b/Pinwheel/Tests/PinwheelTests/CaptureFidelityTests.swift @@ -370,6 +370,64 @@ final class CaptureFidelityTests: XCTestCase { XCTAssertTrue(cards.allSatisfy { $0.component == nil }, "cards of different widths must not be grouped — an instance can't override width") } + // Rows that share an identical image (an icon/chevron the same across every row) must group into one + // component — the image is reproducible, unlike a per-row photo which stays distinct. + func testIdenticalImageRowsGroupAsOneComponent() throws { + struct Cards: SwiftUI.View { + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(["Alpha", "Bravo", "Charlie"], id: \.self) { title in + HStack { + Image(systemName: "star.fill") + PinLabel(title).font(.body) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.spacingL) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } + } + let root = try XCTUnwrap(PinDisplayListCapture.document(Cards(), name: "Icons", size: CGSize(width: 402, height: 1600), screenHeight: 778)).root + let keys = Set(allFrameNodes(in: root).compactMap { $0.component }) + XCTAssertEqual(keys.count, 1, "rows sharing one identical icon must group into a single component") + XCTAssertEqual(allFrameNodes(in: root).filter { $0.component != nil }.count, 3, "all three icon rows are instances") + } + + // Two cards of the same template that differ by a few points of content-driven width must still group; + // only a real size difference (see testDifferentlySizedCards) splits them. + func testCardsWithMinorWidthDifferenceStillGroup() throws { + struct Cards: SwiftUI.View { + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + card(width: 200, title: "One") + card(width: 206, title: "Two") + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } + func card(width: CGFloat, title: String) -> some SwiftUI.View { + VStack { PinLabel(title).font(.title); PinLabel("detail").font(.caption) } + .frame(width: width) + .padding(.spacingL) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + let root = try XCTUnwrap(PinDisplayListCapture.document(Cards(), name: "Widths", size: CGSize(width: 402, height: 1600), screenHeight: 778)).root + let keys = Set(allFrameNodes(in: root).compactMap { $0.component }) + XCTAssertEqual(keys.count, 1, "cards differing by a few points still group as one template") + } + private func hasCenteringSlot(_ node: FigmaNode) -> Bool { if node.name == "Center", node.children.contains(where: { $0.fill != nil }) { return true } return node.children.contains { hasCenteringSlot($0) } diff --git a/Pinwheel/Tests/PinwheelTests/ComponentVariantTests.swift b/Pinwheel/Tests/PinwheelTests/ComponentVariantTests.swift new file mode 100644 index 0000000..9d79994 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/ComponentVariantTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import Pinwheel + +@MainActor +final class ComponentVariantTests: XCTestCase { + private func text() -> FigmaNode { + FigmaNode(tag: "text", x: 0, y: 0, w: 40, h: 20, children: []) + } + private func pill() -> FigmaNode { + FigmaNode(tag: "frame", x: 0, y: 0, w: 40, h: 20, children: [text()]) + } + // A cart row: title + optional SALE pill, then a price + optional strikethrough was-price. + private func row(sale: Bool) -> FigmaNode { + let title = FigmaNode(tag: "frame", x: 0, y: 0, w: 200, h: 20, children: sale ? [text(), pill()] : [text()]) + let price = FigmaNode(tag: "frame", x: 0, y: 20, w: 200, h: 20, children: sale ? [text(), text()] : [text()]) + return FigmaNode(tag: "frame", x: 0, y: 0, w: 300, h: 60, children: [title, price]) + } + + // A gallery of rows that share a structure but each carry a different photo should group as one + // component with the image as a per-instance override — not stay three plain frames, since a Figma + // instance can swap an image fill. + func testSameStructureRowsWithDifferentImagesGroup() { + func imageRow(_ bytes: String) -> FigmaNode { + FigmaNode(tag: "frame", x: 0, y: 0, w: 300, h: 80, + children: [FigmaNode(tag: "image", x: 0, y: 0, w: 64, h: 64, image: bytes, children: []), text()]) + } + let parent = FigmaNode(tag: "frame", x: 0, y: 0, w: 300, h: 240, + children: [imageRow("AAAA"), imageRow("BBBB"), imageRow("CCCC")]) + let result = PinDisplayListCapture.componentizeRepeatedChildren(parent) + let keys = result.children.map { $0.component } + XCTAssertTrue(keys.allSatisfy { $0 != nil }, "every row is part of the component") + XCTAssertEqual(Set(keys.compactMap { $0 }).count, 1, "same-structure rows group as ONE component even though each has a different image") + } + + // Three identical sale rows and one no-sale row: the no-sale row differs only by the optional SALE pill + // and was-price, so it must join the same component as an instance, with those two layers inserted as + // hidden placeholders — not stay a separate frame. + func testNoSaleRowJoinsTheSaleComponentWithHiddenOptionalLayers() { + let parent = FigmaNode(tag: "frame", x: 0, y: 0, w: 300, h: 240, + children: [row(sale: true), row(sale: true), row(sale: false), row(sale: true)]) + let result = PinDisplayListCapture.componentizeRepeatedChildren(parent) + + let keys = result.children.map { $0.component } + XCTAssertTrue(keys.allSatisfy { $0 != nil }, "every row is part of the component") + XCTAssertEqual(Set(keys.compactMap { $0 }).count, 1, "all four rows share ONE component — the no-sale row is a variant, not its own frame") + + // The no-sale row (index 2) is normalized to the master's structure: its title row now carries a + // hidden SALE pill and its price row a hidden was-price. + let noSale = result.children[2] + let titleRow = noSale.children[0] + let priceRow = noSale.children[1] + XCTAssertEqual(titleRow.children.count, 2, "the title row gains the SALE pill placeholder") + XCTAssertEqual(titleRow.children.last?.hidden, true, "the inserted SALE pill is hidden") + XCTAssertEqual(priceRow.children.count, 2, "the price row gains the was-price placeholder") + XCTAssertEqual(priceRow.children.last?.hidden, true, "the inserted was-price is hidden") + } +} diff --git a/Pinwheel/Tests/PinwheelTests/FillWidthCardPaddingTests.swift b/Pinwheel/Tests/PinwheelTests/FillWidthCardPaddingTests.swift new file mode 100644 index 0000000..24bc365 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/FillWidthCardPaddingTests.swift @@ -0,0 +1,47 @@ +import XCTest +import SwiftUI +import UIKit +@testable import Pinwheel + +@MainActor +final class FillWidthCardPaddingTests: XCTestCase { + // Mirrors CardsDemo: full-width cards (.frame(maxWidth: .infinity)) with left-aligned content. + private struct Fixture: SwiftUI.View { + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(["Revenue", "Orders"], id: \.self) { title in + VStack(alignment: .leading, spacing: .spacingXS) { + PinLabel(title).font(.caption).color(.secondary) + PinLabel("$12,480").font(.title) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.spacingL) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } + } + + private func cards(_ node: FigmaNode) -> [FigmaNode] { + (node.fillToken == "secondaryBackground" && node.layout != nil ? [node] : []) + node.children.flatMap(cards) + } + + // A full-width left-aligned card must not record the empty space to the right of its content as + // trailing padding — the padding stays symmetric (spacing-l all round) and the card fills its parent. + func testFullWidthCardHasSymmetricPaddingNotAGiantTrailingGap() throws { + let document = try XCTUnwrap(PinDisplayListCapture.document( + Fixture(), name: "Cards", size: CGSize(width: 402, height: 700), screenHeight: 700)) + let card = try XCTUnwrap(cards(document.root).first, "should capture a secondaryBackground card frame") + let pad = try XCTUnwrap(card.layout?.pad, "the card should carry auto-layout padding") + // pad = [top, trailing, bottom, leading] + XCTAssertEqual(pad[1], pad[3], accuracy: 1.0, + "trailing padding must match leading — a left-aligned card's right gap is fill space, not padding (got trailing=\(pad[1]), leading=\(pad[3]))") + XCTAssertEqual(card.fillWidth, true, "a full-width card should fill its parent, not force width via padding") + } +} diff --git a/Pinwheel/Tests/PinwheelTests/FillWidthPropagationTests.swift b/Pinwheel/Tests/PinwheelTests/FillWidthPropagationTests.swift new file mode 100644 index 0000000..0ca0a1e --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/FillWidthPropagationTests.swift @@ -0,0 +1,54 @@ +import XCTest +import SwiftUI +import UIKit +@testable import Pinwheel + +// A row whose fill-width comes from a Spacer nested inside its text column (a receipt row: thumbnail + +// a column whose price line has a Spacer) must fill the card width, not hug and centre. Fill-width has to +// propagate up from the nested Spacer through the column to the row. +@MainActor +final class FillWidthPropagationTests: XCTestCase { + private struct Row: SwiftUI.View { + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(["A", "B"], id: \.self) { title in + HStack(spacing: .spacingM) { + RoundedRectangle(cornerRadius: .radiusM).fill(.primaryBackground).frame(width: 64, height: 64) + VStack(alignment: .leading, spacing: .spacingS) { + PinLabel(title).font(.bodySemibold) + HStack { + PinLabel("qty").font(.caption) + Spacer() + PinLabel("kr 9").font(.bodySemibold) + } + } + } + .padding(.spacingM).background(.secondaryBackground).cornerRadius(.radiusM) + } + }.padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top).background(.primaryBackground) + } + } + + func testRowFillsWidthFromNestedSpacer() throws { + let size = CGSize(width: 402, height: 900) + let controller = UIHostingController(rootView: Row().environment(\.pinCapturing, true)) + controller.view.frame = CGRect(origin: .zero, size: size) + let window = UIWindow(frame: controller.view.frame) + window.rootViewController = controller + window.isHidden = false + controller.view.layoutIfNeeded() + let document = try XCTUnwrap(PinDisplayListCapture.document(Row(), name: "Row", size: size, screenHeight: 778, liveHost: controller.view)) + // The card row (secondaryBackground fill) must fill width, not hug its content. + func findCard(_ node: FigmaNode) -> FigmaNode? { + if node.fillToken == "secondaryBackground" { return node } + for child in node.children { if let found = findCard(child) { return found } } + return nil + } + let card = try XCTUnwrap(findCard(document.root), "the card row captures with its fill") + XCTAssertEqual(card.fillWidth, true, "the card row fills the width (from its nested Spacer), so it doesn't hug and centre") + _ = withExtendedLifetime(window) {} + } +} diff --git a/Pinwheel/Tests/PinwheelTests/GalleryCaptureTests.swift b/Pinwheel/Tests/PinwheelTests/GalleryCaptureTests.swift new file mode 100644 index 0000000..d3f51e4 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/GalleryCaptureTests.swift @@ -0,0 +1,56 @@ +import XCTest +import SwiftUI +import UIKit +@testable import Pinwheel + +// A plain thumbnail + text-column row (the canonical list cell) collapses to one component in containment +// but reflection sees its parts. The tolerant zip falls back to matching reflection against the flattened +// leaves, so the row captures with its real nesting (thumbnail leading, then the text column) rather than +// scrambling on the containment path. +@MainActor +final class GalleryCaptureTests: XCTestCase { + private struct Gallery: SwiftUI.View { + static let swatch = UIGraphicsImageRenderer(size: CGSize(width: 120, height: 120)).image { context in + UIColor.systemOrange.setFill(); context.fill(CGRect(x: 0, y: 0, width: 120, height: 120)) + } + let rows = ["Sunset Ridge", "Ocean Deep", "Forest Trail"] + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(rows, id: \.self) { title in + HStack(spacing: .spacingM) { + Image(uiImage: Self.swatch).resizable().frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: .radiusM)) + VStack(alignment: .leading, spacing: .spacingXS) { + PinLabel(title).font(.bodySemibold) + PinLabel("Sub").font(.caption).color(.secondary) + } + Spacer() + } + .padding(.spacingM).background(.secondaryBackground).cornerRadius(.radiusM) + } + }.padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top).background(.primaryBackground) + } + } + + func testImageRowCapturesStructuredNotScrambled() throws { + let size = CGSize(width: 402, height: 900) + let controller = UIHostingController(rootView: Gallery().environment(\.pinCapturing, true)) + controller.view.frame = CGRect(origin: .zero, size: size) + let window = UIWindow(frame: controller.view.frame) + window.rootViewController = controller + window.isHidden = false + controller.view.layoutIfNeeded() + let document = try XCTUnwrap(PinDisplayListCapture.document(Gallery(), name: "Gallery", size: size, screenHeight: 778, liveHost: controller.view)) + XCTAssertEqual(document.root.tag, "screen", + "a thumbnail + text-column row captures through reflection (structured), not the containment path (scrambled)") + func hasFill(_ node: FigmaNode) -> Bool { + node.fillToken == "secondaryBackground" || node.children.contains(where: hasFill) + } + XCTAssertTrue(hasFill(document.root), + "the row keeps its card background fill — the reflection path must re-attach a flat-content card's fill, not drop it") + _ = withExtendedLifetime(window) {} + } +} diff --git a/Pinwheel/Tests/PinwheelTests/ListBackgroundCaptureTests.swift b/Pinwheel/Tests/PinwheelTests/ListBackgroundCaptureTests.swift new file mode 100644 index 0000000..af3da6b --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/ListBackgroundCaptureTests.swift @@ -0,0 +1,27 @@ +import XCTest +import UIKit +@testable import Pinwheel + +@MainActor +final class ListBackgroundCaptureTests: XCTestCase { + // A `.plain` List's collection is transparent, so the captured screen would have no background. The + // capture falls back to the opaque surface rendered behind it — the first opaque backgroundColor up + // the superview chain, which is what the list is visually drawn on. + func testOpaqueBackgroundFindsSurfaceBehindTransparentView() { + let surface = UIColor(red: 0.95, green: 0.96, blue: 0.97, alpha: 1) + let root = UIView(); root.backgroundColor = surface + let transparent = UIView(); transparent.backgroundColor = .clear + let collection = UIView() + root.addSubview(transparent); transparent.addSubview(collection) + XCTAssertEqual(PinSwiftUIListCapture.opaqueBackground(above: collection), surface, + "the screen background falls back to the opaque surface behind a transparent collection") + } + + // A fully transparent chain has no surface to fall back to — return nil rather than a bogus fill. + func testOpaqueBackgroundIsNilWhenNothingBehindIsOpaque() { + let root = UIView(); root.backgroundColor = .clear + let collection = UIView() + root.addSubview(collection) + XCTAssertNil(PinSwiftUIListCapture.opaqueBackground(above: collection)) + } +} diff --git a/Pinwheel/Tests/PinwheelTests/OrderedForLayoutTests.swift b/Pinwheel/Tests/PinwheelTests/OrderedForLayoutTests.swift new file mode 100644 index 0000000..9321ef5 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/OrderedForLayoutTests.swift @@ -0,0 +1,22 @@ +import XCTest +import UIKit +@testable import Pinwheel + +@MainActor +final class OrderedForLayoutTests: XCTestCase { + private func box(x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) -> PinDisplayListCapture.Box { + PinDisplayListCapture.Box(DisplayLeaf(frame: CGRect(x: x, y: y, width: w, height: h), kind: .transparent)) + } + + // A stepper row: a full-height value between a short minus bar and a plus glyph. The glyphs share a + // vertical centre but differ in top-edge y by more than a few points, so ordering by top-edge y wrongly + // reads them as stacked and scrambles them (− 1 + → 1 + −). Ordering must key on the vertical centre. + func testMixedHeightRowOrdersLeftToRightByCentre() { + let minus = box(x: 295.7, y: 59.7, w: 14, h: 2) + let value = box(x: 323.7, y: 50.3, w: 8, h: 20) + let plus = box(x: 346, y: 53.7, w: 14, h: 14) + let ordered = PinDisplayListCapture.orderedForLayout([value, plus, minus]) + XCTAssertEqual(ordered.map { $0.leaf.frame.minX }, [295.7, 323.7, 346], + "elements sharing a vertical centre order left-to-right, regardless of differing heights/top-edges") + } +} diff --git a/Pinwheel/Tests/PinwheelTests/PinCaptureTokensTests.swift b/Pinwheel/Tests/PinwheelTests/PinCaptureTokensTests.swift new file mode 100644 index 0000000..2830bf2 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/PinCaptureTokensTests.swift @@ -0,0 +1,55 @@ +import XCTest +import UIKit +@testable import Pinwheel + +@MainActor +final class PinCaptureTokensTests: XCTestCase { + override func tearDown() { + PinCaptureTokens.current = .pinwheel + super.tearDown() + } + + // A consumer's own color must bind to the consumer's own token name — the engine can't be hardwired to + // Pinwheel's palette. + func testCustomColorRegistryBindsConsumerTokenName() { + let brand = UIColor(red: 0.90, green: 0.20, blue: 0.50, alpha: 1) + PinCaptureTokens.current = PinCaptureTokens( + colors: [PinCaptureTokens.ColorToken(name: "brand/pink", light: brand, dark: brand)], + spacings: [], radii: [], systemFontFamily: "SF Pro Rounded" + ) + XCTAssertEqual(PinDisplayListCapture.tokenName(for: brand), "brand/pink") + } + + // A consumer's spacing scale (indexed names, e.g. space-3 = 12) must bind, not Pinwheel's. + func testCustomSpacingRegistryBindsConsumerName() { + PinCaptureTokens.current = PinCaptureTokens( + colors: [], spacings: [PinCaptureTokens.FloatToken(name: "space-3", value: 12)], radii: [], systemFontFamily: "X" + ) + XCTAssertEqual(PinFloatTokens.spacingName(for: 12), "space-3") + } + + // A custom (non-system) font must capture its real family, so a consumer's brand font imports correctly + // instead of the hardcoded system design name. + func testCapturedTextUsesTheActualFontFamily() throws { + let georgia = try XCTUnwrap(UIFont(name: "Georgia", size: 16)) + let font = PinDisplayListCapture.figmaFont(georgia, color: .black, underline: false) + XCTAssertEqual(font.family, "Georgia", "a custom font captures its real family, not the system design name") + } + + // A consumer's named text style (its size + weight) must bind, not Pinwheel's style names. + func testCustomTextStyleRegistryBindsConsumerStyleName() { + let font = UIFont.systemFont(ofSize: 16, weight: .regular) + PinCaptureTokens.current = PinCaptureTokens( + colors: [], spacings: [], radii: [], systemFontFamily: "Inter", + textStyles: [PinCaptureTokens.TextStyleToken(name: "bodyM", family: "Inter", size: 16, weight: 400)] + ) + let captured = PinDisplayListCapture.figmaFont(font, color: .black, underline: false) + XCTAssertEqual(captured.style, "bodyM", "captured text binds the consumer's text-style name") + } + + // Regression: the default registry still binds Pinwheel's own tokens. + func testDefaultRegistryStillBindsPinwheelTokens() { + let actionBackground = UIColor(PinColorToken.actionBackground.color).resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)) + XCTAssertEqual(PinDisplayListCapture.tokenName(for: actionBackground), "actionBackground") + } +} diff --git a/Pinwheel/Tests/PinwheelTests/PinSwiftUIListCaptureTests.swift b/Pinwheel/Tests/PinwheelTests/PinSwiftUIListCaptureTests.swift new file mode 100644 index 0000000..1814baa --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/PinSwiftUIListCaptureTests.swift @@ -0,0 +1,36 @@ +import XCTest +import SwiftUI +import UIKit +@testable import Pinwheel + +@MainActor +final class PinSwiftUIListCaptureTests: XCTestCase { + private struct ListScreen: SwiftUI.View { + var body: some SwiftUI.View { + List { ForEach(1...12, id: \.self) { SwiftUI.Text("Row \($0)") } } + } + } + + + // A SwiftUI List hides each row behind a CellHostingView; capture must force-realize the backing + // collection and read each cell's own DisplayList so rows land as editable text, not a flat image. + func testListRowsCaptureAsEditableText() throws { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 800)) + let controller = UIHostingController(rootView: ListScreen()) + window.rootViewController = controller + window.isHidden = false + window.layoutIfNeeded() + controller.view.layoutIfNeeded() + + let document = try XCTUnwrap( + PinSwiftUIListCapture.document(name: "List", size: CGSize(width: 402, height: 800), screenHeight: 778, liveHost: controller.view), + "a SwiftUI List should capture its rows, not return nil" + ) + var texts: [String] = [] + func collect(_ node: FigmaNode) { texts += (node.texts?.map { $0.text } ?? []); node.children.forEach(collect) } + collect(document.root) + XCTAssertTrue(texts.contains("Row 1"), "row text must capture as editable text nodes") + XCTAssertTrue(texts.contains("Row 12"), "every realized row captures, not just the visible viewport") + withExtendedLifetime(window) {} + } +} diff --git a/Pinwheel/Tests/PinwheelTests/PinVariadicExpanderTests.swift b/Pinwheel/Tests/PinwheelTests/PinVariadicExpanderTests.swift new file mode 100644 index 0000000..cfc4547 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/PinVariadicExpanderTests.swift @@ -0,0 +1,65 @@ +import XCTest +import SwiftUI +@testable import Pinwheel + +// Heavy coverage of the private-internals ForEach expander. These are the canary: if a new iOS changes +// SwiftUI's variadic layout or AttributeGraph's ABI, these go RED (and, in production, `isHealthy` flips to +// false and capture falls back to containment instead of misbehaving). +@MainActor +final class PinVariadicExpanderTests: XCTestCase { + private func leaves(_ node: ReflectedNode?) -> Int { + switch node { + case .leaf: return 1 + case .container(_, let children): return children.reduce(0) { $0 + leaves($1) } + default: return 0 + } + } + + // THE canary. If this fails on a new OS, the private path broke — fix or accept the containment fallback. + func testExpanderIsHealthyOnThisOS() { + XCTAssertTrue(PinVariadicExpander.isHealthy, + "the ForEach expander self-test failed — SwiftUI/AttributeGraph internals changed; capture will fall back to containment") + } + + // A plain ForEach expands to its real row instances, reflectable to correct structure. + func testExpandsRowsToRealInstances() throws { + let forEach = ForEach(["Revenue", "Orders", "Users"], id: \.self) { title in + HStack { PinLabel(title); Spacer(); PinLabel("$1") } + } + let rows = try XCTUnwrap(PinVariadicExpander.expand(forEach), "healthy expander returns rows") + XCTAssertEqual(rows.count, 3) + XCTAssertTrue(rows.allSatisfy { leaves(PinViewReflector.reflect($0)) == 2 }, "each row: two PinLabels") + } + + // The decisive property: a runtime conditional resolves per-row (the metatype path could not). + func testResolvesRuntimeConditionalPerRow() throws { + let forEach = ForEach(["A", "B"], id: \.self) { name in + HStack { PinLabel(name); if name == "A" { PinLabel("SALE") }; Spacer() } + } + let rows = try XCTUnwrap(PinVariadicExpander.expand(forEach)) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(leaves(PinViewReflector.reflect(rows[0])), 2, "row A keeps the conditional badge") + XCTAssertEqual(leaves(PinViewReflector.reflect(rows[1])), 1, "row B drops it") + } + + // A nested 2-D row (the Cart shape) recovers its full structure — the whole point. + func testExpandsNestedTwoDimensionalRow() throws { + let forEach = ForEach(["A", "B"], id: \.self) { name in + HStack { + RoundedRectangle(cornerRadius: 8).frame(width: 40, height: 40) + VStack(alignment: .leading) { PinLabel(name); PinLabel("$10") } + Spacer() + PinLabel("×1") + } + } + let rows = try XCTUnwrap(PinVariadicExpander.expand(forEach)) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(leaves(PinViewReflector.reflect(rows[0])), 4, + "thumbnail shape + name + $10 + ×1 — the filled shape reflects as a leaf, matching the fill box containment keeps") + } + + // A non-view returns nil (never crashes the caller). + func testNonViewReturnsNil() { + XCTAssertNil(PinVariadicExpander.expand(42)) + } +} diff --git a/Pinwheel/Tests/PinwheelTests/RasterImageCaptureTests.swift b/Pinwheel/Tests/PinwheelTests/RasterImageCaptureTests.swift new file mode 100644 index 0000000..dacc5b6 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/RasterImageCaptureTests.swift @@ -0,0 +1,44 @@ +import XCTest +import SwiftUI +import UIKit +@testable import Pinwheel + +@MainActor +final class RasterImageCaptureTests: XCTestCase { + private static func swatch() -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 80, height: 80)).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(x: 0, y: 0, width: 80, height: 80)) + UIColor.systemOrange.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) + } + } + + private struct Fixture: SwiftUI.View { + let image: UIImage + var body: some SwiftUI.View { + VStack(spacing: 16) { + Image(uiImage: image).resizable().frame(width: 80, height: 80) + PinLabel("Photo").font(.body) + } + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } + } + + private func imageNodes(_ node: FigmaNode) -> [FigmaNode] { + (node.image != nil ? [node] : []) + node.children.flatMap(imageNodes) + } + + // A raster image (a real photo, not an SF Symbol vector) must capture as an image node with pixels — + // its DisplayList content kind is `image`, which was unhandled and dropped the photo entirely. + func testRasterImageCapturesWithPixels() throws { + let document = try XCTUnwrap(PinDisplayListCapture.document( + Fixture(image: Self.swatch()), name: "Photo", size: CGSize(width: 402, height: 300), screenHeight: 300)) + let images = imageNodes(document.root) + XCTAssertFalse(images.isEmpty, "a raster image must capture as an image node with pixels, not be dropped") + let photo = try XCTUnwrap(images.first { abs($0.w - 80) < 4 && abs($0.h - 80) < 4 }, "the 80×80 swatch should capture at its size") + XCTAssertNotNil(photo.image, "the image node must carry cropped pixels") + } +} diff --git a/Pinwheel/Tests/PinwheelTests/ReflectionContractTests.swift b/Pinwheel/Tests/PinwheelTests/ReflectionContractTests.swift index 3a05d6b..cdd850c 100644 --- a/Pinwheel/Tests/PinwheelTests/ReflectionContractTests.swift +++ b/Pinwheel/Tests/PinwheelTests/ReflectionContractTests.swift @@ -117,11 +117,127 @@ final class ReflectionContractTests: XCTestCase { } func testStructuralContainersReflectNil() { - XCTAssertNil(PinViewReflector.reflect(ForEach(0..<3, id: \.self) { PinLabel("row \($0)") }), - "a ForEach reflects nil so capture uses the containment fallback") XCTAssertNil(PinViewReflector.reflect(List { PinLabel("row") }), "a List reflects nil — its lazy UIKit-backed rows aren't in the reflected tree") XCTAssertNil(PinViewReflector.reflect(Section { PinLabel("row") }), "a Section reflects nil, falling back to containment") } + + // A ForEach of *container* rows (the rich/2-D case the deref targets — Cart etc.) expands into its real + // rows via PinVariadicExpander, so it no longer reflects nil. Bare-leaf rows (ForEach { PinLabel }) have + // a different graph-node shape the deref doesn't reach; those return nil and the screen falls back to the + // containment path (their prior behavior — a simple 1-D list containment already handles). + // A standalone filled/stroked shape (a thumbnail's `RoundedRectangle().fill()`) renders as a fill box the + // containment path keeps as a component, so reflection must emit it as a leaf — else a rich row's reflected + // leaf count falls short of the rendered components and the whole screen drops to containment (Cart's + // 2-D card scramble). An Image (SF Symbol) must NOT be a leaf — containment drops symbols, so counting one + // would overshoot the other way. + func testFilledShapeReflectsToALeafButImageDoesNot() { + XCTAssertNotNil(PinViewReflector.reflect(RoundedRectangle(cornerRadius: 8).fill(.red).frame(width: 56, height: 56)), + "a filled shape reflects to a leaf — the containment path keeps its fill box as a component") + XCTAssertNil(PinViewReflector.reflect(Image(systemName: "photo")), + "an SF Symbol reflects nil — the containment path drops symbols, so counting it would desync the zip") + } + + // The rich 2-D row (thumbnail | info column | stepper) reflects with the thumbnail shape leading, the + // info as a nested column, and the quantity trailing — the structure Cart needs so Figma auto-layout + // lays it left-to-right instead of ordering by Y (the v5 scramble). + func testTwoDimensionalRowReflectsThumbnailInfoColumnStepper() throws { + try XCTSkipUnless(PinVariadicExpander.isHealthy, "expander unavailable on this OS — falls back to containment") + let row = HStack { + RoundedRectangle(cornerRadius: 8).fill(.gray).frame(width: 56, height: 56) + VStack(alignment: .leading) { + PinLabel("Title").font(.body) + PinLabel("$129").font(.bodySemibold) + } + Spacer() + PinLabel("1").font(.body) + } + guard case .container(let outer, let top)? = PinViewReflector.reflect(row), outer.axis == .row else { + return XCTFail("the row reflects to a horizontal container") + } + func firstLeafText(_ n: ReflectedNode) -> String?? { + switch n { + case .leaf(let t, _, _): return .some(t) + case .container(_, let c): return c.compactMap(firstLeafText).first ?? nil + case .spacer: return nil + } + } + guard case .leaf(let leadingText, _, _) = top.first else { + return XCTFail("the row leads with the thumbnail shape leaf") + } + XCTAssertNil(leadingText, "the leading leaf is the thumbnail shape (no text)") + let texts = top.compactMap(firstLeafText).compactMap { $0 } + XCTAssertEqual(texts, ["Title", "1"], "the info column (Title) precedes the trailing quantity, in reading order") + } + + // An `.overlay(Capsule().stroke(color, lineWidth:))` border reflects onto the container. Reflection reads + // the StrokeStyle's lineWidth from the view value — unlike the DisplayList, which bakes the stroke to a + // filled ring with no readable width — so a bordered control (a stepper pill) captures its border editably. + func testOverlayStrokeReflectsAsTheContainerBorder() throws { + let bordered = HStack { PinLabel("−"); PinLabel("+") } + .overlay(Capsule().stroke(Color.red, lineWidth: 2)) + guard case .container(let container, _)? = PinViewReflector.reflect(bordered) else { + return XCTFail("a bordered HStack reflects to a container") + } + let border = try XCTUnwrap(container.border, "the overlay stroke is captured as the container's border") + XCTAssertEqual(border.width, 2, "the border carries the stroke's lineWidth, read from the view value") + } + + // A Capsule stroke border captures as a pill so the imported frame is fully rounded, not a square + // rectangle; a RoundedRectangle stroke carries its own corner radius. + func testCapsuleStrokeBorderCapturesAsPill() throws { + let capsule = HStack { PinLabel("x") }.overlay(Capsule().stroke(Color.red, lineWidth: 1)) + guard case .container(let capsuleContainer, _)? = PinViewReflector.reflect(capsule), + let capsuleBorder = capsuleContainer.border else { + return XCTFail("the capsule-bordered container carries a border") + } + XCTAssertTrue(capsuleBorder.isPill, "a Capsule stroke border is a pill (fully rounded)") + + let rounded = HStack { PinLabel("x") }.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.red, lineWidth: 1)) + guard case .container(let roundedContainer, _)? = PinViewReflector.reflect(rounded), + let roundedBorder = roundedContainer.border else { + return XCTFail("the rounded-rect-bordered container carries a border") + } + XCTAssertFalse(roundedBorder.isPill, "a RoundedRectangle stroke is not a pill") + XCTAssertEqual(roundedBorder.cornerRadius, 8, "the RoundedRectangle border carries its corner radius") + } + + // A bordered single leaf (a stepper drawn as one "− 1 +" label with an overlay stroke) wraps into a + // bordered container so the border is carried and the leaf count stays 1 — matching containment, which + // groups the ring-enclosed control into a single component (its flatten treats a box of leaves as one). + func testBorderedLeafWrapsIntoABorderedContainer() throws { + let bordered = PinLabel("− 1 +").overlay(Capsule().stroke(Color.red, lineWidth: 1)) + guard case .container(let container, let children)? = PinViewReflector.reflect(bordered) else { + return XCTFail("a bordered leaf wraps into a container carrying the border") + } + XCTAssertNotNil(container.border, "the wrapping container carries the stroke border") + XCTAssertEqual(children.count, 1, "the original leaf is the container's sole child") + guard case .leaf(let text, _, _) = children.first else { return XCTFail("the child is the label leaf") } + XCTAssertEqual(text, "− 1 +", "the label text survives the wrap") + } + + // A fixed-size (framed) image is a deliberate thumbnail the containment path keeps as a component, so + // reflection must count it as a leaf — else a row like a gallery cell (thumbnail + text column) reflects + // short of the rendered components and drops to the containment path, which scrambles the 2-D row. An + // unframed intrinsic-size image (an SF Symbol) still reflects nil, since containment drops those. + func testFramedImageReflectsToALeafButUnframedDoesNot() { + XCTAssertNotNil(PinViewReflector.reflect(Image(systemName: "photo").resizable().frame(width: 64, height: 64)), + "a fixed-size framed image reflects as a leaf") + XCTAssertNil(PinViewReflector.reflect(Image(systemName: "plus")), + "an unframed image still reflects nil") + } + + func testForEachOfContainerRowsExpands() throws { + try XCTSkipUnless(PinVariadicExpander.isHealthy, "expander unavailable on this OS — ForEach falls back to containment") + func leaves(_ n: ReflectedNode?) -> Int { + switch n { + case .container(_, let c): return c.reduce(0) { $0 + leaves($1) } + case .leaf: return 1 + default: return 0 + } + } + XCTAssertEqual(leaves(PinViewReflector.reflect(ForEach(["r0", "r1"], id: \.self) { name in HStack { PinLabel(name) } })), 2, + "ForEach of HStack rows expands → 2 leaves") + } } diff --git a/Pinwheel/Tests/PinwheelTests/SalePillCaptureTests.swift b/Pinwheel/Tests/PinwheelTests/SalePillCaptureTests.swift new file mode 100644 index 0000000..385215c --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/SalePillCaptureTests.swift @@ -0,0 +1,52 @@ +import XCTest +import SwiftUI +import UIKit +@testable import Pinwheel + +@MainActor +final class SalePillCaptureTests: XCTestCase { + // Multiple identical rows so the reflection + componentization path is taken (the on-screen sweep path), + // mirroring CartDemo where the SALE pill's fill dropped. + private struct Fixture: SwiftUI.View { + var body: some SwiftUI.View { + ScrollView { + VStack(spacing: .spacingM) { + ForEach(["Alpha", "Bravo", "Charlie"], id: \.self) { name in + HStack(spacing: .spacingM) { + VStack(alignment: .leading, spacing: .spacingXS) { + HStack(spacing: .spacingS) { + PinLabel(name).font(.body) + PinLabel("SALE").font(.footnote).color(.custom(.white)) + .padding(.horizontal, .spacingS).padding(.vertical, 2) + .background(.criticalBackground, in: Capsule()) + } + PinLabel("$10").font(.bodySemibold) + } + Spacer() + } + .padding(.spacingM) + .background(.secondaryBackground) + .cornerRadius(.radiusM) + } + } + .padding(.spacingL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.primaryBackground) + } + } + + private func hasFill(_ node: FigmaNode, token: String) -> Bool { + node.fillToken == token || node.children.contains { hasFill($0, token: token) } + } + + // A pill (a fill + radius wrapping a single label, like a SALE badge) must keep its fill through the + // containment vertical-list path — flattenLeaves used to dissolve the pill wrapper down to its bare + // label, dropping the capsule and leaving white text invisible on a light card. + func testSalePillFillSurvivesCapture() throws { + let document = try XCTUnwrap(PinDisplayListCapture.document( + Fixture(), name: "Sale", size: CGSize(width: 402, height: 700), screenHeight: 700)) + XCTAssertTrue(hasFill(document.root, token: "criticalBackground"), + "the SALE pill's criticalBackground capsule must survive — else the white label is invisible") + } +} diff --git a/Pinwheel/Tests/PinwheelTests/TextDecorationCaptureTests.swift b/Pinwheel/Tests/PinwheelTests/TextDecorationCaptureTests.swift new file mode 100644 index 0000000..d7b3545 --- /dev/null +++ b/Pinwheel/Tests/PinwheelTests/TextDecorationCaptureTests.swift @@ -0,0 +1,34 @@ +import XCTest +import UIKit +@testable import Pinwheel + +@MainActor +final class TextDecorationCaptureTests: XCTestCase { + private func decorations(_ attributed: NSAttributedString) -> (underline: Bool, strikethrough: Bool) { + guard case .text(_, _, _, let underline, let strikethrough, _) = PinDisplayList.textKind(from: attributed, fallback: nil) else { + XCTFail("expected a text kind") + return (false, false) + } + return (underline, strikethrough) + } + + // A struck-through run (a "was" price) must carry its strikethrough into the IR, not import as plain text. + func testStruckThroughRunCapturesStrikethrough() { + let struck = NSAttributedString(string: "$4.99", attributes: [.strikethroughStyle: NSUnderlineStyle.single.rawValue]) + XCTAssertTrue(decorations(struck).strikethrough) + } + + // Plain text carries neither decoration. + func testPlainRunHasNoDecorations() { + let plain = NSAttributedString(string: "$4.99") + XCTAssertFalse(decorations(plain).underline) + XCTAssertFalse(decorations(plain).strikethrough) + } + + // Underline and strikethrough are independent — a struck run isn't reported as underlined and vice versa. + func testUnderlineAndStrikethroughAreIndependent() { + let underlined = NSAttributedString(string: "Link", attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue]) + XCTAssertTrue(decorations(underlined).underline) + XCTAssertFalse(decorations(underlined).strikethrough) + } +} diff --git a/README.md b/README.md index b631c3d..50e9804 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,29 @@ Components that already ship a UIKit-friendly shell — `UIPinButton`, `UIPinSta Pinwheel can preview a demo in known iPhone and iPad sizes from the floating settings sheet. SwiftUI demos receive the simulated horizontal and vertical size classes through the SwiftUI environment while the content frame is resized to the selected device. +## Figma Capture + +Pinwheel can export your running catalog to editable Figma — every component captured 1:1 with the simulator, in light and dark, as real text/color/number nodes (not a flat screenshot). Components capture with **zero cooperation**: there's no capture code or markers in your views. The engine reads what a component renders (its structure from geometry, its names from reflection) and value-matches the rendered colors, spacing, radii, and fonts against your registered tokens so they import as named, editable Figma variables. + +Register your design tokens once so the match can happen: + +```swift +import Pinwheel + +PinCaptureTokens.current = PinCaptureTokens( + colors: [ + .init(name: "primaryText", light: .black, dark: .white), + .init(name: "primaryBackground", light: .white, dark: .black), + ], + spacings: [.init(name: "spacing-m", value: 16)], + radii: [.init(name: "radius-m", value: 12)], + systemFontFamily: "SF Pro", + textStyles: [.init(name: "body", family: "SF Pro", size: 17, weight: 400)] +) +``` + +Build capturable screens as eager SwiftUI (`ScrollView { VStack { ForEach } }`) — including bespoke 2-D rows (a thumbnail, a stacked text column, a trailing control), which capture with their real nested layout, not a flattened one. A `List` is UIKit-backed and captures lazily; route lists through `PinList` for a full capture. The capture flow itself (the sweep script, the local serve, and the "Pinwheel Capture Import" Figma plugin) is a developer tool that lives in the repo, not something your app links against. + ## Demo App The demo app groups examples by concept into three sections: diff --git a/docs/planning/capture-consumer-readiness.md b/docs/planning/capture-consumer-readiness.md new file mode 100644 index 0000000..24251a4 --- /dev/null +++ b/docs/planning/capture-consumer-readiness.md @@ -0,0 +1,170 @@ +# Figma Capture — Consumer-Readiness Plan (agnostic) + +Goal: a real SwiftUI-first app adopts the capture pipeline with minimal changes — most +components capture as-is; the consumer supplies their design tokens + fonts and changes +nothing structural. + +## Foundation — capture from the on-screen key view (retire the off-screen path) +Today there are two capture paths: an on-screen live host (UIKit controls only paint on a real +window) and an off-screen one that builds + activates its own `UIWindow`. The off-screen +activation is fragile (crashes on hostless/headless test processes) and renders incompletely +(lazy content, async images, controls). Decision: make the **component the key-window root** and +float the catalog chrome as an overlay layer, so capture always reads the real, on-screen tree. +- One capture path, not two; no off-screen window activation. +- Directly unblocks Blocker 1 (lazy realizes on a real sized window) and fidelity (async images + load, controls paint). +- Test strategy: verify capture through the on-screen sweep / DemoUITests in the hosted Demo app + (real window + scene); retire off-screen hostless-window unit tests (also removes the runner + flake if Actions returns). Keep pure-logic unit tests (token matching, signatures, IR shape). +- Cost to weigh: catalog pickers/settings become overlays (layout + hit-testing rework; the FAB + already lives in a pass-through window, so there's precedent). + +## Blocker 1 — Lazy container capture +`List`, `LazyVStack`, `LazyVGrid`, `LazyHStack` are viewport-gated → capture empty *off-screen*. +Measured on the on-screen sweep host (demos in Screens, `.figma`): +- **`LazyVStack` — SOLVED.** 20/20 cards captured (`figma-lazy-cards`). The content-height live host + makes the outer ScrollView's viewport == content, so every row realizes into the DisplayList. +- **`LazyVGrid` — SOLVED.** 12/12 tiles captured (`figma-lazy-grid`). (Per-tile fill fidelity is a + follow-up — labels all present; the containment path flattened the tile backgrounds.) +- **`List` — editable capture WORKING (partial for rich rows), via `PinSwiftUIListCapture`.** A SwiftUI + `List` is a recycled `UICollectionView` (`UpdateCoalescingCollectionView`); each row is its own SwiftUI + hosting boundary (`ListCollectionViewCell → _UICollectionViewListCellContentView → CellHostingView`). + Two moves crack it: (1) **force-realize** — size the collection to its `contentSize` so every cell exists + (14→20 confirmed); (2) **read each cell's own DisplayList** — `CellHostingView` reflects to an empty + `Mirror`, but its `_base` (a `UIHostingViewBase` with the full `viewGraph`) is reachable via the **ObjC + runtime** (`class_getInstanceVariable`/`object_getIvar`). So `displayList(of:)` fetches `_base` via Mirror + *or* the ObjC ivar, and per-cell capture composes the rows into a screen. + - **Text-dominant rows: full** — proven, a plain-text `List` captures every row as editable text + (`testListRowsCaptureAsEditableText`, `Row 1…Row 12`). + - **Rich rows: partial** — `ProductListDemo` (image + title + SALE pill + was/now price + stepper) goes + from a **blank box** to **structured rows** with the image placeholder + one text fragment each (title + or stepper). SwiftUI scatters a rich row across several nested hosting views and only some expose a + readable DisplayList (a `Button`'s label, an inline stepper's text can host in a form that yields + nothing). Capturing every hosting view didn't recover them — a genuine long-tail internals gap. + - Wired ahead of the DisplayList path for SwiftUI items (`PinSwiftUIListCapture.document(...) ?? displayList()`); + returns nil (falls through) for non-`List` screens. The `_base`-via-ObjC change is regression-free for + the root `_UIHostingView` path (it still finds `_base` via Mirror first). + - Follow-up: recover the missing rich-row fragments (map SwiftUI's control/image hosting forms) if full + fidelity on raw-`List` Cart-style rows is needed; today they capture as structured-but-partial. +- **The clean path for consumers: `PinList` with a capture switch (`pinCapturing`).** A raw `List`'s + rich rows only capture partially; but a consumer list built on `PinList` captures **fully**. `PinList` + renders a real `List` in production (recycling, native separators) and, under the `pinCapturing` + environment (set by the capture pipeline in `PinDisplayList.read` and the sweep host), renders the same + rows in an eager stack the DisplayList reads completely. Same `Row` in both — 1:1 cells. Result + (`PinListDemo`, 6 rows): **all row text editable + 1 component + 5 instances**, chevron included. So a + consumer routes lists through `PinList` (a small, non-structural change) and gets editable design + + component-as-rows + instances-on-repeat, sidestepping the raw-`List` wall entirely. +- **Grouping signature handles images + width jitter.** Repeated-cell componentization keys an image node + by its **bytes** (identical icons/chevrons group; per-row photos stay distinct — an instance can't + override an image) and buckets size to ~16pt (content-driven width jitter, e.g. a longer price, doesn't + split one template; a real 120-vs-240 difference still does). +- Layout-fidelity follow-up: the lazy demos fell to the containment path (`root=frame`, not `screen`), + so auto-layout/component-grouping is lossier than the reflection path. Investigate matching the + reflection path for large lazy trees. +- Tall content beyond one screen: scroll-and-stitch the live container as a fallback. + +## Blocker 2 — Consumer-supplied token & font registry +Engine value-matched colors/spacing/radius against fixed library enums and hardcoded the font family. +- **Phase 1 — DONE (`PinCaptureTokens`).** A consumer-supplied registry the matchers consult: + `PinCaptureTokens.current` (defaults to `.pinwheel`) holds color tokens (name + light/dark + a + `textEligible` flag so a text color doesn't bind a background token), spacing + radius float tokens, and + a `systemFontFamily`. The color/float matchers (`tokenName`, `spacingName`/`radiusName`/`gapName`) and + the Figma-variable emitters (`figmaColorTokens`/`figmaFloatTokens`) route through `.current`; captured + text reads its **actual** font family (custom fonts carry through; the system font falls back to + `systemFontFamily`). Default preserves current behavior — full suite green. Tests: a custom color binds + a custom name, a custom spacing binds, a custom font family is emitted, and the default still binds + Pinwheel tokens. +- **Phase 2 — DONE (text styles).** A consumer's named text styles now match + emit from the registry: + `PinCaptureTokens.textStyles` (name/family/size/weight); `textStyleName(for:)` matches a rendered font by + size + weight, `figmaFont.style` and the document's emitted `textStyles` route through `.current`. + Default preserves the Pinwheel style names (existing typography tests green). Consumer "adapter" is just + building a `PinCaptureTokens` from their design system — the public init *is* the seam, no extra API. +- **Font availability is the consumer's job (not ours).** Figma renders a family only if it's a Google + Font, installed on the designer's machine (desktop app), or a shared Org font — and the plugin API can't + install fonts. The pipeline emits the family name; the consumer makes their brand fonts available in + Figma. The plugin already **falls back to Inter** (`resolveFont`) when a family isn't loadable, so a + missing font never breaks the import. +- Multi-brand: swap `PinCaptureTokens.current` per brand before capture. + +## Fidelity gaps +3. **Real / async images — DONE.** A raster image (`Image(uiImage:)`, a loaded `AsyncImage`) has + DisplayList content kind `image`, which was unhandled — `contentKind` returned `.unknown` and the + photo was dropped entirely (SF Symbols only worked because they're vector `shape` content). + `contentKind` now maps `image` → `.rasterizable`, so the existing host-layer crop fills it with real + pixels, same path as a symbol. Red-first (`RasterImageCaptureTests`); verified end-to-end via the + capturable `ImageGalleryDemo` — three `AsyncImage(url:)` photos capture as 64×64 image nodes with + pixels + dark variants. Readiness caveat: capture reads after the sweep's fixed 0.5s, so a + *local/fast* image (bundled, file-URL, cached) is loaded in time; a genuinely slow *remote* fetch + could still miss the window — a bounded load-await on the live host is the follow-up. +4. Text decoration + mixed runs. + - **Strikethrough — DONE.** `textKind` reads `.strikethroughStyle` alongside `.underlineStyle`, + threads it through `FigmaFont.strikethrough`, and the plugin draws `STRIKETHROUGH` (skipping the + style binding that would wipe it, same as underline). Verified end-to-end: the capturable + `PricingDemo` (eager `ScrollView`/`VStack`) serializes all four struck "was" prices as + `strikethrough:true` with their caption style + `secondaryText` token intact. A raw `List` + still can't (the struck price never enters its partial capture — known List limitation). + - **Mixed-run text — remaining.** Per-run font/color within one text node: `textKind` reads only + run 0's attributes, so a `Text("A") + Text("B").bold()` captures as one uniform run. +5. Borders / strokes. + - **Editable stroke — HARD WALL.** SwiftUI resolves `.stroke(color, lineWidth:)` to a *filled ring + path* before it reaches the DisplayList: the shape payload is `(Path, AnyResolvedPaint, FillStyle)` + — a `FillStyle`, never a `StrokeStyle`, so there is **no `lineWidth`** to read (probe-confirmed on + a `Capsule().stroke(_, lineWidth: 1)`). The width is baked into the ring geometry. So the plugin's + `node.stroke`/`strokeWeight` (which it already supports) can't be driven from the capture — the + border can only be *rasterized* (the ring already captures as a `.rasterizable` bitmap leaf). To + emit an editable stroke we'd have to infer width+radius from the ring's Path geometry (fragile). + - **Enclosing-rasterizable image dropped.** Even the rasterized border is lost today: the ring image + *encloses* the stepper's contents, so `containmentTree` makes it the parent Box and `emit`'s + container branch drops the parent's own image (a `.roundedRect` parent keeps its fill; a + `.rasterizable` parent doesn't). Fixing that (emit the enclosing image behind its children) would + render the border as a bitmap, at the cost of turning that frame absolute — low value (non-editable) + vs regression risk, so deferred. +6. **SALE-pill fill — FIXED.** A `.background(_, in: Capsule())` wrapping a single label (a SALE badge) + lost its fill whenever the card split into a title-row band + a price band: `emit`'s containment + vertical-list path ran `flattenLeaves`, which dissolved the fill-bearing pill wrapper down to its bare + label, so `absoluteRowGroup` rebuilt the row without the capsule — white text, invisible on a light + card. (Single-row fixtures took a different path and hid it; the multi-row/full-screen case triggers + it — reproducible at the unit layer, no DemoUITest needed.) `flattenLeaves` now keeps a fill/radius + box whole and only dissolves transparent groups. Red-first (`SalePillCaptureTests`); verified through + the real sweep with `figma-plugin/render_ir.py` (renders the captured IR to a PNG for diffing against + a `-PinwheelPreview` sim screenshot) — the pink pills are back on all three sale rows. +7. Blur/materials — low fidelity: translucent fill or crop. +8. Gradients / page dots / custom shapes — low priority; gradient paint when detectable. + +## Complex rows: bespoke 2-D `ForEach` captures via reflection (SHIPPED) +A bespoke 2-D row (`HStack { thumbnail, VStack{title/price}, Spacer, stepper }` in a `ForEach`) now +captures faithfully through the reflection path — no `PinList` required. Two pieces: +1. **The ForEach expander** (`PinVariadicExpander`, `elvis/foreach-fold`) reverse-engineers SwiftUI's + AttributeGraph: expand the `ForEach` via `_VariadicView.Tree(MultiViewRoot)` (its `body(children:)` + MUST return the children), then deref each row's `TypedUnaryViewGenerator` through the private + `AGGraphGetValue` C ABI (dlsym'd, called inside `body`) to recover the real row instance for the + reflector. Guarded by a cached `isHealthy` self-test canary + graceful containment fallback, so a + future iOS breaking the private ABI is caught (a red canary test) and never crashes. +2. **Shape leaves close the zip.** The reflection→containment zip gate is exact-count, and a rich row's + reflected leaves must match the rendered components. The reflector now emits 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 count the other way. + +CartDemo is the bespoke 2-D `ForEach` (restored from PinList) and captures as `root=screen` with the +correct nested `HStack[thumbnail | VStack(title/SALE, now/was) | spacer | stepper]` — no Y-order +scramble. Verified 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. `ForEach`-of-*bare-leaf* rows (`ForEach { PinLabel }`) still fall back to containment +(different graph shape; 1-D containment already handles them). Remaining Cart gaps are the stepper +border (§5, hard wall for editable) and the SALE pill fill (§6, on-screen-only drop). + +## UI-tier note (environment) +The `DemoUITests` catalog navigation uses a SwiftUI `Menu` section picker that **does not open under UI +automation on iOS 26.5** (the menu never presents; `Tokens`/`Screens` buttons never appear) — the +baseline `b008fec` fails the same tests on 26.5, so it's a runtime issue, not a regression. The suite is +green on iOS 18.3. Harden the picker interaction (or the picker) so the tier runs on the newest runtime +per the durability principle (a test pins a capability, not a snapshot OS). + +## Perf follow-up +The SwiftUI grouping signature is O(n²) on large trees (recomputes each subtree at every level). +Memoize bottom-up to O(n) before heavy consumer screens. + +## Sequencing +0) On-screen key-view foundation → 1) lazy (mostly falls out) + List backing-walk → +2) token/font registry → 3) fidelity: async images → mixed text → shadows/borders → blur → +gradients. diff --git a/figma-plugin/code.js b/figma-plugin/code.js index 4aa2818..e9ef7c3 100644 --- a/figma-plugin/code.js +++ b/figma-plugin/code.js @@ -88,6 +88,7 @@ var PW = (() => { fontRequest: { family: font.family, weight: font.weight, italic: font.italic }, fill: font.color ? { color: font.color, token: font.colorToken } : null, underline: Boolean(font.underline), + strikethrough: Boolean(font.strikethrough), letterSpacing: typeof font.letterSpacing === "number" ? font.letterSpacing : null, autoResize: multiline ? "HEIGHT" : "WIDTH_AND_HEIGHT", width: multiline ? run.w : null, @@ -192,7 +193,7 @@ var PW = (() => { async function makeText(run, font) { const plan = planText(run, font); const text = figma.createText(); - const style = plan.styleName && !plan.underline ? textStyles[plan.styleName] : void 0; + const style = plan.styleName && !plan.underline && !plan.strikethrough ? textStyles[plan.styleName] : void 0; if (style) { await figma.loadFontAsync(style.fontName); text.fontName = style.fontName; @@ -206,6 +207,8 @@ var PW = (() => { if (plan.underline) { text.textDecoration = "UNDERLINE"; text.textDecorationOffset = { value: 2, unit: "PIXELS" }; + } else if (plan.strikethrough) { + text.textDecoration = "STRIKETHROUGH"; } if (!style && plan.letterSpacing !== null) text.letterSpacing = { value: plan.letterSpacing, unit: "PIXELS" }; text.textAutoResize = plan.autoResize; @@ -260,6 +263,33 @@ var PW = (() => { calibrateWidth(text, runs[index].w); } } + function applyImages(layer, node) { + if (!("children" in layer)) return; + const layers = layer.children; + const items = orderChildren(node); + for (let index = 0; index < items.length && index < layers.length; index += 1) { + const child = items[index].child; + if (!child) continue; + if (child.image) { + const source = darkMode && child.imageDark ? child.imageDark : child.image; + const image = figma.createImage(figma.base64Decode(source)); + layers[index].fills = [{ type: "IMAGE", imageHash: image.hash, scaleMode: "FILL" }]; + } else { + applyImages(layers[index], child); + } + } + } + function applyHidden(layer, node) { + if (!("children" in layer)) return; + const layers = layer.children; + const items = orderChildren(node); + for (let index = 0; index < items.length && index < layers.length; index += 1) { + const child = items[index].child; + if (!child) continue; + if (child.hidden) layers[index].visible = false; + else applyHidden(layers[index], child); + } + } async function build(node, parent, parentX, parentY, flow, insideComponent = false) { if (node.grow) { const spacer = figma.createFrame(); @@ -297,12 +327,19 @@ var PW = (() => { if (node.component && masters[node.component]) { const instance = masters[node.component].createInstance(); parent.appendChild(instance); - instance.resize(Math.max(node.w, 0.01), Math.max(node.h, 0.01)); + const parentIsAutoLayout = instance.parent && "layoutMode" in instance.parent && instance.parent.layoutMode !== "NONE"; + if ((node.children.some((child) => child.grow) || node.fillWidth) && parentIsAutoLayout) { + instance.layoutSizingHorizontal = "FILL"; + } else if (node.w > 1 && node.h > 1) { + instance.resize(node.w, node.h); + } if (!flow) { instance.x = node.x - parentX; instance.y = node.y - parentY; } await applyInstanceContent(instance, node); + applyHidden(instance, node); + applyImages(instance, node); return instance; } let frame; @@ -317,7 +354,7 @@ var PW = (() => { frame.fills = node.fill ? [solid(node.fill, node.fillToken)] : []; frame.clipsContent = false; if (node.stroke) { - frame.strokes = [solid(node.stroke)]; + frame.strokes = [solid(node.stroke, node.strokeToken)]; frame.strokeWeight = node.strokeWidth; } if (node.radius) frame.cornerRadius = node.radius; @@ -548,7 +585,7 @@ var PW = (() => { const root = await build(data.root, figma.currentPage, 0, 0, false); return root.type === "FRAME" ? await wrapInDeviceFrame(root, parts.join(" \xB7 ")) : root; } - function traceComponent(name, root) { + function traceComponent(id, name, root) { let nodes = 0; let texts = 0; let images = 0; @@ -559,7 +596,7 @@ var PW = (() => { for (const child of node.children || []) walk(child); }; walk(root); - const summary = { name, rootTag: root.tag, nodes, texts, images, boundStyles: boundTextStyleCount }; + const summary = { id: id || null, name, rootTag: root.tag, nodes, texts, images, boundStyles: boundTextStyleCount }; if (root.tag === "image") summary.warning = "flat image \u2014 the capture produced no structured nodes"; importTrace.push(summary); } @@ -581,7 +618,7 @@ var PW = (() => { importTrace = []; await syncFromDocument(data); const framed = await importFramed(data, message.version, Boolean(message.dark), message.tags); - traceComponent(data.root.name || "Screen", data.root); + traceComponent(message.id, data.root.name || "Screen", data.root); flushTrace(); figma.viewport.scrollAndZoomIntoView([framed]); figma.ui.postMessage({ type: "done" }); @@ -608,7 +645,7 @@ var PW = (() => { frame.y = 0; cursor += frame.width + GAP; placed.push(frame); - traceComponent(entry.data.root && entry.data.root.name || "Screen", entry.data.root); + traceComponent(entry.id, entry.data.root && entry.data.root.name || "Screen", entry.data.root); } flushTrace(); figma.viewport.scrollAndZoomIntoView(placed); diff --git a/figma-plugin/code.ts b/figma-plugin/code.ts index 082545c..e31f16c 100644 --- a/figma-plugin/code.ts +++ b/figma-plugin/code.ts @@ -109,9 +109,9 @@ function calibrateWidth(text: TextNode, targetWidth: number): void { async function makeText(run: any, font: any): Promise { const plan = planText(run, font) const text = figma.createText() - // A bound style dictates textDecoration, so it would wipe the underline; underlined text (the link - // button) keeps its raw font and its underline instead of the typography-token binding. - const style = plan.styleName && !plan.underline ? textStyles[plan.styleName] : undefined + // A bound style dictates textDecoration, so it would wipe a decoration; decorated text (an underlined + // link, a struck "was" price) keeps its raw font and its decoration instead of the typography binding. + const style = plan.styleName && !plan.underline && !plan.strikethrough ? textStyles[plan.styleName] : undefined if (style) { await figma.loadFontAsync(style.fontName as FontName) text.fontName = style.fontName as FontName @@ -125,6 +125,8 @@ async function makeText(run: any, font: any): Promise { if (plan.underline) { text.textDecoration = 'UNDERLINE' text.textDecorationOffset = { value: 2, unit: 'PIXELS' } + } else if (plan.strikethrough) { + text.textDecoration = 'STRIKETHROUGH' } // letterSpacing/lineHeight are owned by a text style; writing them detaches an applied style (Figma // reverts the node to raw values), so only set them when the text is unstyled. @@ -187,6 +189,41 @@ async function applyInstanceContent(instance: InstanceNode, node: any): Promise< } } +// An instance normalized to its component's structure carries hidden placeholders for the optional layers +// it lacks (a cart row without the SALE pill / was-price). Walk the instance's layers in the same order +// build created them (orderChildren) and hide those placeholders, so the instance shows only its own content. +// A component groups rows that share a structure but each carry a different photo; override every image +// fill per instance (walking the instance's layers in build order) so each row shows its own image, not +// the master's. +function applyImages(layer: SceneNode, node: any): void { + if (!('children' in layer)) return + const layers = (layer as ChildrenMixin).children as SceneNode[] + const items = orderChildren(node) + for (let index = 0; index < items.length && index < layers.length; index += 1) { + const child = items[index].child + if (!child) continue + if (child.image) { + const source = darkMode && child.imageDark ? child.imageDark : child.image + const image = figma.createImage(figma.base64Decode(source)) + ;(layers[index] as GeometryMixin).fills = [{ type: 'IMAGE', imageHash: image.hash, scaleMode: 'FILL' }] + } else { + applyImages(layers[index], child) + } + } +} + +function applyHidden(layer: SceneNode, node: any): void { + if (!('children' in layer)) return + const layers = (layer as ChildrenMixin).children as SceneNode[] + const items = orderChildren(node) + for (let index = 0; index < items.length && index < layers.length; index += 1) { + const child = items[index].child + if (!child) continue + if (child.hidden) layers[index].visible = false + else applyHidden(layers[index], child) + } +} + async function build(node: any, parent: BaseNode & ChildrenMixin, parentX: number, parentY: number, flow: boolean, insideComponent: boolean = false): Promise { if (node.grow) { const spacer = figma.createFrame() @@ -228,12 +265,22 @@ async function build(node: any, parent: BaseNode & ChildrenMixin, parentX: numbe if (node.component && masters[node.component]) { const instance = masters[node.component].createInstance() parent.appendChild(instance) - instance.resize(Math.max(node.w, 0.01), Math.max(node.h, 0.01)) + // A reflection-path row has no measured size (node.w/h ≈ 0) and fills its parent via a grow child, so + // it takes FILL like the master frame does — resizing it to node.w would collapse it to ~0 and the + // rows would overlap. Only pin an explicit size when the capture actually measured one. + const parentIsAutoLayout = instance.parent && 'layoutMode' in instance.parent && (instance.parent as FrameNode).layoutMode !== 'NONE' + if ((node.children.some((child: any) => child.grow) || node.fillWidth) && parentIsAutoLayout) { + instance.layoutSizingHorizontal = 'FILL' + } else if (node.w > 1 && node.h > 1) { + instance.resize(node.w, node.h) + } if (!flow) { instance.x = node.x - parentX instance.y = node.y - parentY } await applyInstanceContent(instance, node) + applyHidden(instance, node) + applyImages(instance, node) return instance } @@ -249,7 +296,7 @@ async function build(node: any, parent: BaseNode & ChildrenMixin, parentX: numbe frame.fills = node.fill ? [solid(node.fill, node.fillToken)] : [] frame.clipsContent = false if (node.stroke) { - frame.strokes = [solid(node.stroke)] + frame.strokes = [solid(node.stroke, node.strokeToken)] frame.strokeWeight = node.strokeWidth } if (node.radius) frame.cornerRadius = node.radius @@ -508,7 +555,9 @@ async function importFramed(data: any, version: any, dark: boolean, tags?: strin // Always-on debug path: every import records a compact summary of what it received (the captured IR), // flushed to the serve so an import can be diagnosed by reading http://localhost:8787/debug.json. // A rootTag of "image" means the capture produced no structured nodes (it fell back to a flat image). -function traceComponent(name: string, root: any): void { +// id is the stable catalog id (e.g. "swiftui-button"); name/title collides across the SwiftUI/UIKit +// twins, so a diff against the captured IR must key on id, not name. +function traceComponent(id: string, name: string, root: any): void { let nodes = 0 let texts = 0 let images = 0 @@ -519,7 +568,7 @@ function traceComponent(name: string, root: any): void { for (const child of node.children || []) walk(child) } walk(root) - const summary: any = { name, rootTag: root.tag, nodes, texts, images, boundStyles: boundTextStyleCount } + const summary: any = { id: id || null, name, rootTag: root.tag, nodes, texts, images, boundStyles: boundTextStyleCount } if (root.tag === 'image') summary.warning = 'flat image — the capture produced no structured nodes' importTrace.push(summary) } @@ -543,7 +592,7 @@ figma.ui.onmessage = async (message: any) => { importTrace = [] await syncFromDocument(data) const framed = await importFramed(data, message.version, Boolean(message.dark), message.tags) - traceComponent(data.root.name || 'Screen', data.root) + traceComponent(message.id, data.root.name || 'Screen', data.root) flushTrace() figma.viewport.scrollAndZoomIntoView([framed]) figma.ui.postMessage({ type: 'done' }) @@ -570,7 +619,7 @@ figma.ui.onmessage = async (message: any) => { frame.y = 0 cursor += frame.width + GAP placed.push(frame) - traceComponent((entry.data.root && entry.data.root.name) || 'Screen', entry.data.root) + traceComponent(entry.id, (entry.data.root && entry.data.root.name) || 'Screen', entry.data.root) } flushTrace() figma.viewport.scrollAndZoomIntoView(placed) diff --git a/figma-plugin/debug.mjs b/figma-plugin/debug.mjs new file mode 100644 index 0000000..71f9889 --- /dev/null +++ b/figma-plugin/debug.mjs @@ -0,0 +1,75 @@ +// Pull the live import trace the plugin flushed to /debug.json (populated when you Import in Figma) and +// diff each imported component against the captured IR on the serve — keyed on the stable `id`, so the +// SwiftUI/UIKit twins ("Button", "StateView", …) that share a title never collide. +// +// npm run debug # diff the last import against the captures +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SERVE = process.env.PINWHEEL_SERVE || 'http://localhost:8787' +const here = dirname(fileURLToPath(import.meta.url)) +const getJSON = async (path) => (await fetch(`${SERVE}${path}`)).json() + +const trace = await getJSON('/debug.json') +const { items } = await getJSON('/manifest.json') +const fileById = new Map(items.map((item) => [item.id, item.file])) + +const luminance = (c) => (c ? 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b : null) + +function countIR(doc) { + let nodes = 0, texts = 0, images = 0 + const walk = (node) => { + nodes += 1 + if (node.texts) texts += node.texts.length + if (node.image) images += 1 + for (const child of node.children || []) walk(child) + } + walk(doc.root) + const root = doc.root + // Visual health the node counts can't see: a screen must have a background, and a light capture's + // background must actually be light + tokenized (a dark, untokenized root = the sweep captured the + // wrong appearance and it's baked, so it won't adapt). + const rootFill = root.fill || null + const issues = [] + if (!rootFill && !root.fillToken) issues.push('NO-BG') + else if (rootFill && luminance(rootFill) < 0.3 && !root.fillToken) issues.push('DARK-BG(untokenized)') + return { nodes, texts, images, issues } +} + +const idsPresent = trace.every((entry) => entry.id) +if (!idsPresent) { + console.log('note: this trace predates the id-carrying plugin — pairing by import order (re-import to key by id).\n') +} + +const rows = trace.map((entry, index) => ({ + entry, + id: entry.id || items[index]?.id || `#${index}`, +})) + +const pad = (value, width) => String(value).padEnd(width) +console.log(`${pad('id', 28)}${pad('root', 8)}${pad('nodes i/c', 12)}${pad('text i/c', 11)}${pad('img i/c', 10)}verdict`) +console.log('-'.repeat(82)) + +const mismatches = [] +for (const { entry, id } of rows) { + const file = fileById.get(id) + const cap = file ? countIR((await getJSON(`/${file}`)).document) : null + let verdict = 'ok' + if (!cap) verdict = 'NO CAPTURE' + else if (entry.nodes !== cap.nodes || entry.texts !== cap.texts || entry.images !== cap.images) verdict = 'DIFF' + if (entry.rootTag === 'image') verdict = 'FLAT-IMAGE' + if (cap && cap.issues.length) verdict = cap.issues.join(',') + if (verdict !== 'ok') mismatches.push(id) + const ic = (a, b) => `${a}/${b ?? '?'}` + console.log( + pad(id, 28) + pad(entry.rootTag, 8) + + pad(ic(entry.nodes, cap?.nodes), 12) + pad(ic(entry.texts, cap?.texts), 11) + + pad(ic(entry.images, cap?.images), 10) + verdict + ) +} + +console.log() +console.log(mismatches.length + ? `mismatches: ${mismatches.join(', ')}` + : `all ${rows.length} components imported exactly as captured (i=imported, c=captured)`) diff --git a/figma-plugin/package.json b/figma-plugin/package.json index 291e5b6..a09ab23 100644 --- a/figma-plugin/package.json +++ b/figma-plugin/package.json @@ -7,7 +7,8 @@ "build": "esbuild code.ts --bundle --format=iife --global-name=PW --target=es2017 --outfile=code.js", "serve": "node serve.mjs", "test": "npm run build && node --test test/*.test.mjs", - "verify": "node verify.mjs" + "verify": "node verify.mjs", + "debug": "node debug.mjs" }, "devDependencies": { "@figma/plugin-typings": "^1.130.0", @@ -15,4 +16,4 @@ "quickjs-emscripten": "^0.32.0", "typescript": "~5.6.0" } -} +} \ No newline at end of file diff --git a/figma-plugin/plan.ts b/figma-plugin/plan.ts index b8bae53..e67b357 100644 --- a/figma-plugin/plan.ts +++ b/figma-plugin/plan.ts @@ -65,6 +65,7 @@ export interface TextPlan { fontRequest: { family: string; weight: number; italic: boolean } fill: { color: { r: number; g: number; b: number; a: number }; token?: string } | null underline: boolean + strikethrough: boolean letterSpacing: number | null autoResize: 'HEIGHT' | 'WIDTH_AND_HEIGHT' width: number | null @@ -91,6 +92,7 @@ export function planText(run: any, font: any): TextPlan { fontRequest: { family: font.family, weight: font.weight, italic: font.italic }, fill: font.color ? { color: font.color, token: font.colorToken } : null, underline: Boolean(font.underline), + strikethrough: Boolean(font.strikethrough), letterSpacing: typeof font.letterSpacing === 'number' ? font.letterSpacing : null, autoResize: multiline ? 'HEIGHT' : 'WIDTH_AND_HEIGHT', width: multiline ? run.w : null, diff --git a/figma-plugin/render_ir.py b/figma-plugin/render_ir.py new file mode 100644 index 0000000..09ef6bd --- /dev/null +++ b/figma-plugin/render_ir.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Render a captured Figma IR document back to a PNG so it can be diffed against the real iOS render. +Handles absolutely-positioned nodes (the containment / root=frame captures). Auto-layout children +(root=screen) don't carry absolute child coords, so this is a best-effort approximation for those.""" +import json, sys, base64, io +from PIL import Image, ImageDraw, ImageFont + +def rgba(c, default=(0, 0, 0, 255)): + if not c: + return default + return (int(c['r'] * 255), int(c['g'] * 255), int(c['b'] * 255), int(c.get('a', 1) * 255)) + +def font(size): + for path in ["/System/Library/Fonts/SFNSRounded.ttf", "/System/Library/Fonts/SFNS.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf"]: + try: + return ImageFont.truetype(path, max(int(size), 6)) + except Exception: + continue + return ImageFont.load_default() + +def render(doc, scale=2): + W, H = int(doc['width'] * scale), int(doc['height'] * scale) + img = Image.new('RGBA', (W, H), (255, 255, 255, 255)) + draw = ImageDraw.Draw(img) + + def walk(node): + # Containment-path nodes carry absolute canvas coordinates, so draw each at its own x/y. + x, y = node['x'] * scale, node['y'] * scale + w, h = node['w'] * scale, node['h'] * scale + if node.get('fill'): + r = node.get('radius', 0) * scale + draw.rounded_rectangle([x, y, x + w, y + h], radius=min(r, w / 2, h / 2), fill=rgba(node['fill'])) + if node.get('image'): + try: + photo = Image.open(io.BytesIO(base64.b64decode(node['image']))).convert('RGBA') + photo = photo.resize((max(int(w), 1), max(int(h), 1))) + img.paste(photo, (int(x), int(y)), photo) + except Exception: + draw.rectangle([x, y, x + w, y + h], outline=(255, 0, 0, 255)) + for run in (node.get('texts') or []): + f = node.get('font') or {} + size = f.get('size', 15) * scale + color = rgba(f.get('color'), (20, 20, 20, 255)) + tx, ty = run['x'] * scale, run['y'] * scale + fnt = font(size) + draw.text((tx, ty), run['text'], fill=color, font=fnt) + tw = draw.textlength(run['text'], font=fnt) + if f.get('strikethrough'): + draw.line([tx, ty + size * 0.55, tx + tw, ty + size * 0.55], fill=color, width=max(int(scale), 1)) + if f.get('underline'): + draw.line([tx, ty + size * 1.05, tx + tw, ty + size * 1.05], fill=color, width=max(int(scale), 1)) + for child in node.get('children', []): + walk(child) + + root = doc['root'] + if root.get('fill'): + draw.rectangle([0, 0, W, H], fill=rgba(root['fill'])) + for child in root.get('children', []): + walk(child) + return img + +if __name__ == '__main__': + path, out = sys.argv[1], sys.argv[2] + doc = json.load(open(path))['document'] + render(doc).save(out) + print('wrote', out, doc['width'], 'x', doc['height'], 'root=', doc['root']['tag']) diff --git a/figma-plugin/test/figma-mock.mjs b/figma-plugin/test/figma-mock.mjs index 0a0a9b6..fe6f397 100644 --- a/figma-plugin/test/figma-mock.mjs +++ b/figma-plugin/test/figma-mock.mjs @@ -77,11 +77,11 @@ export function loadPlugin() { createRectangle: () => node('RECT', { fills: [] }), createNodeFromSvg: () => node('SVG'), currentPage: node('PAGE'), - createComponent: () => node('COMPONENT', { createInstance() { const clone = node('INSTANCE'); clone.children = this.children.map((child) => ({ ...child })); return clone } }), + createComponent: () => node('COMPONENT', { createInstance() { const deep = (n) => ({ ...n, children: (n.children || []).map(deep) }); const clone = node('INSTANCE'); clone.children = this.children.map(deep); return clone } }), createTextStyle: () => node('TEXTSTYLE'), getLocalTextStylesAsync: async () => [], - createImage: () => ({ hash: 'image' }), - base64Decode: () => new Uint8Array(), + createImage: (data) => ({ hash: `img-${data}` }), + base64Decode: (source) => source, loadFontAsync: async (fontName) => { loadedFonts.add(fontKey(fontName)) }, variables: { getLocalVariableCollectionsAsync: async () => [], diff --git a/figma-plugin/test/instance-hidden.test.mjs b/figma-plugin/test/instance-hidden.test.mjs new file mode 100644 index 0000000..1c5b857 --- /dev/null +++ b/figma-plugin/test/instance-hidden.test.mjs @@ -0,0 +1,27 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { loadPlugin, rootParent } from './figma-mock.mjs' + +const LAYOUT = { mode: 'row', columnGap: 0, rowGap: 0, pad: [0, 0, 0, 0], justify: 'flex-start', align: 'flex-start', primarySizing: 'FIXED', counterSizing: 'FIXED' } +const frame = (children, extra = {}) => ({ tag: 'frame', x: 0, y: 0, w: 200, h: 40, ordered: true, layout: LAYOUT, children, ...extra }) +const text = (value) => ({ tag: 'text', x: 0, y: 0, w: 40, h: 20, font: { family: 'SF', size: 14, weight: 400, color: { r: 0, g: 0, b: 0, a: 1 }, underline: false }, texts: [{ text: value, x: 0, y: 0, w: 40, h: 20 }], children: [] }) +const pill = (hidden) => frame([text('SALE')], hidden ? { hidden: true } : {}) +// A cart row: a title with an optional SALE pill. The instance (no-sale) carries the pill as a hidden placeholder. +const row = (saleHidden) => frame([frame([text('Title'), pill(saleHidden)])], { component: 'row' }) + +// A normalized instance imports with its optional layers hidden, while the master keeps them visible. +test('a hidden placeholder layer is hidden on the instance but not the master', async () => { + const { build, created } = loadPlugin() + const doc = frame([row(false), row(true)], { component: undefined }) + await build(doc, rootParent(), 0, 0, false) + + const master = created.find((node) => node.type === 'COMPONENT') + const instance = created.find((node) => node.type === 'INSTANCE') + assert.ok(master && instance, 'the first row is the master component, the second an instance') + + // structure: row → titleHStack → [Title text, SALE pill] + const masterPill = master.children[0].children[1] + const instancePill = instance.children[0].children[1] + assert.notEqual(masterPill.visible, false, 'the master keeps the SALE pill visible') + assert.equal(instancePill.visible, false, 'the instance hides its SALE pill placeholder') +}) diff --git a/figma-plugin/test/instance-image.test.mjs b/figma-plugin/test/instance-image.test.mjs new file mode 100644 index 0000000..5d62fc0 --- /dev/null +++ b/figma-plugin/test/instance-image.test.mjs @@ -0,0 +1,22 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { loadPlugin, rootParent } from './figma-mock.mjs' + +const LAYOUT = { mode: 'row', columnGap: 0, rowGap: 0, pad: [0, 0, 0, 0], justify: 'flex-start', align: 'flex-start', primarySizing: 'FIXED', counterSizing: 'FIXED' } +const frame = (children, extra = {}) => ({ tag: 'frame', x: 0, y: 0, w: 200, h: 80, ordered: true, layout: LAYOUT, children, ...extra }) +const text = (value) => ({ tag: 'text', x: 0, y: 0, w: 40, h: 20, font: { family: 'SF', size: 14, weight: 400, color: { r: 0, g: 0, b: 0, a: 1 }, underline: false }, texts: [{ text: value, x: 0, y: 0, w: 40, h: 20 }], children: [] }) +const image = (bytes) => ({ tag: 'image', x: 0, y: 0, w: 64, h: 64, image: bytes, children: [] }) +// A gallery row: a per-row photo beside a title. Both rows are one component; the image swaps per instance. +const row = (bytes, title) => frame([image(bytes), text(title)], { component: 'row' }) + +// An instance's image fill is overridden to its own photo, not left showing the master's. +test('a component instance overrides its image fill per row', async () => { + const { build, created } = loadPlugin() + await build(frame([row('MASTERIMG', 'A'), row('INSTIMG', 'B')]), rootParent(), 0, 0, false) + + const instance = created.find((node) => node.type === 'INSTANCE') + const imageLayer = (instance.children || []).find((layer) => layer.fills && layer.fills[0] && layer.fills[0].type === 'IMAGE') + assert.ok(imageLayer, 'the instance has an image layer') + assert.equal(imageLayer.fills[0].imageHash, 'img-INSTIMG', + "the instance shows its own photo, not the master's — an instance can swap an image fill") +}) diff --git a/figma-plugin/test/instance-sizing.test.mjs b/figma-plugin/test/instance-sizing.test.mjs new file mode 100644 index 0000000..934f3c8 --- /dev/null +++ b/figma-plugin/test/instance-sizing.test.mjs @@ -0,0 +1,24 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { loadPlugin, rootParent } from './figma-mock.mjs' + +// A reflection-captured row (Cart's cards) has no measured size — w/h ≈ 0 — and fills its parent via a +// grow spacer; the master frame gets layoutSizingHorizontal = FILL. The repeated sale rows import as +// component instances, and an instance force-resized to node.w (≈0) collapses to nothing, so the rows +// overlap instead of stacking full-width. An instance that fills width must take FILL like its master. +test('a width-filling component instance imports at FILL, not collapsed to zero width', async () => { + const cart = JSON.parse(readFileSync(fileURLToPath(new URL('../catalog-figma-cart.json', import.meta.url)))) + const { build, created } = loadPlugin() + await build(cart.document.root, rootParent(), 0, 0, false) + + const instances = created.filter((node) => node.type === 'INSTANCE') + assert.ok(instances.length >= 2, `Cart's repeated sale rows should import as instances (got ${instances.length})`) + for (const instance of instances) { + assert.equal(instance.layoutSizingHorizontal, 'FILL', + 'a 0-width, spacer-filled row instance must fill its parent width; resizing it to node.w collapses it and the rows overlap') + assert.ok(instance.width > 1, + `the instance must not collapse to ~0 width (got ${instance.width})`) + } +}) diff --git a/figma-plugin/ui.html b/figma-plugin/ui.html index d3b56a8..aea456b 100644 --- a/figma-plugin/ui.html +++ b/figma-plugin/ui.html @@ -5,6 +5,11 @@ button.secondary { background: #e7e5e4; color: #1c1917; } #status { margin-top: 6px; color: #57534e; } label.dark { display: flex; align-items: center; gap: 6px; margin: 6px 0; color: #1c1917; cursor: pointer; } + #filters { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; } + #filters:empty { display: none; } + .pill { width: auto; margin: 0; padding: 4px 10px; border: 1px solid #d6d3d1; border-radius: 999px; + background: #fff; color: #57534e; font-weight: 500; font-size: 11px; } + .pill.active { background: #14313b; border-color: #14313b; color: #fff; } #list { margin-top: 8px; } .section { margin: 10px 0 2px; font-weight: 700; color: #57534e; text-transform: uppercase; font-size: 10px; letter-spacing: 0.04em; } @@ -16,6 +21,7 @@ +
@@ -23,15 +29,22 @@ const status = document.getElementById('status') const SERVE = 'http://localhost:8787' + let catalogItems = [] + let activeTag = null // null = All + const fetchJSON = async (path) => { const response = await fetch(SERVE + path, { cache: 'no-store' }) return response.json() } const isDark = () => document.getElementById('dark').checked - const importDocument = (data, version, tags) => parent.postMessage({ pluginMessage: { type: 'import', data, dark: isDark(), version, tags } }, '*') + const importDocument = (id, data, version, tags) => parent.postMessage({ pluginMessage: { type: 'import', id, data, dark: isDark(), version, tags } }, '*') + + // Items matching the active tag filter (all items when no tag is selected). + const filteredItems = () => activeTag ? catalogItems.filter((item) => (item.tags || []).includes(activeTag)) : catalogItems const catalogButton = document.getElementById('catalog') + const importAllButton = document.getElementById('importAll') async function loadCatalog({ auto = false } = {}) { if (!auto) status.textContent = 'loading catalog…' @@ -41,8 +54,9 @@ if (!auto) status.textContent = 'no components on the serve yet — run the capture sweep' return } - renderCatalog(items) - status.textContent = items.length + ' components — pick one to import' + catalogItems = items + renderFilters() + render() catalogButton.textContent = 'Reload catalog' } catch (error) { if (!auto) status.textContent = 'catalog failed: ' + error.message + ' (is the serve script running?)' @@ -50,9 +64,33 @@ } catalogButton.onclick = () => loadCatalog() - loadCatalog({ auto: true }) + // Distinct tags across the catalog, plus an "All" reset — mirrors the app's filter-pill bar. + function renderFilters() { + const filters = document.getElementById('filters') + filters.innerHTML = '' + const tags = [...new Set(catalogItems.flatMap((item) => item.tags || []))].sort() + if (!tags.length) return + if (activeTag && !tags.includes(activeTag)) activeTag = null + for (const label of ['All', ...tags]) { + const tag = label === 'All' ? null : label + const pill = document.createElement('button') + pill.className = 'pill' + (activeTag === tag ? ' active' : '') + pill.textContent = label + pill.onclick = () => { activeTag = tag; renderFilters(); render() } + filters.appendChild(pill) + } + } + + function render() { + const items = filteredItems() + renderCatalog(items) + const scope = activeTag ? ' · ' + activeTag : '' + importAllButton.textContent = activeTag ? 'Import all ' + activeTag : 'Import all' + status.textContent = items.length + ' component' + (items.length === 1 ? '' : 's') + scope + ' — pick one to import' + } + function renderCatalog(items) { const list = document.getElementById('list') list.innerHTML = '' @@ -74,39 +112,40 @@ tags.textContent = [version, (item.tags || []).join(', ')].filter(Boolean).join(' · ') row.appendChild(title) row.appendChild(tags) - row.onclick = () => importCatalogItem(item.file, item.title, item.version, item.tags) + row.onclick = () => importCatalogItem(item.id, item.file, item.title, item.version, item.tags) list.appendChild(row) } } } - async function importCatalogItem(file, title, version, tags) { + async function importCatalogItem(id, file, title, version, tags) { status.textContent = 'importing ' + title + '…' try { const entry = await fetchJSON('/' + file) if (!entry || !entry.document) throw new Error('no document in ' + file) - importDocument(entry.document, version != null ? version : entry.version, tags || entry.tags) + importDocument(id, entry.document, version != null ? version : entry.version, tags || entry.tags) } catch (error) { status.textContent = 'import failed: ' + error.message } } - document.getElementById('importAll').onclick = async () => { - status.textContent = 'loading all components…' + importAllButton.onclick = async () => { + const items = filteredItems() + if (!items.length) { + status.textContent = catalogItems.length ? 'no components match ' + activeTag : 'no components on the serve yet — run the capture sweep' + return + } + const scope = activeTag ? ' ' + activeTag : '' + status.textContent = 'loading ' + items.length + scope + ' component' + (items.length === 1 ? '' : 's') + '…' try { - const { items } = await fetchJSON('/manifest.json') - if (!items || !items.length) { - status.textContent = 'no components on the serve yet — run the capture sweep' - return - } const entries = [] for (const item of items) { const entry = await fetchJSON('/' + item.file) if (entry && entry.document) { - entries.push({ data: entry.document, version: item.version != null ? item.version : entry.version, tags: item.tags || entry.tags }) + entries.push({ id: item.id, data: entry.document, version: item.version != null ? item.version : entry.version, tags: item.tags || entry.tags }) } } - status.textContent = 'importing ' + entries.length + ' components (' + (isDark() ? 'dark' : 'light') + ')…' + status.textContent = 'importing ' + entries.length + scope + ' component' + (entries.length === 1 ? '' : 's') + ' (' + (isDark() ? 'dark' : 'light') + ')…' parent.postMessage({ pluginMessage: { type: 'importAll', entries, dark: isDark() } }, '*') } catch (error) { status.textContent = 'import all failed: ' + error.message + ' (is the serve script running?)'