← writing

JUL 2026 · 11 MIN

Creating a design system that agents can't bypass

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

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:

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:

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:

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:

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:

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:

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.