Back to skills
extension
Category: Development & EngineeringNo API key required

"design-system"

Design system rules: AppTheme token pattern, Color(hex:) extension, Colors/Fonts/Spacing/Style enums, SF Symbols, typography tokens. Use when defining colors, spacing, fonts, or any visual design tokens, or when creating/editing AppTheme. Triggers: AppTheme, Color, .primary, .secondary, spacing, cornerRadius, font, SF Symbol.

personAuthor: jakexiaohubgithub

Design System Rules

MANDATORY — AppTheme Is the Single Source of Truth

Every color, font, and spacing value in the app MUST come from AppTheme. No exceptions.

Before writing ANY view code, verify:

  1. Does AppTheme.Colors have a token for the color I need? If not, add one.
  2. Does AppTheme.Fonts have a token for the font I need? If not, add one.
  3. Am I using AppTheme.Spacing for padding/spacing? If not, switch to it.

FORBIDDEN — Hardcoded Styling (Zero Tolerance)

These patterns are BANNED everywhere in feature views. Violations MUST be caught and fixed.

// BANNED — hardcoded colors
.foregroundStyle(.white)                    // use AppTheme.Colors.textPrimary
.foregroundStyle(.white.opacity(0.8))       // use AppTheme.Colors.textSecondary
.foregroundStyle(.white.opacity(0.6))       // use AppTheme.Colors.textTertiary
.foregroundStyle(.black)                    // use AppTheme.Colors.textPrimary
.foregroundStyle(Color.red)                 // define AppTheme.Colors.error or semantic token
.foregroundStyle(Color.blue)                // define AppTheme.Colors.accent or semantic token
.background(.blue)                          // use AppTheme.Colors.* token
.background(Color(hex: "FF0000"))           // define in AppTheme.Colors, reference the token
.tint(.white)                               // use AppTheme.Colors.* token

// BANNED — hardcoded fonts
.font(.system(size: 48))                    // use AppTheme.Fonts.* token
.font(.system(size: 64))                    // use AppTheme.Fonts.* token
.font(.system(.largeTitle, design: .rounded, weight: .bold))  // define in AppTheme.Fonts
.font(.title2)                              // use AppTheme.Fonts.title2
.font(.caption)                             // use AppTheme.Fonts.caption
.font(.headline)                            // use AppTheme.Fonts.headline

// BANNED — hardcoded spacing
.padding(20)                                // use AppTheme.Spacing.*
.padding(.horizontal, 12)                   // use AppTheme.Spacing.*
VStack(spacing: 10)                         // use AppTheme.Spacing.*
// CORRECT — always use AppTheme tokens
.foregroundStyle(AppTheme.Colors.textPrimary)   // system adaptive — auto-adapts to appearance
.foregroundStyle(AppTheme.Colors.textSecondary)
.font(AppTheme.Fonts.title2)
.font(AppTheme.Fonts.caption)
.padding(AppTheme.Spacing.md)
.padding(.horizontal, AppTheme.Spacing.sm)
VStack(spacing: AppTheme.Spacing.sm)
.background(AppTheme.Colors.background)         // custom palette hex — brand identity

AppTheme Pattern

Every app MUST use a centralized theme with nested enums for Colors, Fonts, and Spacing. Do NOT use a flat enum with top-level static properties.

// REQUIRED — always use nested enums
import SwiftUI

enum AppTheme {
    enum Colors {
        // Brand/theme colors — custom hex from palette
        static let primary = Color(hex: "...")
        static let secondary = Color(hex: "...")
        static let accent = Color(hex: "...")
        static let background = Color(hex: "...")
        static let surface = Color(hex: "...")

        // Text colors — REQUIRED — use UIKit adaptive colors
        // These auto-adapt to the locked appearance (Dark or Light).
        static let textPrimary = Color(.label)
        static let textSecondary = Color(.secondaryLabel)
        static let textTertiary = Color(.tertiaryLabel)
    }

    enum Fonts {
        static let largeTitle = Font.system(.largeTitle, design: .rounded, weight: .bold)
        static let title = Font.system(.title, design: .rounded, weight: .bold)
        static let title2 = Font.system(.title2, design: .rounded, weight: .semibold)
        static let title3 = Font.system(.title3, design: .rounded, weight: .semibold)
        static let headline = Font.system(.headline, design: .rounded)
        static let body = Font.system(.body, design: .rounded)
        static let callout = Font.system(.callout, design: .rounded)
        static let subheadline = Font.system(.subheadline, design: .rounded)
        static let footnote = Font.system(.footnote, design: .rounded)
        static let caption = Font.system(.caption, design: .rounded)
        static let caption2 = Font.system(.caption2, design: .rounded)

        // Icon-specific sizes — use when SF Symbols need a size that doesn't match a text style
        static let heroIcon = Font.system(size: 80, weight: .regular, design: .rounded)
        static let bulletIcon = Font.system(size: 6, weight: .regular, design: .rounded)
    }

    enum Spacing {
        static let xs: CGFloat = 4
        static let sm: CGFloat = 8
        static let md: CGFloat = 16
        static let lg: CGFloat = 24
        static let xl: CGFloat = 40
    }

    enum Style {
        static let cornerRadius: CGFloat = 12
        static let cardCornerRadius: CGFloat = 16
    }
}
// FORBIDDEN — never use flat structure
enum AppTheme {
    static let accentColor = Color.blue   // wrong
    static let spacing: CGFloat = 8       // wrong
}

Reference as: AppTheme.Colors.accent, AppTheme.Fonts.headline, AppTheme.Spacing.md

Fonts — AppTheme.Fonts Required

The Fonts enum MUST exist in every AppTheme. It defines the app's typography tokens using the font design from the plan.

Rules:

  • System fonts only — use SwiftUI font styles: .largeTitle, .title, .headline, .body, .caption
  • Apply the plan's fontDesign (rounded, serif, monospaced, default) via Font.system(.style, design: .rounded)
  • NEVER use raw .font(.title2) or .font(.headline) in views — always AppTheme.Fonts.title2
  • NEVER use .font(.system(size: N)) inline — it opts out of Dynamic Type and violates the token rule
  • When SF Symbol icons need a specific size that doesn't match a text style, create a named token in AppTheme.Fonts (e.g. heroIcon, bulletIcon) — never use .font(.system(size: N)) inline
  • No custom fonts, no downloaded fonts

Text Colors — System Adaptive via UIKit

Every AppTheme MUST define text color tokens using UIKit's adaptive system colors. These automatically adapt to the locked appearance mode (Dark or Light) set via Info.plist, so text is always legible against the palette background.

// REQUIRED — UIKit adaptive text colors
static let textPrimary = Color(.label)              // white in Dark, black in Light
static let textSecondary = Color(.secondaryLabel)    // adapts opacity per appearance
static let textTertiary = Color(.tertiaryLabel)      // adapts opacity per appearance

Do NOT use static colors for text — no Color.white, Color.black, Color.primary, or .opacity() on raw colors. The appearance lock ensures UIKit adaptive colors pick the correct variant automatically.

| Instead of | Use | |---|---| | .foregroundStyle(.white) | AppTheme.Colors.textPrimary | | .foregroundStyle(.black) | AppTheme.Colors.textPrimary | | .foregroundStyle(.white.opacity(0.8)) | AppTheme.Colors.textSecondary | | .foregroundStyle(.white.opacity(0.6)) | AppTheme.Colors.textTertiary | | .foregroundStyle(.secondary) | AppTheme.Colors.textSecondary | | .foregroundStyle(Color.primary) | AppTheme.Colors.textPrimary |

macOS note: On macOS, use Color(.labelColor), Color(.secondaryLabelColor), Color(.tertiaryLabelColor) instead — AppKit uses different names than UIKit. For multi-platform shared code, use #if canImport(UIKit) / #if canImport(AppKit) to select the correct initializer.

Icons (SF Symbols)

  • SF Symbols only for all icons — required for every list row, button, empty state, and tab
  • Reference via Image(systemName: "symbol.name")
  • Pick domain-appropriate symbols (e.g. "checkmark.circle.fill" for todos, "note.text" for notes, "heart.fill" for favorites)
  • Use .symbolRenderingMode(.hierarchical) or .symbolRenderingMode(.palette) for visual depth
  • No custom icon assets unless the app concept specifically requires them

Color(hex:) Extension

Every app MUST define a Color(hex:) initializer in AppTheme.swift so palette hex values can be used:

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: .init(charactersIn: "#"))
        let scanner = Scanner(string: hex)
        var rgbValue: UInt64 = 0
        scanner.scanHexInt64(&rgbValue)
        self.init(
            red: Double((rgbValue & 0xFF0000) >> 16) / 255.0,
            green: Double((rgbValue & 0x00FF00) >> 8) / 255.0,
            blue: Double(rgbValue & 0x0000FF) / 255.0
        )
    }
}

When the app has appearance switching (dark/light/system), also define:

iOS / tvOS / visionOS (UIKit available):

extension Color {
    init(light: String, dark: String) {
        self.init(uiColor: UIColor { traits in
            traits.userInterfaceStyle == .dark ? UIColor(Color(hex: dark)) : UIColor(Color(hex: light))
        })
    }
}

macOS (AppKit, no UIKit):

extension Color {
    init(light: String, dark: String) {
        self.init(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
            let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
            return isDark ? NSColor(Color(hex: dark)) : NSColor(Color(hex: light))
        }))
    }
}

Multi-platform shared code — use #if canImport:

extension Color {
    init(light: String, dark: String) {
        #if canImport(UIKit)
        self.init(uiColor: UIColor { traits in
            traits.userInterfaceStyle == .dark ? UIColor(Color(hex: dark)) : UIColor(Color(hex: light))
        })
        #elseif canImport(AppKit)
        self.init(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
            let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
            return isDark ? NSColor(Color(hex: dark)) : NSColor(Color(hex: light))
        }))
        #endif
    }
}

Appearance Mode — Palette-Aware Lock via Info.plist

When the app does NOT support both light and dark appearances (no dark-mode rule key), the pipeline automatically locks appearance via Info.plist based on palette brightness:

  • Dark palette (background brightness < 128): UIUserInterfaceStyle is set to Dark. System chrome (status bar, navigation bars, alerts, sheets, pickers) renders in dark mode to match the app's dark background.
  • Light palette (background brightness >= 128 or no palette): UIUserInterfaceStyle is set to Light.
  • dark-mode rule key: No lock — the app supports adaptive light/dark/system via a user preference picker.
  • visionOS: No appearance lock needed — visionOS has no dark mode. The glass material auto-adapts to the physical environment.
  • macOS: No appearance lock. macOS apps always follow the system appearance (dark/light). Users expect Mac apps to respect their system preference.

This is handled at the XcodeGen project generation level — do NOT use .preferredColorScheme() for this purpose. The Info.plist approach ensures the entire app (including system chrome, alerts, and sheets) respects the locked appearance, not just SwiftUI views.

Text colors auto-adapt: Because the appearance is locked at the system level, UIKit adaptive colors (Color(.label), Color(.secondaryLabel), Color(.tertiaryLabel)) automatically pick the correct variant. Dark-locked apps get white text; light-locked apps get black text. No manual color switching needed.

When the app supports dark mode (dark-mode in rule_keys with Color(light:dark:) adaptive tokens), the pipeline omits these keys and the app follows the system appearance.

Colors

  • Brand/theme tokens (primary, secondary, accent, background, surface) use Color(hex:) with palette values — these are the app's visual identity
  • Text tokens (textPrimary, textSecondary, textTertiary) use UIKit adaptive colors (Color(.label), etc.) — these auto-adapt to the appearance lock
  • One accent color that fits the app's purpose
  • Do NOT add dark mode support, colorScheme checks, or custom dark/light color handling unless the user explicitly requests it
  • NEVER use hardcoded SwiftUI colors like .blue, .orange, .white, .black directly in views
  • Keep brand/surface tokens explicit in AppTheme so appearance changes do not shift core palette identity

Platform-Specific Color Rules

  • visionOS: AppTheme colors are ONLY for accent buttons, badges, and small decorative elements. NEVER use AppTheme.Colors for backgrounds or body text. Use system glass and vibrancy instead.
  • tvOS: AppTheme palette must use muted, desaturated colors. Saturation is overwhelming on large TV screens. Dark-first design — light text on dark backgrounds.
  • macOS: Standard color usage similar to iOS. Sidebar icons should be monochrome (system handles tinting).
  • iOS: Full AppTheme color palette usage is appropriate.

Spacing Standards

  • 16pt standard padding (outer margins, section spacing)
  • 8pt compact spacing (between related elements)
  • 24pt large spacing (between major sections)
  • Use AppTheme.Spacing constants throughout — never raw numeric values

Empty States

Every list or collection MUST have an empty state. Use ContentUnavailableView (iOS 17+) for a polished look:

if items.isEmpty {
    ContentUnavailableView(
        "No Notes Yet",
        systemImage: "note.text",
        description: Text("Tap + to create your first note")
    )
} else {
    // Show the list
}

Surface Materials

Map the design surfaces token to SwiftUI materials:

  • glass -> .ultraThinMaterial (modern/translucent)
  • material -> .regularMaterial (depth/layers)
  • solid -> opaque Color from palette (clean/opaque)
  • flat -> no shadows, no materials (minimal)

Sheet Sizing

Always specify presentationDetents on .sheet:

  • Small option pickers -> .height(N) (calculate based on content)
  • Medium forms -> .medium
  • Complex multi-section -> .large
  • Use .sheet / .fullScreenCover for creation forms

Animations

Use subtle, purposeful animations for state changes and list mutations:

withAnimation(.spring) {
    item.isComplete.toggle()
}

.transition(.opacity.combined(with: .scale))
.contentTransition(.numericText())
.animation(.default, value: selectedFilter)

Rules:

  • Always use withAnimation(.spring) for toggle/complete state changes
  • Always add .transition(.opacity.combined(with: .scale)) for list add/remove
  • Never add gratuitous motion that slows down interaction
  • Keep animations subtle — .spring and .default curves only