Claude Skill

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

LLM Mart · 0 points · 7 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download fortunto2-solo-factory-skills_swiftui-design-system-bf3e92b.zip · 20 KB
Part of fortunto2/solo-factory — 43 skills

Install

skills CLI npx skills add https://github.com/fortunto2/solo-factory/tree/main/skills/swiftui-design-system
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install fortunto2-solo-factory@llmmart
Git 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 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:

# 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:

  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.

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.
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.

No comments yet.

Reviews (0)

No reviews yet.

Related