SwiftKitUI.swift

Design tokens, the five-curve motion system, haptics, and surface styles including iOS 26 Liquid Glass. Every component depends on this and nothing else. Install it once.

SwiftKitUI.swift
//
//  SwiftKitUI.swift
//  Swift Kit UI — the foundation.
//
//  This is the ONLY file every component depends on. Add it once, then each
//  component is a single self-contained file you can drop in beside it.
//
//  That rule is deliberate. A component library with a tangled internal
//  dependency graph is one an AI agent gets wrong: it copies one file, misses
//  three others, and the build breaks. One foundation + one file per component
//  is small enough to fit in an agent's head and in yours.
//
//  Requires iOS 17 / macOS 14. iOS 26 features (Liquid Glass) are behind
//  #available and degrade to a hand-built approximation below that.
//
//  https://theswiftk.it.com/ui
//

import SwiftUI
#if canImport(UIKit)
import UIKit
#endif

// MARK: - Namespace

/// Design tokens for Swift Kit UI.
///
/// Every component reads its spacing, radii, colour and type from here, which
/// is what makes forty components look like one product instead of forty
/// snippets. Override these values once and the whole kit re-skins.
public enum SK {}

// MARK: - Spacing

extension SK {
    /// A 4pt base scale. Components never hard-code padding.
    public enum Space {
        public static let xxs: CGFloat = 2
        public static let xs: CGFloat = 4
        public static let sm: CGFloat = 8
        public static let md: CGFloat = 12
        public static let lg: CGFloat = 16
        public static let xl: CGFloat = 24
        public static let xxl: CGFloat = 32
        public static let xxxl: CGFloat = 48
    }

    public enum Radius {
        public static let sm: CGFloat = 8
        public static let md: CGFloat = 12
        public static let lg: CGFloat = 16
        public static let xl: CGFloat = 22
        public static let xxl: CGFloat = 28
        /// Use with `.capsule`-style shapes where a true pill is wanted.
        public static let pill: CGFloat = 999
    }

    /// Stroke widths. Hairlines are expressed in points, not pixels, so they
    /// stay visually consistent across scale factors.
    public enum Stroke {
        public static let hairline: CGFloat = 0.5
        public static let thin: CGFloat = 1
        public static let thick: CGFloat = 2
    }
}

// MARK: - Colour

extension SK {
    /// Semantic colours. Always dynamic — every one resolves correctly in both
    /// light and dark without an asset catalogue, so a single copied file works.
    public enum Palette {
        public static let accent = dynamic(light: 0x7C3AED, dark: 0x9B6BFF)

        /// Tinted backgrounds for badges, icon bubbles and selected states.
        /// Use these rather than `.opacity()` on a solid colour — opacity
        /// composites against whatever is behind it and drifts between screens.
        public static let accentSoft = dynamic(light: 0xEDE7FE, dark: 0x2A1F45)
        public static let successSoft = dynamic(light: 0xDDF5E8, dark: 0x11301F)
        public static let warningSoft = dynamic(light: 0xFDF0DC, dark: 0x33260F)
        public static let dangerSoft = dynamic(light: 0xFCE3E5, dark: 0x3A171B)

        public static let surface = dynamic(light: 0xFFFFFF, dark: 0x101014)
        public static let surfaceElevated = dynamic(light: 0xFFFFFF, dark: 0x1A1A20)
        public static let surfaceSunken = dynamic(light: 0xF4F4F6, dark: 0x0A0A0D)

        public static let label = dynamic(light: 0x0B0B0F, dark: 0xFFFFFF)
        public static let labelSecondary = dynamic(light: 0x5B5B66, dark: 0x9C9CA8)
        public static let labelTertiary = dynamic(light: 0x8E8E99, dark: 0x6B6B78)

        public static let separator = dynamic(light: 0xE2E2E8, dark: 0x2C2C35)
        public static let fill = dynamic(light: 0xEDEDF1, dark: 0x22222A)

        /// Skeleton and placeholder blocks. Deliberately darker than `fill` in
        /// light mode: `fill` is tuned to sit on `surface` (white), and on a
        /// tinted or sunken background it washes out to invisible.
        public static let placeholder = dynamic(light: 0xDDDDE6, dark: 0x2A2A34)

        public static let success = dynamic(light: 0x11A35B, dark: 0x30D158)
        public static let warning = dynamic(light: 0xC77700, dark: 0xFFB340)
        public static let danger = dynamic(light: 0xD62F3E, dark: 0xFF5A65)

        /// Builds a colour that resolves per colour scheme. Hex in, `Color` out.
        public static func dynamic(light: UInt32, dark: UInt32) -> Color {
            #if canImport(UIKit)
            return Color(UIColor { traits in
                UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light)
            })
            #elseif canImport(AppKit)
            return Color(nsColor: NSColor(name: nil) { appearance in
                let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
                return NSColor(rgb: isDark ? dark : light)
            })
            #else
            return Color(rgb: light)
            #endif
        }
    }
}

// MARK: - Typography

extension SK {
    /// A type ramp built on Dynamic Type. Every style scales with the user's
    /// text-size setting — a component that ignores that is not shippable.
    public enum Type_ {
        public static let display = Font.system(.largeTitle, design: .rounded, weight: .bold)
        public static let title = Font.system(.title2, design: .rounded, weight: .semibold)
        public static let headline = Font.system(.headline, design: .rounded, weight: .semibold)
        public static let body = Font.system(.body, design: .rounded, weight: .regular)
        public static let callout = Font.system(.callout, design: .rounded, weight: .medium)
        public static let caption = Font.system(.caption, design: .rounded, weight: .medium)
        /// Monospaced digits so counters and timers do not jitter mid-animation.
        public static let numeric = Font.system(.body, design: .rounded, weight: .semibold)
            .monospacedDigit()

        /// Code. Ligatures off is deliberate: in a code block `!=` rendering as
        /// a single glyph is a nice touch in an editor and a liability in a
        /// snippet someone is about to retype.
        public static let mono = Font.system(.callout, design: .monospaced, weight: .regular)
        public static let monoCaption = Font.system(.caption, design: .monospaced, weight: .regular)
    }

    /// Icon and control sizing.
    ///
    /// Exists because SF Symbols are sized in points, not Dynamic Type styles,
    /// so `Type_` cannot express them. Without this scale every component
    /// invents its own `.system(size:)` and the icons drift out of alignment.
    public enum Icon {
        public static let xs: CGFloat = 11
        public static let sm: CGFloat = 13
        public static let md: CGFloat = 15
        public static let lg: CGFloat = 20
        public static let xl: CGFloat = 26

        /// Apple's minimum comfortable touch target. Anything interactive
        /// should reach this, padding included.
        public static let hitTarget: CGFloat = 44

        /// `Image(systemName: "star").font(SK.Icon.symbol(SK.Icon.md))`
        public static func symbol(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font {
            .system(size: size, weight: weight)
        }
    }
}

// MARK: - Motion

/// A named animation curve.
///
/// The kit uses five. Consistency of *motion* is what makes a set of components
/// feel like one product — the moment one button eases and another springs, the
/// illusion breaks. Reach for a preset; do not hand-roll `.easeInOut` in a
/// component.
public struct SKMotion: Equatable, Sendable {
    public let standard: Animation
    /// Substituted when the user has Reduce Motion on. `nil` means "no
    /// animation at all", which is the right answer for anything that moves
    /// across the screen.
    public let reduced: Animation?

    public init(_ standard: Animation, reduced: Animation? = nil) {
        self.standard = standard
        self.reduced = reduced
    }

    /// Press states and other sub-100ms feedback. Never bounces — overshoot on
    /// a press feels like lag.
    public static let micro = SKMotion(
        .spring(duration: 0.18, bounce: 0),
        reduced: .easeOut(duration: 0.1)
    )

    /// The workhorse. Toggles, selection, tab changes. A 0.12 bounce reads as
    /// "brisk" rather than "bouncy".
    public static let snap = SKMotion(
        .spring(duration: 0.32, bounce: 0.12),
        reduced: .easeOut(duration: 0.15)
    )

    /// Cards, sheets, anything with mass. Slightly more overshoot so it feels
    /// like it settles rather than stops.
    public static let settle = SKMotion(
        .spring(duration: 0.48, bounce: 0.18),
        reduced: .easeOut(duration: 0.2)
    )

    /// Large or ambient movement. No bounce — at this distance overshoot looks
    /// like a mistake.
    public static let glide = SKMotion(
        .spring(duration: 0.65, bounce: 0),
        reduced: .easeOut(duration: 0.2)
    )

    /// Delight only: success ticks, celebrations, attention pulls. Use sparingly.
    public static let pop = SKMotion(
        .spring(duration: 0.4, bounce: 0.42),
        reduced: nil
    )

    /// Resolves to the curve that should actually run.
    public func resolved(reduceMotion: Bool) -> Animation? {
        reduceMotion ? reduced : standard
    }
}

private struct SKAnimationModifier<V: Equatable>: ViewModifier {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    let motion: SKMotion
    let value: V

    func body(content: Content) -> some View {
        content.animation(motion.resolved(reduceMotion: reduceMotion), value: value)
    }
}

extension View {
    /// Animates on `value` using a kit motion preset, honouring Reduce Motion.
    ///
    /// Prefer this over `.animation(_:value:)` everywhere in the kit — it is the
    /// single place accessibility is handled, so no component can forget.
    public func skAnimation<V: Equatable>(_ motion: SKMotion, value: V) -> some View {
        modifier(SKAnimationModifier(motion: motion, value: value))
    }
}

// MARK: - Haptics

extension SK {
    /// Thin wrapper so components can add feedback without importing UIKit or
    /// breaking on platforms that have no haptics.
    ///
    /// `@MainActor` because UIFeedbackGenerator is main-actor isolated under
    /// Swift 6. Every call site is SwiftUI view code, which is already on the
    /// main actor, so this costs callers nothing — and it keeps the kit building
    /// warning-free in strict-concurrency projects.
    @MainActor
    public enum Haptic {
        public enum Style { case light, medium, heavy, soft, rigid, success, warning, error, selection }

        public static func play(_ style: Style) {
            #if canImport(UIKit) && !os(tvOS) && !os(watchOS)
            switch style {
            case .success: UINotificationFeedbackGenerator().notificationOccurred(.success)
            case .warning: UINotificationFeedbackGenerator().notificationOccurred(.warning)
            case .error:   UINotificationFeedbackGenerator().notificationOccurred(.error)
            case .selection: UISelectionFeedbackGenerator().selectionChanged()
            case .light:  UIImpactFeedbackGenerator(style: .light).impactOccurred()
            case .medium: UIImpactFeedbackGenerator(style: .medium).impactOccurred()
            case .heavy:  UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
            case .soft:   UIImpactFeedbackGenerator(style: .soft).impactOccurred()
            case .rigid:  UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
            }
            #endif
        }
    }
}

// MARK: - Surfaces

extension SK {
    /// How a raised surface is drawn. `.liquidGlass` uses Apple's native iOS 26
    /// effect where available and falls back to a hand-built approximation, so
    /// it is safe to leave on with a deployment target of 17.
    public enum Surface: Sendable {
        case flat
        case bordered
        case elevated
        /// Translucent bar with a top hairline. For pinned footers and toolbars
        /// that sit over scrolling content.
        case bar
        case liquidGlass
    }
}

private struct SKSurfaceModifier: ViewModifier {
    let style: SK.Surface
    let radius: CGFloat

    private var shape: RoundedRectangle {
        RoundedRectangle(cornerRadius: radius, style: .continuous)
    }

    func body(content: Content) -> some View {
        switch style {
        case .flat:
            content.background(SK.Palette.surface, in: shape)

        case .bordered:
            content
                .background(SK.Palette.surface, in: shape)
                .overlay(shape.strokeBorder(SK.Palette.separator, lineWidth: SK.Stroke.thin))

        case .elevated:
            content
                .background(SK.Palette.surfaceElevated, in: shape)
                .shadow(color: .black.opacity(0.10), radius: 14, x: 0, y: 6)
                .shadow(color: .black.opacity(0.04), radius: 2, x: 0, y: 1)

        case .bar:
            content
                .background(.ultraThinMaterial, in: Rectangle())
                .overlay(alignment: .top) {
                    Rectangle()
                        .fill(SK.Palette.separator)
                        .frame(height: SK.Stroke.hairline)
                }

        case .liquidGlass:
            if #available(iOS 26, macOS 26, *) {
                content.glassEffect(.regular, in: shape)
            } else {
                content
                    .background(.ultraThinMaterial, in: shape)
                    .overlay(
                        shape.strokeBorder(
                            LinearGradient(
                                colors: [.white.opacity(0.45), .white.opacity(0.08)],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            ),
                            lineWidth: SK.Stroke.thin
                        )
                    )
                    .shadow(color: .black.opacity(0.12), radius: 18, x: 0, y: 8)
            }
        }
    }
}

extension View {
    /// Applies a kit surface treatment.
    public func skSurface(_ style: SK.Surface, radius: CGFloat = SK.Radius.lg) -> some View {
        modifier(SKSurfaceModifier(style: style, radius: radius))
    }
}

// MARK: - Colour helpers

#if canImport(UIKit)
extension UIColor {
    fileprivate convenience init(rgb: UInt32) {
        self.init(
            red: CGFloat((rgb >> 16) & 0xFF) / 255,
            green: CGFloat((rgb >> 8) & 0xFF) / 255,
            blue: CGFloat(rgb & 0xFF) / 255,
            alpha: 1
        )
    }
}
#elseif canImport(AppKit)
extension NSColor {
    fileprivate convenience init(rgb: UInt32) {
        self.init(
            srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
            green: CGFloat((rgb >> 8) & 0xFF) / 255,
            blue: CGFloat(rgb & 0xFF) / 255,
            alpha: 1
        )
    }
}
#else
extension Color {
    fileprivate init(rgb: UInt32) {
        self.init(
            red: Double((rgb >> 16) & 0xFF) / 255,
            green: Double((rgb >> 8) & 0xFF) / 255,
            blue: Double(rgb & 0xFF) / 255
        )
    }
}
#endif


// MARK: - Shared press interaction

/// Scale-on-press with an optional haptic, for anything tappable that is not a
/// `SKButton` — cards, rows, chips, tiles.
///
/// Wrap the content in a `Button` and apply this. Using a real `Button` rather
/// than `.onTapGesture` is deliberate: it gets VoiceOver, keyboard focus,
/// pointer hover and the correct accessibility traits for free.
public struct SKPressStyle: ButtonStyle {
    private let scale: CGFloat
    private let haptic: SK.Haptic.Style?

    public init(scale: CGFloat = 0.98, haptic: SK.Haptic.Style? = .light) {
        self.scale = scale
        self.haptic = haptic
    }

    public func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? scale : 1)
            .skAnimation(.micro, value: configuration.isPressed)
            .onChange(of: configuration.isPressed) { _, pressed in
                if pressed, let haptic { SK.Haptic.play(haptic) }
            }
    }
}

extension ButtonStyle where Self == SKPressStyle {
    /// `Button { } label: { }.buttonStyle(.skPress)`
    public static var skPress: SKPressStyle { SKPressStyle() }

    public static func skPress(scale: CGFloat = 0.98, haptic: SK.Haptic.Style? = .light) -> SKPressStyle {
        SKPressStyle(scale: scale, haptic: haptic)
    }
}

// MARK: - Shimmer

/// A travelling highlight, for skeleton placeholders.
///
/// Lives in the foundation rather than in the skeleton component because
/// several components want it (skeleton rows, loading cards, placeholder
/// avatars) and duplicating the animation would let them drift out of sync.
public struct SKShimmer: ViewModifier {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var phase: CGFloat = -1
    public var active: Bool = true

    public func body(content: Content) -> some View {
        if active && !reduceMotion {
            content
                .overlay {
                    GeometryReader { geo in
                        LinearGradient(
                            colors: [.clear, .white.opacity(0.75), .clear],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                        .frame(width: geo.size.width * 0.6)
                        .offset(x: phase * geo.size.width * 1.6)
                    }
                }
                .mask(content)
                .onAppear {
                    withAnimation(.linear(duration: 1.3).repeatForever(autoreverses: false)) {
                        phase = 1
                    }
                }
        } else {
            content
        }
    }
}

extension View {
    /// Applies a shimmering highlight. No-ops under Reduce Motion.
    public func skShimmer(_ active: Bool = true) -> some View {
        modifier(SKShimmer(active: active))
    }
}


// MARK: - Cross-platform text input

extension SK {
    /// Autocapitalisation, expressed once so components do not each carry
    /// `#if os(iOS)`. `TextInputAutocapitalization` does not exist on macOS,
    /// and a component riddled with platform conditionals is a component an
    /// agent copies wrong.
    public enum Capitalization: Sendable {
        case never, words, sentences, characters
    }

    /// Keyboard types the kit actually uses.
    public enum Keyboard: Sendable {
        case `default`, email, number, phone, url
    }
}

extension View {
    @ViewBuilder
    public func skCapitalization(_ mode: SK.Capitalization?) -> some View {
        #if os(iOS) || os(tvOS)
        switch mode {
        case .never: self.textInputAutocapitalization(.never)
        case .words: self.textInputAutocapitalization(.words)
        case .sentences: self.textInputAutocapitalization(.sentences)
        case .characters: self.textInputAutocapitalization(.characters)
        case nil: self
        }
        #else
        self
        #endif
    }

    @ViewBuilder
    public func skKeyboard(_ type: SK.Keyboard) -> some View {
        #if os(iOS)
        switch type {
        case .default: self.keyboardType(.default)
        case .email: self.keyboardType(.emailAddress)
        case .number: self.keyboardType(.numberPad)
        case .phone: self.keyboardType(.phonePad)
        case .url: self.keyboardType(.URL)
        }
        #else
        self
        #endif
    }

    /// One-time-code content type, for SMS autofill. No-op off iOS.
    @ViewBuilder
    public func skOneTimeCode() -> some View {
        #if os(iOS)
        self.textContentType(.oneTimeCode)
        #else
        self
        #endif
    }
}