DEV Community

Cover image for How to stop your AI coding agent from inventing SwiftUI UI
Nerd Snipe
Nerd Snipe

Posted on

How to stop your AI coding agent from inventing SwiftUI UI

Ask a coding agent for a few SwiftUI screens in separate sessions and you get a separate set of decisions each time. The corner radius is 10 on one and 12 on another. The blue is .blue here and Color(red: 0.2, green: 0.4, blue: 0.9) there. Each screen compiles and looks fine alone. Side by side they read as different apps, and each fix is another prompt to pay for.

The agent has nothing in the project that says what your app looks like, so it guesses. The setup below gives it something to read and something to run into when it strays. It uses DesignFoundation, a free MIT-licensed SwiftUI package I work on, but the three parts (a token theme, instruction files, a lint rule) apply to any design system. The longer argument for why this works is in the companion post on the NerdSnipe blog.

Step 1: put the theme in the environment

Add the package. It needs Xcode 16 and targets iOS 18, macOS 15, and visionOS 2.

dependencies: [
    .package(url: "https://github.com/NerdSnipe-Inc/design-foundation", from: "1.0.0")
]
Enter fullscreen mode Exit fullscreen mode

Then set a preset once at the app root:

import SwiftUI
import DesignFoundation

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .dfThemePreset(.slate)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Five presets ship: .slate, .aurora, .copper, .sage, and .garnet. Each has a light and a dark theme and follows the system setting. Every DesignFoundation component below that modifier reads colors, spacing, radii, typography, and shadows from the theme, so DFButton("Save") { } picks up the preset without any styling in the call.

Your own views read the same tokens through the environment:

struct ProfileChip: View {
    @Environment(\.dfTheme) private var theme
    let name: String

    var body: some View {
        HStack(spacing: theme.spacing.sm) {
            Circle()
                .fill(theme.colors.primary)
                .frame(width: 8, height: 8)
            Text(name)
                .font(theme.typography.label.font)
                .foregroundStyle(theme.colors.textPrimary)
        }
        .padding(.horizontal, theme.spacing.md)
        .padding(.vertical, theme.spacing.xs)
        .background(theme.colors.surfaceElevated, in: Capsule())
    }
}
Enter fullscreen mode Exit fullscreen mode

To change one thing, copy a theme, edit it, and inject it:

var custom = DFTheme.sageLight
custom.colors.primary = .purple

ContentView().dfTheme(custom)
Enter fullscreen mode Exit fullscreen mode

Step 2: give the agent the rules

The repo ships instructions for the agents that will write against it: AGENTS.md, CLAUDE.md, a Cursor rule at .cursor/rules/design-foundation.mdc with alwaysApply: true, and docs/llms.txt. The first rule in all of them is to never build UI the package already provides. Below that they list real signatures, mostly the places generic SwiftUI habits break. Three examples:

DFCheckbox(isChecked: $agreed, label: "I agree")             // label: is a keyword argument
DFButton("Delete", style: .ghost, role: .destructive) { } // destructive is a role; no icon: or isLoading:
DFText("Account", scale: .headline)                        // scale:, not style:
Enter fullscreen mode Exit fullscreen mode

SwiftPM checks out the whole repository, so these files are already on your disk once the package resolves. In a package-manifest project they sit in .build/checkouts/design-foundation/. In Xcode they are under DerivedData in SourcePackages/checkouts/design-foundation/. I confirmed both AGENTS.md and the Cursor rule are present in a fresh checkout of the current release.

How you wire them into your own app is up to you, since the package doesn't prescribe it. Two options I'd consider. You can copy the file your agent reads into your app repo, which is simple and goes stale when you upgrade the package. Or you can point your app's own instruction file at the checked-out copy, so it updates with the package:

# UI rules
Before writing any SwiftUI, read the DesignFoundation instructions at
.build/checkouts/design-foundation/AGENTS.md and follow them.
Never build a component that DesignFoundation provides.
Enter fullscreen mode Exit fullscreen mode

That block is my example, not something from the repo. Adjust the path for your setup.

The repo also has a workflow, doc-snippets.yml, that compiles every Swift block in those files against the real package on every change, so the instructions can't drift from the API unnoticed. I compiled the snippets in this post against the current release for the same reason.

The cost is context. AGENTS.md is about 400 lines, and the agent loads it every session. If you are generating one small screen, that overhead can outweigh the saving.

Step 3: add the lint rules

Instructions can't stop a raw Color(red:green:blue:) from landing in a pull request, and neither can the Swift compiler. The package includes Tooling/swiftlint-design-foundation-tokens.yml, a block of SwiftLint custom_rules with four rules:

  • design_foundation_no_raw_color for Color(red:...), Color(hue:...), and similar
  • design_foundation_no_named_color_literal for Color.red, Color.gray, and the rest
  • design_foundation_no_raw_font for .font(.system(...))
  • design_foundation_no_hardcoded_corner_radius for cornerRadius: 10

Merge the block into your .swiftlint.yml, or pull it in with !include. Each rule has a message that names the token to use instead, for example "A literal corner-radius value won't track a theme's radius scale, use theme.radius.sm/md/lg/xl instead." An agent that runs SwiftLint reads that message and knows the fix.

The rules are regex-based and set to warning, so expect the occasional false positive, like a preview fixture or a third-party API that needs a plain Color. Silence those on the line with // swiftlint:disable:next design_foundation_no_raw_color. In CI, the warnings show up on the pull request.

You still review every diff, since none of this makes the agent's logic correct. The components don't need #if os(), but your own scene declarations and any platform API the package doesn't wrap still do. The token saving is less generated code, and no benchmark backs a number for it: the docs site's "Foundation Way" comparison page rests on its own line-count samples and a rough 35-characters-per-line estimate.

If you keep asking the agent for the same whole screens (sign-in, analytics, settings) and rewiring them each time, there's a paid add-on on the same tokens, DesignFoundationPro, with 55 finished screens and 18 navigation shells. The free package works without it.

The source is on GitHub, and the docs have the component reference. Build one real screen in a sandbox app, then run SwiftLint over it.

Top comments (0)