Creating a design system that agents can't bypass

A design system is only as strong as your ability to enforce it.

Share

Ask an agent to build a settings screen and it will give you a settings screen. It will also, somewhere in those two hundred lines of SwiftUI, do a handful of small things you never asked for:

  • .padding(16) where the spacing token .spacing(.md) was meant, because 16 was right there and looked fine.
  • Color(.systemGray) for a muted label, because that is the obvious way to get gray, instead of Color.semantic(.inkSecondary).
  • A Text with no font modifier, which silently falls back to the system typeface rather than a typography token.
  • A hardcoded .cornerRadius(12) that is close to, but not exactly, the radius on every other card in the app.

Every one of those looks correct. Every one compiles. Every one renders fine in the single screenshot the agent hands back. And every one is a small hole punched in the design system, in a place no one will think to look.

That is the part that makes it dangerous: none of it is a bug. The screen works, so it ships. Then it happens again on the next screen, and the one after that. A hundred agent-written views later, the "system" is folklore. Some views honor it and some do not, the palette has grown a dozen near-identical grays, and the spacing scale is more of a suggestion. Nobody decided to abandon the design system. It eroded one reasonable-looking literal at a time.

You cannot review your way out of this, either. When the bottleneck was typing, the person writing the code carried the conventions in their head because they had lived in them. Hand the writing to an agent and that context is gone: it does not remember your tokens between sessions, and it will always reach for the locally obvious value over the globally correct one. Review is supposed to catch the difference, but review does not scale with generation speed, and .padding(8) versus .padding(.spacing(.md)) is exactly the kind of thing a tired human skims past at 11pm.

So the real question is not how to get an agent to write a screen. It is how to let an agent write a hundred screens and be certain every one honors the design system, without reading each line yourself. That rules out anywhere the rule can be forgotten, skimmed, or overridden: a doc, a review, your own memory. The rule has to live somewhere the agent simply cannot get past. There are several places to try putting it, most of them leaky, and the one that actually holds is a little strange.

The rule you wish you could enforce

Every design system has a short list of rules that everyone agrees with and nobody follows perfectly:

  • Colors come from semantic tokens, never from raw palette values. Color.semantic(.brandAccent), not Color.blue.
  • Spacing and corner radii come from a scale. .spacing(.md) and .radius(.sm), not 8 and 12.
  • Every piece of text has an explicit typography token, so nothing silently falls back to the system font.

These are conventions. Conventions are things you enforce with willpower and code review, which is to say things you do not really enforce. What I wanted was for a violation to fail the build. Not a warning in a separate lint step that runs in CI and gets ignored. A red compiler error, at the exact line, at the moment the code is written, that neither the agent nor I can wave through.

Here is roughly what that looks like at the call site. Take a small badge component:

@DesignSystem
struct Badge: View {
    @Environment(\.semantic) private var semantic
    let label: String

    var body: some View {
        Text(label)
            .font(Typography.caption.font())
            .padding(.horizontal, .spacing(.sm))
            .foregroundStyle(Color.semantic(.inkPrimary, in: semantic))
    }
}

That one attribute, @DesignSystem, is the whole interface. Remove the .font(...) line and the build fails: this Text has no typography token and will fall back to the system font. Change .spacing(.md) to 8 and the build fails: hardcoded spacing literal. Swap Color.semantic(...) for Color.red and the build fails: non-semantic color. The error points at the offending node and tells you why.

The approaches that leak

I did not start here. Everyone who has tried to hold a design system in place has climbed the same ladder, and each rung taught me what the next one had to fix.

A written guideline. The first instinct is to document the rules: a style guide, a section in CONTRIBUTING.md, a paragraph in the agent's system prompt. This shifts the odds and nothing more. Guidance is probabilistic. An agent that has read "always use semantic tokens" will still reach for Color.red when the surrounding context makes it the locally obvious move. A rule that is followed most of the time is, from the design system's point of view, not followed.

Code review. So you put a human at the gate. This is where most teams stop, and it is the thing that breaks first under delegation, because review does not scale with generation speed. It also lands the feedback in the wrong place: after the work is "done," far from the moment of authorship, on a reviewer who is pattern-matching a diff rather than reasoning about a token scale. Drift is exactly the kind of thing that survives a skim.

A custom linter. So you automate the reviewer: a regex rule, a SwiftLint custom rule, a CI check. Better, because it is tireless, but it is blind to structure. It sees lines of text, not a syntax tree, and design-system rules live in the structure. The spacing literal is on one line, the token three lines up. The font is inherited from an ancestor container far from the Text it styles. A line-based tool either misses these or, worse, produces false positives that train everyone to sprinkle ignore comments until the rule means nothing. (This is the failure I dig into in the next section.) Structural tools like ast-grep are a real step up, and I do use one, but for a coarse presence check rather than the scope-aware rules.

The type system. This is the most principled alternative, and the one a good Swift engineer reaches for first: do not check for the wrong thing, make it unrepresentable. Define a Spacing enum and an overloaded padding(_:) that only accepts it, a color type that can only be constructed from a token, and let the compiler reject .padding(8) because 8 is not a Spacing. Where this works it is the best option, and you should do it. But it has a hard ceiling: you cannot delete Apple's APIs. padding(_: CGFloat), Color(red:green:blue:), and Color.red still exist and still compile, because they belong to SwiftUI, not to you. You can add a safe overload alongside the unsafe one. You cannot remove the unsafe one. The type system can make the right path convenient. It cannot make the wrong path impossible.

That last gap is the whole problem. Every rung is either non-binding (guidelines, review), structurally blind (line-based linters), or unable to close the escape routes the framework leaves open (the type system). What was missing was something binding, structural, able to inspect the code you actually wrote, and running early enough to matter. The mechanism I landed on is stranger than any of the above: a macro whose entire job is to refuse to generate code.

A macro that produces nothing

Swift macros are usually pitched as code generators. You write @Observable and it synthesizes a pile of boilerplate you did not want to hand-write. @DesignSystem is the opposite. It is an attached peer macro whose expansion returns an empty array. It generates nothing. It exists purely for the side effect it runs while the compiler is expanding it: it walks the syntax tree of the thing it is attached to and emits diagnostics.

public struct DesignSystemMacro: PeerMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        let tree = Syntax(declaration)
        for checker in checkers {
            checker.diagnose(in: tree, context: context)
        }
        return []   // analysis only: no code, just diagnostics
    }
}

This is a linter wearing a macro's clothes. And it is a deliberate trick, because it buys three things a normal linter does not have:

  • It runs inside the ordinary build, with no extra tooling step, so there is no separate command to forget.
  • Its errors are indistinguishable from any other compiler error, so they block the build by default rather than living in a report nobody opens.
  • Most importantly, a peer macro receives the entire declaration it is attached to, which means the checker sees the whole body and can reason about it as a tree rather than as lines of text.

That last point is the reason this is a macro and not a regex.

Why the tree matters

Consider the font rule: every Text must have a typography token in scope. The naive version greps for Text( and checks the same line for .font(. It is wrong immediately, because in SwiftUI a font is inherited through the environment:

VStack {
    Text("Title")
    Text("Subtitle")
}
.font(Typography.body.font())

Both Text views are covered, by a .font modifier that lives several lines below them on an ancestor container. A line-based linter cannot see that. Even tree-sitter struggles with multi-statement SwiftUI bodies. But SwiftSyntax parses the real abstract syntax tree, so the checker can start at a naked Text and walk up through its ancestors looking for a font modifier anywhere in scope.

Walking up is where it gets interesting, because not every ancestor counts the same way. The font checker classifies container modifiers into two sets:

  • Exempt containers like .alert, .confirmationDialog, and .navigationTitle. These are system chrome that ignores custom fonts. A Text inside one is treated as covered, because flagging it would be a false positive.
  • Environment boundaries like .sheet, .popover, .toolbar, and .fullScreenCover. These are presentation surfaces with their own environment. A .font set above them does not reach inside, so when the walk hits one of these it stops and flags the Text.

In code, a checker is just a SyntaxVisitor that collects offending nodes. The font one visits every call expression, and for each Text(...) walks up the tree deciding whether a font is in scope:

final class TextWithoutFontVisitor: SyntaxVisitor {
    private(set) var violations: [Syntax] = []

    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        if node.isTextInitializer && !hasFontInScope(node) {
            violations.append(Syntax(node))
        }
        return .visitChildren
    }

    /// Walk from the `Text` up through its ancestors.
    private func hasFontInScope(_ node: some SyntaxProtocol) -> Bool {
        var ancestor = node.parent
        while let node = ancestor {
            if node.appliesFontModifier { return true }    // a `.font(...)` covers us
            if node.isExemptContainer   { return true }    // alert/navTitle ignores fonts anyway
            if node.isEnvironmentBoundary { return false } // sheet/toolbar: font can't cross
            ancestor = node.parent
        }
        return false   // reached the top with no font in scope
    }
}

That is the whole idea: the same ancestor walk the eye does when it reads the code, written down once. diagnose(in:context:) runs the visitor and turns each collected node into a compiler error.

It even special-cases our brand buttons, where the font is set invisibly inside a ButtonStyle.makeBody the checker cannot see from the call site. That is a lot of domain knowledge about how SwiftUI actually resolves fonts, encoded once, in a place that runs on every build. No human reviewer holds all of that in their head at 11pm. The compiler does, now, every time.

The color rule works as an inverse allowlist. Exactly two things are permitted, Color.semantic(...) and Color.clear, and everything else that reads as a color, any other Color.member, any UIColor.member, any bare Color(...) initializer, is flagged. Allowlisting rather than blocklisting is the right default for governance: you do not have to anticipate every wrong thing, you only have to enumerate the small set of right things. New ways to be wrong are caught for free.

Governing the governance

A macro only runs where it is attached. Which means the entire system has an obvious hole: an agent writes a new View, forgets the @DesignSystem attribute, and none of the rules apply. The enforcement is opt-in, and opt-in governance is not governance.

So there is a second rule, one level up, that enforces the presence of the first. A structural lint rule (I use ast-grep), marked as an error, that flags any type conforming to View which does not carry the @DesignSystem attribute. Now the macro is mandatory. You cannot write a view that opts out of the design system, because opting out is itself the error.

And then, because absolute rules with no escape hatch get worked around in worse ways, there are two deliberate exits. A // design-system-ignore comment suppresses a specific check on a specific node. A separate ignore comment exempts a genuinely special view, like an animated gradient background whose entire job is to paint raw color. Both are grep-able, both are rare, and both are load-bearing enough that I call them out explicitly wherever they appear. The point is not zero exceptions. The point is that every exception is visible, named, and intentional, rather than smuggled in as a plausible-looking literal.

The vocabulary the agent is allowed to speak

None of this works without something to point at. The rules say "use a semantic color," which is only meaningful because there is a fixed set of them: an enum of a few dozen role-based tokens like .inkPrimary, .surfaceSubtle, .brandAccent, .decorGlow. Each role resolves, per theme, to a concrete color, and the concrete values are generated from JSON design tokens rather than typed by hand.

Roles instead of raw values is what closes the loop. The macro tells the agent it may not name a color directly; the token enum gives it the only names it is allowed to use instead. The agent never learns the hex codes. It learns the vocabulary, and the vocabulary is the only thing it is allowed to say.

Where it still leaks

This is not a perfect solution, and here is why:

  • It is syntactic, so it runs before the type checker. The macro sees source structure, not resolved types, because macros are deliberately not handed type information. A leading-dot literal like .foregroundStyle(.red) slips right through, because at the syntax level there is no Color. prefix to key on. The checker cannot know .red is a color until the type checker resolves it, and by then macro expansion is over. Anything that needs type information to detect is out of reach. This is a documented blind spot, not a solved one.
  • It enforces vocabulary, not judgment. The macro can prove you used a semantic token. It cannot prove you used the right one. Reach for .inkPrimary where .inkSecondary was intended and the build stays green. It closes the gap between "a token" and "a raw value," which is where drift actually happens, but it will never review taste. That still lands on a human, or on screenshots.
  • It has a real build cost. swift-syntax compiles from source, and that adds noticeable time to a clean build. On an incremental build it is invisible; on a fresh checkout it is not.
  • It is fiddly to integrate. Getting the macro package to coexist with another dependency's transitive copy of swift-syntax took an afternoon of pinning exact version ranges. Compiler-plugin tooling is still sharp-edged, and you should budget for that.

None of these are reasons not to do it. They are the shape of the tool. The alternative to "leaks in these four specific, known places" is not "leaks nowhere," it is "leaks everywhere, invisibly." I would rather have a fence with a labeled gap than no fence.

What this is really about

None of this is really about macros, and it is not really about Swift. It is about what changes when you delegate the writing to something fast, tireless, and context-free. The leverage stops being the code you produce and becomes the constraints you produce. The most valuable artifact in a codebase built this way is not any single feature. It is the set of rules that make the wrong version of a feature fail to compile, because those rules are what let you say yes to delegation without saying yes to drift.

That reframing is the transferable part, and it generalizes past this one macro and this one language:

  • Push the feedback to the moment of authorship. A rule that fires in the agent's own build loop gets fixed in the same turn, silently, before I ever see it. A rule that fires in CI gets a comment. A rule that fires in review gets a shrug. Earlier is not just faster, it is the difference between a rule that shapes behavior and one that merely records violations.
  • Make the machine hold the domain knowledge. All that SwiftUI font-inheritance logic is exactly what a human reviewer cannot be relied on to remember. Encode it once, in the check, and it applies with full rigor on every build forever.
  • Allowlist the right things instead of chasing the wrong ones. You will never enumerate every way an agent can drift. You can enumerate the small set of blessed patterns.
  • Design your escape hatches on purpose. Absolute rules breed clever workarounds. A visible, grep-able, intentional exception is healthier than a rule so rigid people route around it, and infinitely healthier than one they satisfy with a literal that happens to look fine.

And that is a trade worth making. You give up a little build speed and a weekend of plumbing, and you accept a few known gaps. In exchange, an agent can write a view at three in the morning and the design system holds, not because anyone reviewed it, but because the wrong version of it was never going to compile. That is what makes the delegation safe. The compiler is the most reliable reviewer you have: it never gets tired, it never skims, and it reads every line.