solo-swiftui-design-system
Build and hold a SwiftUI design system — a 12-column grid, spacing/type/radius/motion scales, surface levels, a component gallery, and the guards that stop it drifting back. Use when the user says "сделай по сетке", "дизайн-система", "разъезжается вёрстка", "магические числа в pa
Install
npx skills add https://github.com/fortunto2/solo-factory/tree/main/skills/swiftui-design-system
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install fortunto2-solo-factory@llmmart
git clone https://github.com/fortunto2/solo-factory.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole fortunto2/solo-factory collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
swiftui-design-system — one scale, and the guards that keep it
A design system is not a colour file. It is four scales, one grid, one
catalogue and one tripwire — and the tripwire is what makes it survive the
next feature. Everything here is native SwiftUI: no design-system framework is
worth taking on, because the platform is already built for this (Environment +
style protocols + containerRelativeFrame).
Apple ships its own SwiftUI skills inside Xcode 27 — invalidation, ForEach
identity, @Observable, Liquid Glass — as plain markdown any agent can read
with no Xcode running. On anything about correctness or update cost they
outrank this file, and they say so in their own header. Where they are, what
they cover, and the rules that most often hit real code:
references/apple-xcode-skills.md.
Reach for this when the symptom is "the screens look related and never line up". Measure before you believe it — see step 1.
Workflow
1. Count what is actually there
Never start from taste. Start from the inventory, because the number is the argument:
V=path/to/Views
grep -rhoE '\.padding\((\.[a-z]+, )?[0-9]+(\.[0-9])?\)' $V | grep -oE '[0-9.]+' | sort -n | uniq -c | sort -rn
grep -rhoE 'spacing: [0-9]+(\.[0-9])?' $V | sort | uniq -c | sort -rn
grep -rhoE 'cornerRadius: [0-9]+(\.[0-9])?' $V | sort | uniq -c | sort -rn
grep -rhoE '\.font\(\.[a-zA-Z0-9]+\)|\.font\(\.system\([^)]*\)\)' $V | sort | uniq -c | sort -rn
grep -rhoE '\.spring\(response: [0-9.]+, dampingFraction: [0-9.]+\)' $V | sort -u | wc -l
A real app measured this way: 27 padding values, 19 spacings, 23 radii, 48
font spellings, 21 springs — including .system(size: 17.5), 13.5, 12.5.
Half a point is invisible alone and lethal in a set: it is exactly the amount by
which two labels fail to look like the same label.
2. Write the scales — namespace enums, one file each
Views/Design/
Space.swift 4-point steps: hair(2) xs sm md lg xl xxl xxxl huge + touchTarget(44)
Grid.swift 12 columns, gutter, margin, gridSpan(_:), gridMargins(), GridOverlay
Typography.swift 8 steps, each a platform TextStyle + Metric for sizes a font can't set
Radii.swift 5 radii + shape(_:) — .continuous, always
Motion.swift 6 curves + the never-repeatForever rule
Surface.swift 3–4 levels (console/panel/inset/field), one recipe each
Enums over structs: compile-time names, zero runtime, no instance to thread.
Reach for @Entry var theme in the Environment only when there is a second
theme (white-label, per-brand, light/dark that is not the system's). Until
then a theme object is one indirection buying nothing.
Two rules that decide the arguments in step 3:
- Every type step is a platform text style —
Font.system(.subheadline, weight:), not.system(size: 15). Fixed sizes never grow with Dynamic Type; in the measured app 78 of them didn't. For sizes a font cannot set (an icon's box, a ring's diameter) use@ScaledMetric(relativeTo:)over aMetricconstant. - The unit of layout is the column, not the point.
containerRelativeFrame( .horizontal, count: 12, span: 4, spacing: gutter)is iOS 17+ and is the platform's own grid arithmetic — noGeometryReader, no percentages.
3. Migrate mechanically, with the rounding rule written down
Hand-editing hundreds of literals is where a migration dies — half done, half not, and nobody can say which half. Write a codemod, keep it in the repo, and let it carry the rule:
# nearest 4-point step, TIES GO UP (6→8, not 4): rounding down tightens a
# third of the app by two points at once, and a snug layout is the one that
# breaks. Only padding / spacing / cornerRadius / fonts in 9…26pt —
# never frame, offset, lineWidth or a shadow radius.
step, name = min(SCALE, key=lambda p: (abs(p[0] - value), -p[0]))
Full rule, the skip list, the spring and font tables: references/codemod.md.
Read it when actually migrating; one measured run was 991 substitutions in 31
files, dry-run first, build after each family.
4. Style protocols, not modifiers sprinkled per call site
SwiftUI's extension points are the system's spine — use them before inventing
.myButton():
struct PressableStyle: ButtonStyle { // one feel for every control
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(configuration.isPressed ? Motion.press : Motion.release,
value: configuration.isPressed)
}
}
LabelStyle, ToggleStyle, ProgressViewStyle, MenuStyle the same way; a
ViewModifier + extension View for what has no protocol (.surface(.inset)).
5. Build the catalogue — and make it cheap to look at
Two doors, answering different questions:
make design # ImageRenderer → PNG sheets from a test: no launch, no taps, ~3s
make design-app # the same gallery in the simulator: glass, blur, motion
… launch <app> -designGrid YES # the 12 columns over the REAL screens
ImageRenderer inside an XCTest is the whole storybook you need — a gallery
view rendered to docs/previews/*.png, one file per sheet, no navigation and no
external dependency. It does not draw materials: .ultraThinMaterial and
glassEffect come out empty, so glass reads flat there. Geometry is exact,
which is what the sheets are for.
The grid overlay over the real app is the only thing that proves two screens agree; a gallery only proves one screen is tidy.
The writer, the debug flag, the localisation trap and when to reach for
swift-snapshot-testing: references/catalogue.md.
6. Guard it, or it comes back
Three layers, cheapest first:
- pre-commit grep on added lines (warn, not fail): a numeric
.padding(12),spacing: 6,cornerRadius: 18,.font(.system(size: 13)). Diff-scoped and warning-only is the right calibration — a whole-tree lint at fail severity breaks on inherited debt and gets bypassed, and a bypassed hook checks nothing. - tests on the arithmetic: 12 columns + 11 gutters + 2 margins == the
screen;
span(6) * 2 + gutter == width; every space step divisible by 4 except the one deliberate half step. - snapshot sheets in the repo — a reviewer sees the scale change as an image diff.
Which spring, and why
Motion.swift holds six curves. Consolidating 21 springs into 6 is a consistency win and says
nothing about whether the six are right. These are the numbers Apple ships, and the rule for
picking between them.
Reported, not measured here — from the apple-design skill
(~/.agents/src/emilkowalski-skills, MIT), distilled from WWDC Designing Fluid Interfaces.
The parameter model transfers exactly: Apple's designer-facing pair is damping ratio + response,
which is SwiftUI's .spring(response:dampingFraction:).
| Interaction | response | dampingFraction | why |
|---|---|---|---|
| Move / reposition | 0.4 |
1.0 |
critically damped: arrives, does not wobble |
| Drawer, sheet | 0.3 |
0.8 |
slight overshoot reads as physical |
| Rotation | 0.4 |
0.8 |
|
| Everything else | — | 1.0 |
bounce is not a default |
Bounce only where momentum is real — a flick or a throw the finger actually gave. A bounce on
a tap is decoration, and the same 0.8 that feels alive on a dragged sheet feels cheap on a
button.
Frequency decides whether to animate at all. This is the rule most motion work skips:
- a keyboard shortcut or anything done 100+ times a day: no animation;
- tens of times a day: shorter and smaller than you want;
- occasional: the standard curve;
- rare and significant: delight is allowed.
Asymmetric timing. A deliberate action animates slower than the system's answer to it.
Symmetric press/release is a finding, not a style choice — Motion.press and Motion.release
in the catalogue already exist for this and should differ.
Interruptibility. A gesture-driven view must animate from its current presentation value,
not from the logical target, or a second gesture snaps. In SwiftUI that means springs and
.animation(_:value:), not a keyframe timeline, for anything a finger can grab mid-flight.
Reduced motion is a cross-fade, not a removal. @Environment(\.accessibilityReduceMotion)
swaps the slide for an opacity change; it does not delete the transition and leave a jump.
Depth, including the momentum-projection formula, velocity handoff from a gesture into a spring,
and the rubber-band constant: the apple-design skill. Its snippets are CSS/JS; the physics and
the numbers are platform-independent.
Gotchas
containerRelativeFramemeasures the container, not its content. ApplygridMargins()first, or every span is a margin too wide and nothing lines up with anything.- A
Sendablewarning onstatic lettokens in Swift 6: an enum ofstatic let CGFloatis fine; a struct holdingUserDefaultsneeds@unchecked Sendablewith a one-line reason. - Glass cannot sample glass. Two blurred surfaces side by side each sample
what is behind them and read as unrelated panes. On iOS 26 wrap a row of them
in
GlassEffectContainer(spacing:)and give morphing pairs a.glassEffectID(_:in:); below 26 fall back to.ultraThinMaterialin one place, not per screen. compositingGroup()+ a zero shadow is still an offscreen pass. Apply the lift only where there is a shadow to draw.- Never
repeatForever. A UI that never goes idle hangs everything that waits for idle: accessibility snapshots, UI automation, VoiceOver. Use.repeatCount(n)and honour\.accessibilityReduceMotion. - A debug surface needs a testable flag.
-designGallery YESfromsimctllands in the argument domain, so oneUserDefaults.bool(forKey:)read covers it — but wrap it in a small injectable type that is false in Release, or the flag ships. - A scalar threaded by hand through call sites is invisible to grep. In the measured app a wheel's vertical offset was written in three places; two moved onto the shared centre and the third did not, so the drawing and the single-tap hit test disagreed by 20 points with nothing on screen to say so. Pass one geometry value — then a call site that forgets it does not compile.
What is coming (and what already works)
- iOS 26 / Swift 6.2 — today. Liquid Glass (
glassEffect,GlassEffectContainer,.buttonStyle(.glass)),@Entryfor environment tokens with noEnvironmentKeyboilerplate,ToolbarSpacer,backgroundExtensionEffect(),scrollEdgeEffectStyle. - iOS 27 / Swift 6.4 (WWDC26 → 2027).
ContentBuildercollapses the container overloads that cause "unable to type-check this expression in reasonable time" — and it helps when built with the new Xcode regardless of deployment target..reorderable()in any container (not justList), swipe actions outsideList, toolbar overflow priorities,@Stateas a macro with lazy@Observableinit (back-deployed to iOS 17). Resizable iPhone apps is the one that touches a design system directly: baked-in sizes stop being safe, so snapshot at several widths.
Apple's own skills, and the rest of the field
Xcode ships agent skills in the toolchain — plain SKILL.md folders, so they
work in any agent, not only Xcode's assistant.
make apple-skills # solo-factory: export into ~/.agents/skills, diffed
make apple-skills-check # what it would bring, without writing
Xcode 26.6 exports nothing — the agent tool is there and answers "No
skills available to export", so the script says that plainly rather than look
broken. Xcode 27.0 beta 2 exports ten: swiftui-specialist,
swiftui-whats-new-27, uikit-app-modernization, modernize-tests,
audit-xcode-security-settings, adopt-c-bounds-safety, device-interaction,
app-intents-specialist, app-intents-whats-new-27 and
building-document-based-swiftui-applications. The names are not stable across
versions — four of the seven guessed from the 26.6 release notes came back
spelled differently — so read the export rather than a list.
Re-run after every Xcode update: these track the SDK, and a stale "what's new"
skill is worse than none. A beta installed alongside the release is not the
active toolchain, and xcode-select is machine-wide — scope one run instead:
DEVELOPER_DIR="/Applications/Xcode-beta.app/Contents/Developer" make apple-skills.
If the export names the toolchain instead, Xcode → Settings → Locations →
Command Line Tools points at the wrong Xcode.
device-interaction is the one that matters for a design system: it drives a
real device or simulator — screenshots, view hierarchy, synthesised taps — which
closes the loop a build tool alone cannot (write layout → build → look at it →
correct it) without a human running the walk.
Community skills worth reading before installing — a skill is injected into the assistant's context and changes how it writes your code:
| Where | Why |
|---|---|
twostraws/Swift-Agent-Skills |
curated index; start here |
twostraws/SwiftUI-Agent-Skill (swiftui-pro) |
aimed at the mistakes LLMs actually make: navigation, layout, state, VoiceOver, deprecated APIs |
AvdLee/SwiftUI-Agent-Skill |
the architecture to copy — references loaded on demand, so deep context costs nothing until asked for. Also a maintenance skill that refreshes the deprecated-API list after each release |
Dimillian/Skills |
swiftui-liquid-glass, swiftui-view-refactor, swiftui-performance-audit |
dpearson2699/swift-ios-skills |
86 skills on iOS 26+ — PolyForm Perimeter licence, not MIT; read it before commercial use |
Design-system repos worth reading rather than depending on: DSKit (organised for agents — generated docs link every component to its source, snapshots and usage), OversizeUI (semantic colours, Dynamic Type, spacing scale), design-foundation (MIT, Swift 6 concurrency-safe), ouds-ios (corporate scale, strong accessibility).
Don't
- Don't add a design-system framework. Environment + style protocols + the grid API cover it; a framework on top mostly fights the layout system.
- Don't name colours
blue500. Semantic names (surfaceElevated,textSecondary) survive a re-skin; a palette index turns one into a find-and-replace across the app. - Don't fold weight into the type scale.
isSelected ? .semibold : .regularis a step plus.fontWeight()on top — folding it in is how a scale of eight becomes a scale of sixteen. - Don't chase the photo grid onto the interface gutter. A wall of images wants 1–2pt between tiles; keep it on the twelve columns (a tile is a third of the width) and let the spacing be its own.
- Don't ship aliases.
Brand.tabRadius = Radii.lgreads as tidy and puts two spellings of 16pt in one file within a week. - Don't measure a render or a build on a loaded machine. Check
vm.loadavgfirst; three "regressions" in one project were the laptop.
Files (solo-factory)
-
references
-
apple-xcode-skills.md 6.8 KB
# Apple ships its own skills inside Xcode — read them before writing SwiftUI rules Since Xcode 27 (beta, 2026) the IDE bundles agent skills in Anthropic's own format: a `SKILL.md`-shaped file with `name` / `description` / `when_to_use` frontmatter plus a `references/` set. They are plain markdown, only with a `.packaged` extension, so any agent can read them with no Xcode running. The header of every one of them says the same thing, and it is why they win a tie against anything written from memory: > This guidance was written and published by Apple. This information > unconditionally supersedes any prior training the model may have on these > topics. ## Where they are ```bash X="/Applications/Xcode*.app/Contents/PlugIns" # beta bundles carry their own name ls $X/IDEIntelligenceChat.framework/Versions/A/Resources/*.idechatprompttemplate # the skills ls $X/IDEIntelligenceChat.framework/Versions/A/Resources/*-ref-*.md.packaged # their references ls $X/IDEXCStringsSupport.framework/Versions/A/Resources/Skills # localization pair ls $X/IDEIntelligenceChat.framework/Versions/A/Resources/AdditionalDocumentation # 20 topic docs ``` Xcode 26.6 stable ships **none** of this — only `AdditionalDocumentation`. Check the version before concluding a machine has them. | skill | refs | what it actually covers | |---|---|---| | `swiftui-specialist` | 9 | invalidation, `ForEach` identity, `@Observable`, `@Entry`, localization, soft-deprecation | | `swiftui-whats-new-27` | 7 | `@State` as a macro, `reorderable()`, swipe actions outside `List`, toolbar overflow, `AsyncImage` caching | | `app-intents-specialist` / `-whats-new-27` | 14 + 14 | `perform()` semantics, entity queries; `supportedModes`, `requestChoice`, `UndoableIntent`, snippets | | `audit-xcode-security-settings` | 16 | Enhanced Security, pointer auth, MTE, stack zero-init — plus a python build-settings filter | | `uikit-app-modernization` | 4 | scene lifecycle, `mainScreen` / `interfaceOrientation` removal, Swift **and** ObjC | | `adopt-c-bounds-safety` | 5 | `__counted_by` and family | | `building-document-based-swiftui-applications` | 3 | the new `Document` protocol vs `FileDocument` | | `translation` + `translation-coordinator` | 22 style guides | String Catalog translation over MCP tools, one locale per sub-agent | The translation pair is worth knowing about separately: it carries per-locale style guides (ja, uk, fi, sv, he, hi, zh-Hans, ar, and the English variants) and a hard rule — never write `.xcstrings` directly, go through `StringCatalogRead` / `StringCatalogContext` / `StringCatalogEdit`. ## Xcode eats the same plugins we write `AgentVersions.plist` pins the CLI agents the IDE downloads (claude-code and codex, by version and checksum), and the framework binary carries `.claude-plugin`, `.codex-plugin/plugin.json`, `PluginsManifest.json`, a marketplace ("enter the URL of a git repository containing plug-ins, skills or MCP servers") and skill import/export. So a skill repo is installable in Xcode's assistant, not only in the CLI. ## The rules most likely to hit real code Short versions. Read the reference before acting on any of them. - **A computed `private var section: some View` is not factoring.** It is inlined into the parent's body, so it shares the parent's invalidation boundary — extracting sections for *readability* buys nothing at runtime. Only a separate `View` type with narrow inputs does. (`structure.md`) - **`init` runs as often as the parent's body.** No decoding, no formatters, no file access there. - **`Group { OneConcreteView() }` costs a type wrapper** every chained modifier must type-check against. A `Group` around `if`/`else` or siblings is fine — the anti-pattern is exactly one concrete child. - **`ForEach` id must be stable, unique and cheap to hash.** `\.indices`, `\.offset`, `id: \.self` on a fat struct, or an id derived from an editable field all break state, focus and animations the moment the collection changes. `.enumerated()` itself is fine — use `id: \.element.id`. - **No `filter` / `sorted` / rebuilding `map` inline in `ForEach`.** The expression re-runs on every body evaluation, including ones that have nothing to do with the list. Cache it on the model. - **`List` rows want to be unary.** A bare top-level `switch`, a top-level `if` without `else`, or an `AnyView` row defeats the templating fast path and forces SwiftUI to evaluate every row's body just to compute ids. Wrap the branch in a single-root container. Replacing `AnyView` with a `@ViewBuilder` returning `some View` is only half the fix. - **Make `@Observable` property types `Equatable`.** The generated setter skips invalidation when the new value equals the old one — but only when it can compare. A property typed as a tuple, or an array of non-`Equatable` elements, notifies on every write. Mark the class `@MainActor`. - **A computed property on an `@Observable` establishes its dependencies transitively.** `var current: Item? { items.first { … } }` makes every reader depend on the whole `items` array. Cache the derived value in a stored property and recompute in `didSet`. - **`.onChange(of:)` reads its dependency in the body scope**, so an expensive view re-evaluates on every change of a value it never renders. Move the `.onChange` and the read into a small `ViewModifier`. - **Never write an `.if(condition) { $0.modifier() }` extension.** It swaps view identity on every toggle: state resets, animations become replacements. Use a ternary inside the modifier argument, and `AnyShapeStyle` (cheap, not `AnyView`) when the two branches are different `ShapeStyle` types. - **`@Entry` needs a stable default.** `Model()`, `Date()`, `UUID()` or any fresh allocation invalidates dependents on every read; the compiler warns about closures and class types. ## Liquid Glass, condensed `glassEffect(_:in:)` after the layout modifiers; `.interactive()` only on surfaces that actually respond to touch; **`GlassEffectContainer` whenever two glass surfaces sit near each other** — separately they each sample the content behind them and read as unrelated panes. `glassEffectID(_:in:)` + `@Namespace` morphs one surface into another across a hierarchy change, and `glassEffectUnion` merges several into one shape. Button styles: `.glass`, `.glassProminent`. ## Diagnostics worth stealing ```bash xcrun simctl launch <UDID> <bundle-id> -LogForEachSlowPath YES ``` SwiftUI is supposed to log every `ForEach` inside a lazy container whose row body produces a non-constant number of views. Measured once on an app built against the iOS 26 SDK and run on an iOS 27 simulator: **no output at all**, in the process console or in `log show --info`. Treat it as an SDK-27-and-later tool until proven otherwise, and don't read silence as a clean bill of health. -
catalogue.md 5.2 KB
# The catalogue, and seeing it without driving the app Loaded on demand: read this when building the component gallery or wiring the visual loop. A design system nobody can look at is a set of constants. The catalogue is what makes it a system — the type scale beside itself, the twelve columns over a real control, every surface at once, so a step that does not belong is *visible* rather than merely present in a file. ## Two doors, two questions | Want to know | Use | Cost | |---|---|---| | do the numbers line up | `ImageRenderer` sheets from a test | ~3s, no launch, no taps | | does the glass look right | the gallery on a simulator/device | a build + install | | do two real screens agree | the grid overlay over the app | a relaunch | ## The whole storybook is one test No external dependency needed — `ImageRenderer` inside XCTest writes PNGs: ```swift @MainActor enum SnapshotWriter { static let outputDir = URL(fileURLWithPath: #filePath) // NOT the cwd: .deletingLastPathComponent() // a test host's .deletingLastPathComponent() // working dir is .deletingLastPathComponent() // not the project .appendingPathComponent("docs/previews", isDirectory: true) @discardableResult static func write(_ view: some View, named name: String, size: CGSize, scale: CGFloat = 2) throws -> URL? { let renderer = ImageRenderer(content: view.frame(width: size.width) .frame(minHeight: size.height, alignment: .top) .background(Color.black)) renderer.scale = scale guard let image = renderer.uiImage, let data = image.pngData() else { XCTFail("ImageRenderer produced no image for \(name)"); return nil } try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) let url = outputDir.appendingPathComponent("\(name).png") try data.write(to: url) print("SNAPSHOT \(url.path) — \(data.count) bytes") XCTAssertGreaterThan(data.count, 1_000, "\(name).png looks empty") return url } } ``` **`ImageRenderer` does not draw materials.** `.ultraThinMaterial` and `glassEffect` come out empty, so a glass panel reads flat there. Everything that is geometry — grid, type, radii, spacing — is exact, and that is what the sheets are for. For glass, launch the gallery. **One writer, not one per suite.** Two test files each grew their own copy of this in one project: same renderer, same three `deletingLastPathComponent()` calls, different scales. Two copies of a path is how one of them ends up writing somewhere nobody looks. Make it one command: ```make design: ## Render the design system to docs/previews/*.png @$(BOOTED_SIM) \ xcodebuild test -project App.xcodeproj -scheme App \ -destination "id=$$SIM" -only-testing:AppTests/DesignSheetTests 2>&1 \ | grep -E "SNAPSHOT|error:|\*\* TEST" ``` ## The gallery in the app, behind a launch argument ```swift @State private var showGallery = DebugFlag.designGallery.isOn // false in Release ``` `simctl launch <udid> <bundle> -designGallery YES` writes the pair into the argument domain, so a launch argument *is* a `UserDefaults` key for that launch — one `bool(forKey:)` read covers both. Wrap it in a small injectable type (store + arguments as values) so the decision is testable and cannot ship on. Reuse the existing run recipe rather than copying its boot/install/launch sequence: ```make design-app: ; @$(MAKE) run-ios LAUNCH_ARGS="-designGallery YES" ``` ## Two traps in the gallery itself - **Its strings will end up in your localisation catalogue.** Five token names ("micro", "aspect · 9:16", a picker's "Sheet") landed in a 36-language catalogue as *awaiting translation* on the first run. Use `Text(verbatim:)` throughout and pass a `String` variable where an API takes a `LocalizedStringKey`. - **Name properties so your own guards do not fire.** A lint that catches untranslated UI text keys on property names like `title` — call the gallery's tab label `tab`, and say in a comment which of the two it is. ## The grid overlay is the only proof two screens agree ```swift func gridOverlay(_ show: Bool = GridOverlay.isOn) -> some View { overlay { if show { GridOverlay().allowsHitTesting(false) } } } ``` Twelve translucent columns plus the margins, drawn over the live app. A gallery proves one screen is tidy; the overlay over two different screens is what shows that a chip row and a tab row land on the same vertical lines. ## When to reach for the real libraries - **swift-snapshot-testing** (Point-Free) — when you want *failing tests* on visual regression rather than files to look at. The `ImageRenderer` sheet is a document; a snapshot test is a gate. Both is fine; start with the document. - **Inject + InjectionIII** — hot reload while iterating on layout. Saves real time on a big app; adds a build-setting to explain to everyone else. - **figma-export** (RedMadRobot) if the designer lives in Figma and there is one platform; **Style Dictionary** if there are two or more and tokens should be generated from one JSON. -
codemod.md 4.3 KB
# The codemod — moving an existing app onto the scale Loaded on demand: read this when actually migrating literals, not when writing a new component. Hand-editing hundreds of call sites is where a design-system migration dies — half done, half not, and no one can say which half. Write the codemod, keep it in the repo, and let it carry the rounding rule so the rule is readable instead of folklore. One real run: **991 substitutions across 31 files**, in one pass, with the app building after each family. ## What it may touch, and what it must not | Rewritten | Left alone | |---|---| | `.padding(12)`, `.padding(.horizontal, 6)` | `.frame(width: 240)` — a size, not a gap | | `spacing: 14` | `.offset(x: 3)` | | `cornerRadius: 18` | `lineWidth: 1.5` | | `.font(.system(size: 13, weight: .semibold))`, `.font(.caption)` | `.shadow(radius: 16)` | | `.spring(response:dampingFraction:)`, short `.easeOut/.easeInOut/.linear` | decorative/long curves (0.35s+, 60s turns) | | `RoundedRectangle(cornerRadius: X, style: .continuous)` | a computed radius (`radius * 0.5`) | A `frame` is the size of a thing; a `padding` is the space around it. Rounding the first changes the design, rounding the second aligns it. ## The rounding rule ```python SPACE = [(2,"Space.hair"),(4,"Space.xs"),(8,"Space.sm"),(12,"Space.md"), (16,"Space.lg"),(20,"Space.xl"),(24,"Space.xxl"),(32,"Space.xxxl"), (48,"Space.huge")] def nearest(value, table): if value > 56: # past the scale it is a size, not a gap return None # TIES GO UP: 6 is equidistant from 4 and 8, 10 from 8 and 12. Rounding # down tightens a third of the app by two points at once, and the layout # that was already snug is the one that breaks. Air is the safe direction. step, name = min(table, key=lambda p: (abs(p[0] - value), -p[0])) return name if abs(step - value) <= 6 else None ``` ## Font bounds are 9…26, deliberately ```python def font_step(size, weight): if size < 9 or size > 26: return None # a mark, or an illustration glyph — not type strong = weight in {"semibold","bold","heavy","black"} if size <= 10: return "caption" if size <= 12.5: return "label" if strong else "caption" if size <= 14.5: return "calloutStrong" if strong else "callout" if size <= 16.5: return "bodyStrong" if strong else "body" if size <= 19: return "headline" return "title" ``` Below nine points the number is a *mark*: a day number on a calendar ring, a tick's label inside a `Canvas`. The scale's smallest step would double it. Above ~26 it is nearly always `Image(systemName:)` standing in for an illustration — a 64pt permission glyph, a 72pt launch ring. **Have a `*Strong` step for every tier the app actually bolds.** Without one the script picks the nearest step and a human bolts `.fontWeight(.semibold)` on top — a step followed immediately by an override of the step. Three of those appeared in one file the first time this ran. ## Springs by shape, not by number ```python if damping < 0.7: name = "release" # that bounce is its character elif response >= 0.38: name = "settle" # a panel arriving at a stop else: name = "slide" # something small changing place ``` Twenty-one distinct spring spellings existed before this; nobody chose twenty-one springs. Easing folds the same way: 0.12–0.26s → `fade`, a 0.25–0.35s `linear` → `progress`, everything longer is doing something specific and stays. ## How to run it ```bash python3 scripts/design_migrate.py # report only python3 scripts/design_migrate.py --write # apply ``` Read the report before writing — the font lines especially, since a 13pt label that was deliberately smaller than its neighbour becomes the same step as that neighbour. Usually that is the point. Occasionally it is not. Build after each family, and **keep the script**: the pre-commit guard's warning should name it, which makes it live tooling rather than an orphan. ## Skip list Exclude by path, and say why in the code: - the design system's own directory (it defines the scale) - generated bindings (uniffi/protobuf output) - anything whose points are geometry, not layout — Metal vertex code, `Canvas` paths drawn to their own design grid, a logo mark built on a 52-point canvas where every number is relative
-
-
scripts
-
design_migrate.py 11 KB
#!/usr/bin/env python3 """Put every literal gap, corner and font in the views onto the design scale. Counted before this ran: 27 distinct padding values, 19 spacings, 23 corner radii and 48 font spellings across 17 900 lines of SwiftUI. None of them was chosen against the others — each was chosen once, on its own screen. This script does the mechanical half of the move, and only the mechanical half: .padding(9) -> .padding(Space.sm) spacing: 14 -> spacing: Space.lg cornerRadius: 18 -> cornerRadius: Radii.lg .font(.system(size: 13, weight: .semibold)) -> .font(TypeScale.callout)… Rounding is to the nearest 4-point step, with 2 kept as the one half step (a hairline, or the gap inside a single object). A value that rounds to something a person would notice — a 40-point frame becoming 48 — is left alone: this only ever touches padding, spacing and corner radius, never `frame`, `offset`, `lineWidth` or a shadow's radius. python3 scripts/design_migrate.py # report what would change python3 scripts/design_migrate.py --write # change it Read the report before writing. The fonts in particular are a judgement — a 13-point label that was deliberately smaller than its neighbour becomes the same step as that neighbour, which is usually the point and occasionally not. """ from __future__ import annotations import argparse import pathlib import re import sys from collections import Counter # Where the views live. Given on the command line, because this script belongs # to the skill rather than to any one app. VIEWS = pathlib.Path() # Files this must not touch, and why. SKIP = { "Design", # the scale itself "Generated", # uniffi output "Cylinder", # Metal geometry — points here are vertex coordinates "L2FIcons.swift", # Canvas paths drawn to a 24-unit design grid "MakeMark.swift", # the mark's own 52-point canvas, every number relative } # 4-point steps, and the one half step at 2. SPACE = [ (2, "Space.hair"), (4, "Space.xs"), (8, "Space.sm"), (12, "Space.md"), (16, "Space.lg"), (20, "Space.xl"), (24, "Space.xxl"), (32, "Space.xxxl"), (48, "Space.huge"), ] RADII = [ (2, "Radii.hair"), (4, "Radii.xs"), (8, "Radii.sm"), (12, "Radii.md"), (16, "Radii.lg"), (24, "Radii.xl"), (36, "Radii.xxl"), ] def nearest(value: float, table) -> str | None: """The step closest to `value`, or None when nothing is close enough. Past 48 the scale stops and the number is a size rather than a gap — a 300-point sheet height is not a padding that drifted.""" if value > 56: return None # Ties go **up**: 6 is equidistant from 4 and 8, and 10 from 8 and 12. # Rounding down there would tighten a third of the app by two points at # once, and a layout that was already snug is the one that breaks. Air is # the safer direction — `-pair[0]` makes the larger step win a tie. step, name = min(table, key=lambda pair: (abs(pair[0] - value), -pair[0])) # More than a step and a half away means this was not a gap on any scale — # leave it and let a person look at it. return name if abs(step - value) <= 6 else None NUM = r"(\d+(?:\.\d+)?)" PADDING = re.compile(r"\.padding\((\.\w+,\s*)?" + NUM + r"\)") SPACING = re.compile(r"\bspacing:\s*" + NUM + r"\b") CORNER = re.compile(r"\bcornerRadius:\s*" + NUM + r"\b") # `.spring(response: 0.34, dampingFraction: 0.78)` and friends. 21 distinct # spellings across 12 files before this ran — nobody chose twenty-one springs. SPRING = re.compile( r"\.spring\(response: " + NUM + r", dampingFraction: " + NUM + r"\)" ) # The easing curves that are one of the two named ones. Anything longer than a # third of a second is doing something specific (a decorative drift, a 60s # turn) and is left alone. EASING = re.compile(r"\.(easeOut|easeInOut)\(duration: " + NUM + r"\)") LINEAR = re.compile(r"\.linear\(duration: " + NUM + r"\)") # `RoundedRectangle(cornerRadius: X, style: .continuous)` — 41 spellings in 12 # files. `Radii.shape(_:)` is the same thing and is always `.continuous`. ROUNDED = re.compile( r"RoundedRectangle\(cornerRadius: ([A-Za-z0-9_.]+)(, style: \.continuous)?\)" ) # .font(.system(size: 13, weight: .semibold, design: .rounded)) FONT_SIZED = re.compile( r"\.font\(\.system\(size:\s*" + NUM + r"(?:,\s*weight:\s*\.(\w+))?(?:,\s*design:\s*\.(\w+))?\)\)" ) # .font(.caption) and friends FONT_STYLE = re.compile( r"\.font\(\.(caption2|caption|footnote|subheadline|headline|body|callout|title3|title2|title|largeTitle)\)" ) STYLE_MAP = { "caption2": "caption", "caption": "caption", "footnote": "callout", "subheadline": "body", "body": "body", "callout": "callout", "headline": "headline", "title3": "title", "title2": "title", "title": "title", "largeTitle": "display", } HEAVY = {"semibold", "bold", "heavy", "black"} def font_step(size: float, weight: str | None) -> str | None: """Which step a fixed size belongs to, or None to leave it alone. Weight decides between the two steps that share a size: 12 semibold is a control's name (`label`), 11 regular is a caption under a thumbnail. **Only 9…26 is text.** Below nine the number is a mark rather than a word — a day number on the calendar ring, a tick's label — and the scale's smallest step would double it. Above twenty-six it is almost always an `Image(systemName:)` standing in for an illustration: the permission screen's 64-point photo glyph, the launch screen's 72-point ring. Both ends are sizes, not type, and neither belongs on a text scale.""" if size < 9 or size > 26: return None strong = weight in HEAVY if size <= 10: return "caption" if size <= 12.5: return "label" if strong else "caption" if size <= 14.5: return "callout" if size <= 16.5: return "bodyStrong" if strong else "body" if size <= 19: return "headline" return "title" def convert(text: str, tally: Counter) -> str: def pad(m: re.Match) -> str: edge, raw = m.group(1) or "", float(m.group(2)) name = nearest(raw, SPACE) if not name: return m.group(0) tally[f"padding {raw:g} -> {name}"] += 1 return f".padding({edge}{name})" def space(m: re.Match) -> str: raw = float(m.group(1)) if raw == 0: return m.group(0) name = nearest(raw, SPACE) if not name: return m.group(0) tally[f"spacing {raw:g} -> {name}"] += 1 return f"spacing: {name}" def corner(m: re.Match) -> str: raw = float(m.group(1)) name = nearest(raw, RADII) if not name: return m.group(0) tally[f"radius {raw:g} -> {name}"] += 1 return f"cornerRadius: {name}" def sized(m: re.Match) -> str: raw, weight = float(m.group(1)), m.group(2) # A size computed from something else (`size * 0.28`) never matches # this pattern, so anything here is a literal. step = font_step(raw, weight) if step is None: tally[f"font {raw:g} — left alone (a size, not type)"] += 1 return m.group(0) tally[f"font {raw:g}{'/' + weight if weight else ''} -> {step}"] += 1 return f".font(TypeScale.{step})" def styled(m: re.Match) -> str: step = STYLE_MAP[m.group(1)] tally[f"font .{m.group(1)} -> {step}"] += 1 return f".font(TypeScale.{step})" def spring(m: re.Match) -> str: response, damping = float(m.group(1)), float(m.group(2)) # Three questions, in order. A loose spring is a *release* whatever its # response — that bounce is the whole character of it. A slow one is a # panel arriving at a stop. Everything else is a small thing moving. if damping < 0.7: name = "release" elif response >= 0.38: name = "settle" else: name = "slide" tally[f"spring {response:g}/{damping:g} -> Motion.{name}"] += 1 return f"Motion.{name}" def easing(m: re.Match) -> str: seconds = float(m.group(2)) # Only the short ones. Past 0.3s an ease is doing something particular # — a wheel settling, a colour drifting — and a named "fade" would be # a lie about what it is. if not 0.12 <= seconds <= 0.26: return m.group(0) tally[f"{m.group(1)} {seconds:g}s -> Motion.fade"] += 1 return "Motion.fade" def linear(m: re.Match) -> str: seconds = float(m.group(1)) if not 0.25 <= seconds <= 0.35: return m.group(0) tally[f"linear {seconds:g}s -> Motion.progress"] += 1 return "Motion.progress" def rounded(m: re.Match) -> str: radius = m.group(1) # A computed radius (`radius * 0.5`) never matches — the pattern takes # one identifier or number, so what is rewritten is always a token. tally[f"RoundedRectangle({radius}) -> Radii.shape"] += 1 return f"Radii.shape({radius})" text = PADDING.sub(pad, text) text = SPACING.sub(space, text) text = CORNER.sub(corner, text) text = FONT_SIZED.sub(sized, text) text = FONT_STYLE.sub(styled, text) text = SPRING.sub(spring, text) text = EASING.sub(easing, text) text = LINEAR.sub(linear, text) text = ROUNDED.sub(rounded, text) return text def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument( "--views", required=True, help="the directory of SwiftUI views to migrate" ) ap.add_argument("--write", action="store_true", help="apply the changes") ap.add_argument("paths", nargs="*", help="limit to these files") args = ap.parse_args() global VIEWS VIEWS = pathlib.Path(args.views) files = ( [pathlib.Path(p) for p in args.paths] if args.paths else sorted(VIEWS.rglob("*.swift")) ) tally: Counter = Counter() touched = 0 for path in files: if any(part in SKIP for part in path.parts): continue before = path.read_text() after = convert(before, tally) if after == before: continue touched += 1 if args.write: path.write_text(after) for change, count in sorted(tally.items(), key=lambda kv: -kv[1]): print(f"{count:4} {change}") # A left-alone literal is not a substitution, and counting it as one is how # a finished migration reports "23 substitutions" and reads as unfinished. # The two numbers answer different questions: what changed, and what the # codemod deliberately refused to touch. skipped = sum(c for change, c in tally.items() if "left alone" in change) changed = sum(tally.values()) - skipped print( f"\n{changed} substitutions in {touched} files" f"{f', {skipped} literals left alone' if skipped else ''}" f"{'' if args.write else ' (dry run — pass --write)'}" ) return 0 if __name__ == "__main__": sys.exit(main())
-
-
SKILL.md 15.8 KB
--- name: solo-swiftui-design-system description: Build and hold a SwiftUI design system — a 12-column grid, spacing/type/radius/motion scales, surface levels, a component gallery, and the guards that stop it drifting back. Use when the user says "сделай по сетке", "дизайн-система", "разъезжается вёрстка", "магические числа в padding", "design tokens", "grid 12", "нужен storybook/каталог компонентов", or when a screen's spacing and fonts were each chosen once and never against each other. Do NOT use for SwiftUI correctness, state flow or Instruments profiling (that is the swiftui-expert skill), or for shipping to TestFlight (solo-ios-release). license: MIT metadata: author: fortunto2 version: "1.0.0" openclaw: emoji: "📐" --- # swiftui-design-system — one scale, and the guards that keep it A design system is not a colour file. It is **four scales, one grid, one catalogue and one tripwire** — and the tripwire is what makes it survive the next feature. Everything here is native SwiftUI: no design-system framework is worth taking on, because the platform is already built for this (Environment + style protocols + `containerRelativeFrame`). **Apple ships its own SwiftUI skills inside Xcode 27** — invalidation, `ForEach` identity, `@Observable`, Liquid Glass — as plain markdown any agent can read with no Xcode running. On anything about correctness or update cost they outrank this file, and they say so in their own header. Where they are, what they cover, and the rules that most often hit real code: **`references/apple-xcode-skills.md`**. Reach for this when the symptom is *"the screens look related and never line up"*. Measure before you believe it — see step 1. ## Workflow ### 1. Count what is actually there Never start from taste. Start from the inventory, because the number is the argument: ```bash V=path/to/Views grep -rhoE '\.padding\((\.[a-z]+, )?[0-9]+(\.[0-9])?\)' $V | grep -oE '[0-9.]+' | sort -n | uniq -c | sort -rn grep -rhoE 'spacing: [0-9]+(\.[0-9])?' $V | sort | uniq -c | sort -rn grep -rhoE 'cornerRadius: [0-9]+(\.[0-9])?' $V | sort | uniq -c | sort -rn grep -rhoE '\.font\(\.[a-zA-Z0-9]+\)|\.font\(\.system\([^)]*\)\)' $V | sort | uniq -c | sort -rn grep -rhoE '\.spring\(response: [0-9.]+, dampingFraction: [0-9.]+\)' $V | sort -u | wc -l ``` A real app measured this way: **27 padding values, 19 spacings, 23 radii, 48 font spellings, 21 springs** — including `.system(size: 17.5)`, `13.5`, `12.5`. Half a point is invisible alone and lethal in a set: it is exactly the amount by which two labels fail to look like the same label. ### 2. Write the scales — namespace enums, one file each ``` Views/Design/ Space.swift 4-point steps: hair(2) xs sm md lg xl xxl xxxl huge + touchTarget(44) Grid.swift 12 columns, gutter, margin, gridSpan(_:), gridMargins(), GridOverlay Typography.swift 8 steps, each a platform TextStyle + Metric for sizes a font can't set Radii.swift 5 radii + shape(_:) — .continuous, always Motion.swift 6 curves + the never-repeatForever rule Surface.swift 3–4 levels (console/panel/inset/field), one recipe each ``` Enums over structs: compile-time names, zero runtime, no instance to thread. **Reach for `@Entry var theme` in the Environment only when there is a second theme** (white-label, per-brand, light/dark that is not the system's). Until then a theme object is one indirection buying nothing. Two rules that decide the arguments in step 3: - **Every type step is a platform text style** — `Font.system(.subheadline, weight:)`, not `.system(size: 15)`. Fixed sizes never grow with Dynamic Type; in the measured app 78 of them didn't. For sizes a font cannot set (an icon's box, a ring's diameter) use `@ScaledMetric(relativeTo:)` over a `Metric` constant. - **The unit of layout is the column, not the point.** `containerRelativeFrame( .horizontal, count: 12, span: 4, spacing: gutter)` is iOS 17+ and is the platform's own grid arithmetic — no `GeometryReader`, no percentages. ### 3. Migrate mechanically, with the rounding rule written down Hand-editing hundreds of literals is where a migration dies — half done, half not, and nobody can say which half. Write a codemod, keep it in the repo, and let it carry the rule: ```python # nearest 4-point step, TIES GO UP (6→8, not 4): rounding down tightens a # third of the app by two points at once, and a snug layout is the one that # breaks. Only padding / spacing / cornerRadius / fonts in 9…26pt — # never frame, offset, lineWidth or a shadow radius. step, name = min(SCALE, key=lambda p: (abs(p[0] - value), -p[0])) ``` **Full rule, the skip list, the spring and font tables: `references/codemod.md`.** Read it when actually migrating; one measured run was 991 substitutions in 31 files, dry-run first, build after each family. ### 4. Style protocols, not modifiers sprinkled per call site SwiftUI's extension points are the system's spine — use them before inventing `.myButton()`: ```swift struct PressableStyle: ButtonStyle { // one feel for every control func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(configuration.isPressed ? Motion.press : Motion.release, value: configuration.isPressed) } } ``` `LabelStyle`, `ToggleStyle`, `ProgressViewStyle`, `MenuStyle` the same way; a `ViewModifier` + `extension View` for what has no protocol (`.surface(.inset)`). ### 5. Build the catalogue — and make it cheap to look at Two doors, answering different questions: ```bash make design # ImageRenderer → PNG sheets from a test: no launch, no taps, ~3s make design-app # the same gallery in the simulator: glass, blur, motion … launch <app> -designGrid YES # the 12 columns over the REAL screens ``` `ImageRenderer` inside an XCTest is the whole storybook you need — a gallery view rendered to `docs/previews/*.png`, one file per sheet, no navigation and no external dependency. **It does not draw materials**: `.ultraThinMaterial` and `glassEffect` come out empty, so glass reads flat there. Geometry is exact, which is what the sheets are for. The grid overlay over the *real* app is the only thing that proves two screens agree; a gallery only proves one screen is tidy. **The writer, the debug flag, the localisation trap and when to reach for swift-snapshot-testing: `references/catalogue.md`.** ### 6. Guard it, or it comes back Three layers, cheapest first: 1. **pre-commit grep on added lines** (warn, not fail): a numeric `.padding(12)`, `spacing: 6`, `cornerRadius: 18`, `.font(.system(size: 13))`. Diff-scoped and warning-only is the right calibration — a whole-tree lint at fail severity breaks on inherited debt and gets bypassed, and a bypassed hook checks nothing. 2. **tests on the arithmetic**: 12 columns + 11 gutters + 2 margins == the screen; `span(6) * 2 + gutter == width`; every space step divisible by 4 except the one deliberate half step. 3. **snapshot sheets in the repo** — a reviewer sees the scale change as an image diff. ## Which spring, and why `Motion.swift` holds six curves. Consolidating 21 springs into 6 is a consistency win and says nothing about whether the six are *right*. These are the numbers Apple ships, and the rule for picking between them. *Reported*, not measured here — from the `apple-design` skill (`~/.agents/src/emilkowalski-skills`, MIT), distilled from WWDC *Designing Fluid Interfaces*. The parameter model transfers exactly: Apple's designer-facing pair is damping ratio + response, which is SwiftUI's `.spring(response:dampingFraction:)`. | Interaction | response | dampingFraction | why | |---|---|---|---| | Move / reposition | `0.4` | `1.0` | critically damped: arrives, does not wobble | | Drawer, sheet | `0.3` | `0.8` | slight overshoot reads as physical | | Rotation | `0.4` | `0.8` | | | Everything else | — | `1.0` | **bounce is not a default** | **Bounce only where momentum is real** — a flick or a throw the finger actually gave. A bounce on a tap is decoration, and the same `0.8` that feels alive on a dragged sheet feels cheap on a button. **Frequency decides whether to animate at all.** This is the rule most motion work skips: - a keyboard shortcut or anything done 100+ times a day: **no animation**; - tens of times a day: shorter and smaller than you want; - occasional: the standard curve; - rare and significant: delight is allowed. **Asymmetric timing.** A deliberate action animates slower than the system's answer to it. Symmetric press/release is a finding, not a style choice — `Motion.press` and `Motion.release` in the catalogue already exist for this and should differ. **Interruptibility.** A gesture-driven view must animate from its *current presentation value*, not from the logical target, or a second gesture snaps. In SwiftUI that means springs and `.animation(_:value:)`, not a keyframe timeline, for anything a finger can grab mid-flight. **Reduced motion is a cross-fade, not a removal.** `@Environment(\.accessibilityReduceMotion)` swaps the slide for an opacity change; it does not delete the transition and leave a jump. Depth, including the momentum-projection formula, velocity handoff from a gesture into a spring, and the rubber-band constant: the `apple-design` skill. Its snippets are CSS/JS; the physics and the numbers are platform-independent. ## Gotchas - **`containerRelativeFrame` measures the container, not its content.** Apply `gridMargins()` first, or every span is a margin too wide and nothing lines up with anything. - **A `Sendable` warning on `static let` tokens** in Swift 6: an enum of `static let CGFloat` is fine; a struct holding `UserDefaults` needs `@unchecked Sendable` with a one-line reason. - **Glass cannot sample glass.** Two blurred surfaces side by side each sample what is behind them and read as unrelated panes. On iOS 26 wrap a row of them in `GlassEffectContainer(spacing:)` and give morphing pairs a `.glassEffectID(_:in:)`; below 26 fall back to `.ultraThinMaterial` in one place, not per screen. - **`compositingGroup()` + a zero shadow is still an offscreen pass.** Apply the lift only where there is a shadow to draw. - **Never `repeatForever`.** A UI that never goes idle hangs everything that waits for idle: accessibility snapshots, UI automation, VoiceOver. Use `.repeatCount(n)` and honour `\.accessibilityReduceMotion`. - **A debug surface needs a testable flag.** `-designGallery YES` from `simctl` lands in the argument domain, so one `UserDefaults.bool(forKey:)` read covers it — but wrap it in a small injectable type that is false in Release, or the flag ships. - **A scalar threaded by hand through call sites is invisible to grep.** In the measured app a wheel's vertical offset was written in three places; two moved onto the shared centre and the third did not, so the drawing and the single-tap hit test disagreed by 20 points with nothing on screen to say so. Pass one geometry *value* — then a call site that forgets it does not compile. ## What is coming (and what already works) - **iOS 26 / Swift 6.2 — today.** Liquid Glass (`glassEffect`, `GlassEffectContainer`, `.buttonStyle(.glass)`), `@Entry` for environment tokens with no `EnvironmentKey` boilerplate, `ToolbarSpacer`, `backgroundExtensionEffect()`, `scrollEdgeEffectStyle`. - **iOS 27 / Swift 6.4 (WWDC26 → 2027).** `ContentBuilder` collapses the container overloads that cause *"unable to type-check this expression in reasonable time"* — and it helps when built with the new Xcode regardless of deployment target. `.reorderable()` in any container (not just `List`), swipe actions outside `List`, toolbar overflow priorities, `@State` as a macro with lazy `@Observable` init (back-deployed to iOS 17). **Resizable iPhone apps** is the one that touches a design system directly: baked-in sizes stop being safe, so snapshot at several widths. ## Apple's own skills, and the rest of the field Xcode ships agent skills in the toolchain — plain `SKILL.md` folders, so they work in any agent, not only Xcode's assistant. ```bash make apple-skills # solo-factory: export into ~/.agents/skills, diffed make apple-skills-check # what it would bring, without writing ``` **Xcode 26.6 exports nothing** — the `agent` tool is there and answers *"No skills available to export"*, so the script says that plainly rather than look broken. **Xcode 27.0 beta 2 exports ten**: `swiftui-specialist`, `swiftui-whats-new-27`, `uikit-app-modernization`, `modernize-tests`, `audit-xcode-security-settings`, `adopt-c-bounds-safety`, `device-interaction`, `app-intents-specialist`, `app-intents-whats-new-27` and `building-document-based-swiftui-applications`. The names are not stable across versions — four of the seven guessed from the 26.6 release notes came back spelled differently — so read the export rather than a list. Re-run after every Xcode update: these track the SDK, and a stale "what's new" skill is worse than none. A beta installed alongside the release is not the active toolchain, and `xcode-select` is machine-wide — scope one run instead: `DEVELOPER_DIR="/Applications/Xcode-beta.app/Contents/Developer" make apple-skills`. If the export names the toolchain instead, Xcode → Settings → Locations → Command Line Tools points at the wrong Xcode. `device-interaction` is the one that matters for a design system: it drives a real device or simulator — screenshots, view hierarchy, synthesised taps — which closes the loop a build tool alone cannot (write layout → build → look at it → correct it) without a human running the walk. Community skills worth reading **before** installing — a skill is injected into the assistant's context and changes how it writes your code: | Where | Why | |---|---| | `twostraws/Swift-Agent-Skills` | curated index; start here | | `twostraws/SwiftUI-Agent-Skill` (`swiftui-pro`) | aimed at the mistakes LLMs actually make: navigation, layout, state, VoiceOver, deprecated APIs | | `AvdLee/SwiftUI-Agent-Skill` | the architecture to copy — references loaded on demand, so deep context costs nothing until asked for. Also a maintenance skill that refreshes the deprecated-API list after each release | | `Dimillian/Skills` | `swiftui-liquid-glass`, `swiftui-view-refactor`, `swiftui-performance-audit` | | `dpearson2699/swift-ios-skills` | 86 skills on iOS 26+ — **PolyForm Perimeter licence, not MIT**; read it before commercial use | Design-system repos worth reading rather than depending on: **DSKit** (organised for agents — generated docs link every component to its source, snapshots and usage), **OversizeUI** (semantic colours, Dynamic Type, spacing scale), **design-foundation** (MIT, Swift 6 concurrency-safe), **ouds-ios** (corporate scale, strong accessibility). ## Don't - **Don't add a design-system framework.** Environment + style protocols + the grid API cover it; a framework on top mostly fights the layout system. - **Don't name colours `blue500`.** Semantic names (`surfaceElevated`, `textSecondary`) survive a re-skin; a palette index turns one into a find-and-replace across the app. - **Don't fold weight into the type scale.** `isSelected ? .semibold : .regular` is a step plus `.fontWeight()` on top — folding it in is how a scale of eight becomes a scale of sixteen. - **Don't chase the photo grid onto the interface gutter.** A wall of images wants 1–2pt between tiles; keep it *on* the twelve columns (a tile is a third of the width) and let the spacing be its own. - **Don't ship aliases.** `Brand.tabRadius = Radii.lg` reads as tidy and puts two spellings of 16pt in one file within a week. - **Don't measure a render or a build on a loaded machine.** Check `vm.loadavg` first; three "regressions" in one project were the laptop.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.