Initial commit: Bedrock design system and UI component library

- Design system: spacing, typography, colors, animations, opacity, shadows
- Protocol-based color theming (AppColorTheme)
- Settings UI components: toggles, pickers, selection indicators
- Sound and haptic feedback manager (generic AppSound protocol)
- Onboarding state management
- Cloud sync manager (PersistableData protocol)
- Visual effects: ConfettiView, PulsingModifier
- Debug utilities: DebugBorderModifier
- Device information utilities (cross-platform)
- Unit tests for design constants
This commit is contained in:
Matt Bruce 2026-01-02 11:58:30 -06:00
commit fa7d848f52
18 changed files with 2859 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Swift Package Manager
.build/
.swiftpm/
Package.resolved
# Xcode
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
DerivedData/
xcuserdata/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Vim
*.swp
*.swo
# VSCode
.vscode/
# Temporary files
*.tmp
*.temp

33
Package.swift Normal file
View File

@ -0,0 +1,33 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Bedrock",
defaultLocalization: "en",
platforms: [
.iOS(.v18),
.macOS(.v15)
],
products: [
.library(
name: "Bedrock",
targets: ["Bedrock"]
)
],
dependencies: [],
targets: [
.target(
name: "Bedrock",
dependencies: [],
resources: [
.process("Resources")
]
),
.testTarget(
name: "BedrockTests",
dependencies: ["Bedrock"]
)
]
)

207
README.md Normal file
View File

@ -0,0 +1,207 @@
# Bedrock
A foundational design system and UI component library for building consistent, beautiful SwiftUI applications.
## Overview
Bedrock is designed to be the foundation upon which apps are built, providing:
- **Design System**: Consistent spacing, typography, and animations
- **Color Protocols**: Define consistent color naming with custom palettes per app
- **Settings Components**: Ready-to-use toggle, picker, and selector views
- **Utilities**: Common helpers for device detection, debugging, and more
## Installation
Add Bedrock as a dependency in your `Package.swift`:
```swift
dependencies: [
.package(path: "../Bedrock")
]
```
Then add it to your target:
```swift
.target(
name: "YourApp",
dependencies: ["Bedrock"]
)
```
## Usage
### Design Constants
Use the `Design` enum for consistent spacing, sizing, and styling:
```swift
import Bedrock
struct MyView: View {
var body: some View {
VStack(spacing: Design.Spacing.medium) {
Text("Hello")
.font(.system(size: Design.BaseFontSize.title))
.padding(Design.Spacing.large)
Button("Tap Me") { }
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
}
}
```
### Color System
Bedrock provides a **protocol-based color system** that enforces consistent naming while allowing apps to define their own palettes.
#### Using Default Colors
Bedrock includes neutral default colors that work out of the box:
```swift
Text("Primary Text")
.foregroundStyle(Color.Text.primary)
Text("Secondary Text")
.foregroundStyle(Color.Text.secondary)
VStack { }
.background(Color.Surface.primary)
```
#### Creating Custom Color Themes
Apps can define custom color palettes by conforming to the color protocols:
```swift
// In CasinoKit - define casino-themed accent colors
public enum CasinoAccentColors: AccentColorProvider {
public static let primary = Color(red: 0.9, green: 0.75, blue: 0.3) // Gold
public static let light = Color(red: 1.0, green: 0.85, blue: 0.4)
public static let dark = Color(red: 0.7, green: 0.55, blue: 0.2)
public static let secondary = Color(red: 0.2, green: 0.7, blue: 0.7) // Teal
}
// Create a complete theme
public enum CasinoTheme: AppColorTheme {
public typealias Surface = CasinoSurfaceColors
public typealias Text = DefaultTextColors // Reuse Bedrock defaults
public typealias Accent = CasinoAccentColors // Custom gold accents
public typealias Button = CasinoButtonColors
public typealias Status = DefaultStatusColors // Reuse Bedrock defaults
public typealias Border = DefaultBorderColors
public typealias Interactive = DefaultInteractiveColors
}
// Use in views
Button("Deal") { }
.background(CasinoTheme.Accent.primary)
```
#### Available Color Protocols
| Protocol | Properties |
|----------|------------|
| `SurfaceColorProvider` | primary, secondary, tertiary, overlay, card, groupedFill, sectionFill |
| `TextColorProvider` | primary, secondary, tertiary, disabled, placeholder, inverse |
| `AccentColorProvider` | primary, light, dark, secondary |
| `ButtonColorProvider` | primaryLight, primaryDark, secondary, destructive, cancelText |
| `StatusColorProvider` | success, warning, error, info |
| `BorderColorProvider` | subtle, standard, emphasized, selected |
| `InteractiveColorProvider` | selected, hover, pressed, focus |
### Settings Components
Ready-to-use settings UI components:
```swift
// Toggle with title and subtitle
SettingsToggle(
title: "Notifications",
subtitle: "Receive push notifications",
isOn: $notificationsEnabled
)
// Volume slider
VolumePicker(label: "Volume", volume: $soundVolume)
// Segmented picker
SegmentedPicker(
title: "Theme",
options: [("Light", "light"), ("Dark", "dark"), ("Auto", "auto")],
selection: $theme
)
// Selectable row
SelectableRow(
title: "Premium Plan",
subtitle: "Unlock all features",
isSelected: plan == .premium,
badge: { BadgePill(text: "$9.99", isSelected: plan == .premium) },
action: { plan = .premium }
)
```
### Device Detection
Adapt your UI based on device characteristics:
```swift
if DeviceInfo.isPad {
SidebarView()
} else {
TabBarView()
}
```
## Design System Reference
### Spacing
| Name | Value | Usage |
|------|-------|-------|
| `xxxSmall` | 1pt | Hairline spacing |
| `xxSmall` | 2pt | Minimal spacing |
| `xSmall` | 4pt | Tight spacing |
| `small` | 8pt | Compact spacing |
| `medium` | 12pt | Default spacing |
| `large` | 16pt | Comfortable spacing |
| `xLarge` | 20pt | Generous spacing |
| `xxLarge` | 24pt | Section spacing |
| `xxxLarge` | 32pt | Large section spacing |
### Opacity
| Name | Value | Usage |
|------|-------|-------|
| `verySubtle` | 0.05 | Barely visible |
| `subtle` | 0.1 | Subtle backgrounds |
| `hint` | 0.2 | Hints and disabled states |
| `light` | 0.3 | Light overlays |
| `medium` | 0.5 | Medium emphasis |
| `strong` | 0.7 | Strong emphasis |
| `heavy` | 0.8 | Heavy overlays |
| `almostFull` | 0.9 | Nearly opaque |
### Default Colors
Default colors use a neutral blue accent. Apps should define custom themes for branding:
| Category | Purpose |
|----------|---------|
| `Color.Surface.*` | Background colors |
| `Color.Text.*` | Text colors |
| `Color.Accent.*` | Accent/highlight colors (default: blue) |
| `Color.Button.*` | Button colors |
| `Color.Status.*` | Success/warning/error colors |
| `Color.Border.*` | Border and divider colors |
| `Color.Interactive.*` | Interactive state colors |
## Requirements
- iOS 18.0+
- macOS 15.0+
- Swift 6.0+

View File

@ -0,0 +1,305 @@
//
// SoundManager.swift
// Bedrock
//
// Generic sound and haptic feedback manager.
//
import AVFoundation
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
// MARK: - Sound Protocol
/// Protocol for defining app-specific sounds.
///
/// Implement this protocol to define your app's sound effects, then use
/// `SoundManager` to play them.
///
/// ## Example
///
/// ```swift
/// enum MyAppSound: String, AppSound {
/// case success, error, notification
///
/// var resourceName: String { rawValue }
/// var resourceExtension: String { "mp3" }
/// var bundle: Bundle { .main }
/// var fallbackSystemSoundID: UInt32? { nil }
/// }
/// ```
public protocol AppSound: Sendable {
/// The resource name of the sound file.
var resourceName: String { get }
/// The file extension (e.g., "mp3", "wav", "caf").
var resourceExtension: String { get }
/// The bundle containing the sound resource.
var bundle: Bundle { get }
/// Optional system sound ID to play as fallback.
var fallbackSystemSoundID: UInt32? { get }
}
// MARK: - Haptic Type
/// Standard haptic feedback types.
public enum HapticType: Sendable {
case light
case medium
case heavy
case success
case warning
case error
case selection
case softImpact
case rigidImpact
}
// MARK: - Sound Manager
/// Manages audio playback and haptic feedback.
///
/// This is a generic sound manager that works with any app-defined sound type
/// conforming to `AppSound`.
///
/// ## Example
///
/// ```swift
/// let soundManager = SoundManager.shared
/// soundManager.play(MyAppSound.success)
/// soundManager.playHaptic(.success)
/// ```
@MainActor
@Observable
public final class SoundManager {
// MARK: - Singleton
/// Shared instance for convenience.
public static let shared = SoundManager()
// MARK: - Properties
/// Master volume for sound effects (0.0 to 1.0).
public var volume: Float = 1.0 {
didSet {
for player in audioPlayers.values {
player.volume = volume
}
}
}
/// Whether sound is enabled.
public var isSoundEnabled: Bool = true
/// Whether haptic feedback is enabled.
public var isHapticEnabled: Bool = true
// MARK: - Private Properties
private var audioPlayers: [String: AVAudioPlayer] = [:]
#if canImport(UIKit)
private let impactLight = UIImpactFeedbackGenerator(style: .light)
private let impactMedium = UIImpactFeedbackGenerator(style: .medium)
private let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
private let impactSoft = UIImpactFeedbackGenerator(style: .soft)
private let impactRigid = UIImpactFeedbackGenerator(style: .rigid)
private let notification = UINotificationFeedbackGenerator()
private let selection = UISelectionFeedbackGenerator()
#endif
// MARK: - Initialization
public init() {
configureAudioSession()
}
// MARK: - Audio Session
private func configureAudioSession() {
#if canImport(UIKit) && !os(watchOS)
do {
try AVAudioSession.sharedInstance().setCategory(
.ambient,
mode: .default,
options: [.mixWithOthers]
)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
#endif
}
// MARK: - Sound Playback
/// Plays a sound effect.
/// - Parameter sound: The sound to play, conforming to `AppSound`.
public func play<S: AppSound>(_ sound: S) {
guard isSoundEnabled else { return }
let key = "\(sound.resourceName).\(sound.resourceExtension)"
// Try cached player first
if let player = audioPlayers[key] {
player.currentTime = 0
player.volume = volume
player.play()
return
}
// Try to load the sound file
if let url = sound.bundle.url(
forResource: sound.resourceName,
withExtension: sound.resourceExtension
) {
do {
let player = try AVAudioPlayer(contentsOf: url)
player.volume = volume
player.prepareToPlay()
audioPlayers[key] = player
player.play()
return
} catch {
print("Failed to load sound \(key): \(error)")
}
}
// Fall back to system sound if available
if let systemSoundID = sound.fallbackSystemSoundID {
AudioServicesPlaySystemSound(systemSoundID)
}
}
/// Plays a system sound by ID.
/// - Parameter soundID: The system sound ID to play.
public func playSystemSound(_ soundID: UInt32) {
guard isSoundEnabled else { return }
AudioServicesPlaySystemSound(soundID)
}
/// Preloads sounds for faster playback later.
/// - Parameter sounds: Array of sounds to preload.
public func preload<S: AppSound>(_ sounds: [S]) {
for sound in sounds {
let key = "\(sound.resourceName).\(sound.resourceExtension)"
guard audioPlayers[key] == nil else { continue }
if let url = sound.bundle.url(
forResource: sound.resourceName,
withExtension: sound.resourceExtension
) {
do {
let player = try AVAudioPlayer(contentsOf: url)
player.volume = volume
player.prepareToPlay()
audioPlayers[key] = player
} catch {
print("Failed to preload sound \(key): \(error)")
}
}
}
}
// MARK: - Haptic Feedback
/// Plays haptic feedback.
/// - Parameter type: The type of haptic feedback to play.
public func playHaptic(_ type: HapticType) {
#if canImport(UIKit)
guard isHapticEnabled else { return }
switch type {
case .light:
impactLight.impactOccurred()
case .medium:
impactMedium.impactOccurred()
case .heavy:
impactHeavy.impactOccurred()
case .success:
notification.notificationOccurred(.success)
case .warning:
notification.notificationOccurred(.warning)
case .error:
notification.notificationOccurred(.error)
case .selection:
selection.selectionChanged()
case .softImpact:
impactSoft.impactOccurred()
case .rigidImpact:
impactRigid.impactOccurred()
}
#endif
}
/// Plays haptic with custom intensity.
/// - Parameters:
/// - style: The impact style.
/// - intensity: The intensity (0.0 to 1.0).
public func playHaptic(style: HapticType, intensity: CGFloat) {
#if canImport(UIKit)
guard isHapticEnabled else { return }
let generator: UIImpactFeedbackGenerator
switch style {
case .light:
generator = impactLight
case .medium:
generator = impactMedium
case .heavy:
generator = impactHeavy
case .softImpact:
generator = impactSoft
case .rigidImpact:
generator = impactRigid
default:
playHaptic(style)
return
}
generator.impactOccurred(intensity: intensity)
#endif
}
/// Prepares haptic generators for immediate feedback.
public func prepareHaptics() {
#if canImport(UIKit)
impactLight.prepare()
impactMedium.prepare()
impactHeavy.prepare()
notification.prepare()
selection.prepare()
#endif
}
// MARK: - Cleanup
/// Clears all cached audio players.
public func clearCache() {
for player in audioPlayers.values {
player.stop()
}
audioPlayers.removeAll()
}
}
// MARK: - Common System Sounds
/// Common iOS system sound IDs for convenience.
public enum SystemSound {
public static let tap: UInt32 = 1104
public static let click: UInt32 = 1105
public static let tock: UInt32 = 1306
public static let tink: UInt32 = 1057
public static let pop: UInt32 = 1306
public static let swoosh: UInt32 = 1001
public static let alert: UInt32 = 1007
public static let error: UInt32 = 1053
public static let success: UInt32 = 1025
}

View File

@ -0,0 +1,47 @@
//
// Bedrock.swift
// Bedrock
//
// A foundational design system and UI component library for building
// consistent, beautiful SwiftUI applications.
//
// Bedrock provides:
// - Design constants (spacing, typography, colors, animations)
// - Reusable settings UI components
// - Common utilities and extensions
//
import SwiftUI
/// The Bedrock module provides foundational design constants and reusable
/// UI components for building consistent SwiftUI applications.
///
/// ## Overview
///
/// Bedrock is designed to be the foundation upon which apps are built,
/// providing:
///
/// - **Design System**: Consistent spacing, typography, colors, and animations
/// - **Settings Components**: Ready-to-use toggle, picker, and selector views
/// - **Utilities**: Common helpers for device detection, debugging, and more
///
/// ## Usage
///
/// Import Bedrock and use the design constants:
///
/// ```swift
/// import Bedrock
///
/// struct MyView: View {
/// var body: some View {
/// Text("Hello")
/// .padding(Design.Spacing.medium)
/// .background(Color.Surface.primary)
/// }
/// }
/// ```
/// Bedrock library metadata and version information.
public enum BedrockInfo {
/// The current version of the Bedrock library.
public static let version = "1.0.0"
}

View File

@ -0,0 +1,14 @@
//
// Exports.swift
// Bedrock
//
// Re-exports for convenient importing.
//
import SwiftUI
// Re-export SwiftUI for consumers who only import Bedrock
@_exported import SwiftUI
// Re-export Foundation for common types
@_exported import Foundation

View File

@ -0,0 +1,141 @@
//
// OnboardingState.swift
// Bedrock
//
// Tracks first-time user onboarding progress and contextual hints.
// Generic implementation that works for any app.
//
import Foundation
import SwiftUI
/// Observable state for managing user onboarding and progressive feature discovery.
///
/// Use this to track whether users have seen welcome screens, tutorials, and
/// contextual hints throughout your app.
///
/// ## Example
///
/// ```swift
/// @Observable
/// class AppState {
/// let onboarding = OnboardingState(appIdentifier: "myApp")
///
/// var showWelcome: Bool {
/// !onboarding.hasCompletedWelcome
/// }
/// }
/// ```
@Observable
@MainActor
public final class OnboardingState {
// MARK: - Properties
/// Whether the user has launched the app before.
public var hasLaunchedBefore: Bool = false
/// Whether the user has completed the welcome/intro screen.
public var hasCompletedWelcome: Bool = false
/// Whether the user is in tutorial mode (shows all contextual hints).
public var isTutorialMode: Bool = false
/// Set of hint keys that have been shown to the user.
public var hintsShown: Set<String> = []
/// Hint keys registered by the app for automatic skipping.
private var registeredHintKeys: Set<String> = []
// MARK: - Private Properties
private let persistenceKey: String
// MARK: - Initialization
/// Creates an onboarding state manager.
/// - Parameter appIdentifier: Unique identifier for this app's onboarding data.
public init(appIdentifier: String) {
self.persistenceKey = "onboarding.\(appIdentifier)"
load()
}
/// Registers hint keys that should be marked as shown when skipping onboarding.
/// Call this once during app setup with all hint keys used by the app.
public func registerHintKeys(_ keys: Set<String>) {
registeredHintKeys = keys
}
/// Registers hint keys that should be marked as shown when skipping onboarding.
public func registerHintKeys(_ keys: String...) {
registeredHintKeys = Set(keys)
}
// MARK: - Hint Management
/// Marks a hint as shown and persists the state.
public func markHintShown(_ key: String) {
hintsShown.insert(key)
save()
}
/// Returns whether a specific hint should be shown.
public func shouldShowHint(_ key: String) -> Bool {
isTutorialMode || !hintsShown.contains(key)
}
/// Marks the welcome screen as completed.
public func completeWelcome() {
hasLaunchedBefore = true
hasCompletedWelcome = true
save()
}
/// Skips onboarding entirely - marks all registered hints as shown.
public func skipOnboarding() {
for key in registeredHintKeys {
hintsShown.insert(key)
}
hasLaunchedBefore = true
hasCompletedWelcome = true
save()
}
/// Enables tutorial mode (shows all hints again).
public func startTutorialMode() {
isTutorialMode = true
}
/// Disables tutorial mode.
public func endTutorialMode() {
isTutorialMode = false
}
/// Resets all onboarding state.
public func reset() {
hasLaunchedBefore = false
hasCompletedWelcome = false
isTutorialMode = false
hintsShown.removeAll()
save()
}
// MARK: - Persistence
private func load() {
let defaults = UserDefaults.standard
hasLaunchedBefore = defaults.bool(forKey: "\(persistenceKey).hasLaunched")
hasCompletedWelcome = defaults.bool(forKey: "\(persistenceKey).hasCompletedWelcome")
if let hintsData = defaults.array(forKey: "\(persistenceKey).hintsShown") as? [String] {
hintsShown = Set(hintsData)
}
}
private func save() {
let defaults = UserDefaults.standard
defaults.set(hasLaunchedBefore, forKey: "\(persistenceKey).hasLaunched")
defaults.set(hasCompletedWelcome, forKey: "\(persistenceKey).hasCompletedWelcome")
defaults.set(Array(hintsShown), forKey: "\(persistenceKey).hintsShown")
}
}

View File

@ -0,0 +1 @@
{"sourceLanguage":"en","strings":{},"version":"1.0"}

View File

@ -0,0 +1,415 @@
//
// CloudSyncManager.swift
// Bedrock
//
// Generic iCloud sync manager for app data.
// Uses NSUbiquitousKeyValueStore for lightweight cross-device sync.
//
import Foundation
import SwiftUI
// MARK: - PersistableData Protocol
/// Protocol for data that can be persisted locally and synced to iCloud.
///
/// Conform to this protocol to enable automatic iCloud sync for your app's data.
///
/// ## Example
///
/// ```swift
/// struct GameStats: PersistableData {
/// static var dataIdentifier = "gameStats"
/// static var empty = GameStats()
///
/// var gamesPlayed: Int = 0
/// var lastModified: Date = .now
///
/// var syncPriority: Int { gamesPlayed } // Higher = more progress
/// }
/// ```
public protocol PersistableData: Codable, Sendable {
/// Unique identifier for this data type (e.g., "userProfile", "gameStats").
/// Used as the storage key.
static var dataIdentifier: String { get }
/// Priority value for conflict resolution.
/// When cloud and local data conflict, the version with higher priority wins.
/// Typically represents "progress" (e.g., games played, items collected).
var syncPriority: Int { get }
/// Last time this data was modified.
var lastModified: Date { get set }
/// Creates empty/default data.
static var empty: Self { get }
}
// MARK: - CloudSyncManager
/// Manages data persistence to local storage and iCloud.
///
/// Features:
/// - Automatic sync with iCloud using `NSUbiquitousKeyValueStore`
/// - Local fallback using `UserDefaults`
/// - Conflict resolution based on `syncPriority`
/// - Change notifications when data updates from another device
///
/// ## Usage
///
/// ```swift
/// @Observable
/// class AppState {
/// let syncManager = CloudSyncManager<UserData>()
///
/// var userData: UserData {
/// syncManager.data
/// }
///
/// func saveProgress() {
/// syncManager.update { data in
/// data.level += 1
/// }
/// }
/// }
/// ```
@MainActor
@Observable
public final class CloudSyncManager<T: PersistableData> {
// MARK: - Properties
/// The current data.
public private(set) var data: T
/// Whether iCloud sync is available (user signed in to iCloud).
public var iCloudAvailable: Bool {
let token = FileManager.default.ubiquityIdentityToken
let available = token != nil
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud available = \(available)")
return available
}
/// Whether iCloud sync is enabled by the user.
public var iCloudEnabled: Bool {
get { UserDefaults.standard.bool(forKey: iCloudEnabledKey) }
set {
UserDefaults.standard.set(newValue, forKey: iCloudEnabledKey)
if newValue { sync() }
}
}
/// Last successful sync date.
public private(set) var lastSyncDate: Date?
/// Whether a sync is currently in progress.
public private(set) var isSyncing: Bool = false
/// Human-readable sync status.
public private(set) var syncStatus: String = ""
/// Whether initial iCloud sync has completed.
public private(set) var hasCompletedInitialSync: Bool = false
/// Callback when data is received from iCloud.
public var onCloudDataReceived: ((T) -> Void)?
// MARK: - Private Properties
private var _iCloudStore: NSUbiquitousKeyValueStore?
private var _iCloudStoreInitialized = false
private var iCloudStore: NSUbiquitousKeyValueStore? {
if _iCloudStoreInitialized {
return _iCloudStore
}
_iCloudStoreInitialized = true
guard iCloudAvailable else {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud not available")
return nil
}
_iCloudStore = NSUbiquitousKeyValueStore.default
return _iCloudStore
}
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var localKey: String { "\(T.dataIdentifier).data" }
private var cloudKey: String { "\(T.dataIdentifier).data" }
private var syncDateKey: String { "\(T.dataIdentifier).lastSync" }
private var iCloudEnabledKey: String { "\(T.dataIdentifier).iCloudEnabled" }
// MARK: - Initialization
public init() {
self.data = T.empty
// Default iCloud enabled to true
if UserDefaults.standard.object(forKey: iCloudEnabledKey) == nil {
UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
}
// Register for iCloud changes
if iCloudAvailable, let store = iCloudStore {
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: store,
queue: .main
) { [weak self] notification in
guard let userInfo = notification.userInfo,
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
return
}
Task { @MainActor in
self?.handleCloudChange(reason: reason, changedKeys: changedKeys)
}
}
// Trigger iCloud sync
if iCloudEnabled {
store.synchronize()
}
}
// Load data
self.data = load()
// On fresh install, wait for iCloud data
if data.syncPriority == 0 && iCloudAvailable && iCloudEnabled {
scheduleDelayedCloudCheck()
}
}
// MARK: - Save
/// Saves the current data locally and to iCloud.
public func save(_ newData: T) {
var dataToSave = newData
dataToSave.lastModified = Date()
self.data = dataToSave
guard let encoded = try? encoder.encode(dataToSave) else {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Failed to encode data")
return
}
// Save locally
UserDefaults.standard.set(encoded, forKey: localKey)
// Save to iCloud
if iCloudAvailable && iCloudEnabled, let store = iCloudStore {
store.set(encoded, forKey: cloudKey)
store.set(Date(), forKey: syncDateKey)
store.synchronize()
lastSyncDate = Date()
syncStatus = "Synced"
}
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Saved (priority: \(dataToSave.syncPriority))")
}
/// Updates and saves data in one call.
public func update(_ transform: (inout T) -> Void) {
var updated = data
transform(&updated)
save(updated)
}
// MARK: - Load
/// Loads data, preferring iCloud if it has higher priority.
public func load() -> T {
let localData = loadLocal()
let cloudData = loadCloud()
let finalData: T
switch (localData, cloudData) {
case (nil, nil):
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: No saved data, using empty")
finalData = T.empty
case (let local?, nil):
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local data")
finalData = local
case (nil, let cloud?):
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud data")
finalData = cloud
case (let local?, let cloud?):
// Use whichever has higher priority (more progress)
if cloud.syncPriority > local.syncPriority {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud (priority \(cloud.syncPriority) > \(local.syncPriority))")
finalData = cloud
// Update local with cloud data
if let encoded = try? encoder.encode(cloud) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
} else if local.lastModified > cloud.lastModified {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local (newer)")
finalData = local
} else {
finalData = local
}
}
return finalData
}
private func loadLocal() -> T? {
guard let data = UserDefaults.standard.data(forKey: localKey),
let decoded = try? decoder.decode(T.self, from: data) else {
return nil
}
return decoded
}
private func loadCloud() -> T? {
guard iCloudAvailable && iCloudEnabled,
let store = iCloudStore,
let data = store.data(forKey: cloudKey),
let decoded = try? decoder.decode(T.self, from: data) else {
return nil
}
if let syncDate = store.object(forKey: syncDateKey) as? Date {
lastSyncDate = syncDate
}
return decoded
}
// MARK: - Sync
/// Forces a sync with iCloud.
public func sync() {
guard iCloudAvailable && iCloudEnabled else {
syncStatus = iCloudAvailable ? "Sync disabled" : "iCloud unavailable"
return
}
guard let store = iCloudStore else {
syncStatus = "iCloud unavailable"
return
}
isSyncing = true
syncStatus = "Syncing..."
store.synchronize()
// Reload to get any changes
let latestData = load()
if latestData.syncPriority != data.syncPriority {
data = latestData
onCloudDataReceived?(latestData)
}
isSyncing = false
lastSyncDate = Date()
syncStatus = "Synced"
}
// MARK: - Reset
/// Clears all saved data locally and from iCloud.
public func reset() {
UserDefaults.standard.removeObject(forKey: localKey)
if iCloudAvailable, let store = iCloudStore {
store.removeObject(forKey: cloudKey)
store.removeObject(forKey: syncDateKey)
store.synchronize()
}
data = T.empty
syncStatus = "Data cleared"
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: All data cleared")
}
// MARK: - Private Methods
private func scheduleDelayedCloudCheck() {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Scheduling delayed cloud check...")
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
guard let store = iCloudStore else {
hasCompletedInitialSync = true
return
}
_ = store.synchronize()
if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Cloud has more data, updating...")
data = cloudData
onCloudDataReceived?(cloudData)
if let encoded = try? encoder.encode(cloudData) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
NotificationCenter.default.post(
name: .persistedDataDidChange,
object: nil,
userInfo: ["dataIdentifier": T.dataIdentifier]
)
}
hasCompletedInitialSync = true
}
}
private func handleCloudChange(reason: Int, changedKeys: [String]) {
guard changedKeys.contains(cloudKey) else { return }
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Data changed from another device")
syncStatus = "Received update"
if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority {
data = cloudData
onCloudDataReceived?(cloudData)
if let encoded = try? encoder.encode(cloudData) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
NotificationCenter.default.post(
name: .persistedDataDidChange,
object: nil,
userInfo: ["dataIdentifier": T.dataIdentifier]
)
}
case NSUbiquitousKeyValueStoreQuotaViolationChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud quota exceeded")
syncStatus = "Storage full"
case NSUbiquitousKeyValueStoreAccountChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud account changed")
syncStatus = "Account changed"
data = load()
default:
break
}
}
}
// MARK: - Notifications
public extension Notification.Name {
/// Posted when persisted data changes from iCloud.
static let persistedDataDidChange = Notification.Name("persistedDataDidChange")
}

View File

@ -0,0 +1,180 @@
//
// ColorProtocols.swift
// Bedrock
//
// Protocols defining semantic color categories for consistent theming.
// Apps conform to these protocols to provide their own color palettes
// while maintaining consistent naming conventions.
//
import SwiftUI
// MARK: - Surface Colors Protocol
/// Protocol defining background and surface colors.
///
/// Conform to this protocol to provide consistent surface colors for your app.
public protocol SurfaceColorProvider {
/// Primary background color (darkest/base).
static var primary: Color { get }
/// Secondary/elevated surface.
static var secondary: Color { get }
/// Tertiary/card surface.
static var tertiary: Color { get }
/// Overlay background (for sheets/modals).
static var overlay: Color { get }
/// Card/grouped element background.
static var card: Color { get }
/// Subtle fill for grouped content.
static var groupedFill: Color { get }
/// Section fill for list sections.
static var sectionFill: Color { get }
}
// MARK: - Text Colors Protocol
/// Protocol defining text and label colors.
///
/// Conform to this protocol to provide consistent text colors for your app.
public protocol TextColorProvider {
/// Primary text color (highest emphasis).
static var primary: Color { get }
/// Secondary/muted text.
static var secondary: Color { get }
/// Tertiary/hint text.
static var tertiary: Color { get }
/// Disabled text.
static var disabled: Color { get }
/// Placeholder text.
static var placeholder: Color { get }
/// Inverse text (for contrasting backgrounds).
static var inverse: Color { get }
}
// MARK: - Accent Colors Protocol
/// Protocol defining accent colors for interactive elements.
///
/// Conform to this protocol to provide your app's brand accent colors.
public protocol AccentColorProvider {
/// Primary accent color.
static var primary: Color { get }
/// Light variant of primary accent.
static var light: Color { get }
/// Dark variant of primary accent.
static var dark: Color { get }
/// Secondary accent color.
static var secondary: Color { get }
}
// MARK: - Button Colors Protocol
/// Protocol defining button-specific colors.
public protocol ButtonColorProvider {
/// Light gradient color for primary buttons.
static var primaryLight: Color { get }
/// Dark gradient color for primary buttons.
static var primaryDark: Color { get }
/// Secondary button background.
static var secondary: Color { get }
/// Destructive action color.
static var destructive: Color { get }
/// Cancel button text color.
static var cancelText: Color { get }
}
// MARK: - Status Colors Protocol
/// Protocol defining semantic status colors.
public protocol StatusColorProvider {
/// Success/positive state.
static var success: Color { get }
/// Warning state.
static var warning: Color { get }
/// Error/negative state.
static var error: Color { get }
/// Informational state.
static var info: Color { get }
}
// MARK: - Border Colors Protocol
/// Protocol defining border and divider colors.
public protocol BorderColorProvider {
/// Subtle border.
static var subtle: Color { get }
/// Standard border.
static var standard: Color { get }
/// Emphasized border.
static var emphasized: Color { get }
/// Selected/active border.
static var selected: Color { get }
}
// MARK: - Interactive Colors Protocol
/// Protocol defining colors for interactive states.
public protocol InteractiveColorProvider {
/// Selected state.
static var selected: Color { get }
/// Hover/highlight state.
static var hover: Color { get }
/// Pressed state.
static var pressed: Color { get }
/// Focus ring color.
static var focus: Color { get }
}
// MARK: - Full Theme Protocol
/// Protocol combining all color providers into a complete app theme.
///
/// Conform to this protocol to define a complete color theme for your app.
/// Each associated type should conform to its respective color provider protocol.
///
/// ## Example
///
/// ```swift
/// enum CasinoTheme: AppColorTheme {
/// typealias Surface = CasinoSurfaceColors
/// typealias Text = CasinoTextColors
/// typealias Accent = CasinoAccentColors
/// // ... etc
/// }
/// ```
public protocol AppColorTheme {
associatedtype Surface: SurfaceColorProvider
associatedtype Text: TextColorProvider
associatedtype Accent: AccentColorProvider
associatedtype Button: ButtonColorProvider
associatedtype Status: StatusColorProvider
associatedtype Border: BorderColorProvider
associatedtype Interactive: InteractiveColorProvider
}

View File

@ -0,0 +1,137 @@
//
// Colors.swift
// Bedrock
//
// Default color implementations conforming to the color protocols.
// These provide neutral, app-agnostic defaults that can be used as-is
// or serve as a reference for custom implementations.
//
import SwiftUI
// MARK: - Default Surface Colors
/// Default surface colors - neutral dark theme.
///
/// Apps can create their own type conforming to `SurfaceColorProvider`
/// to provide custom surface colors while maintaining consistent naming.
public enum DefaultSurfaceColors: SurfaceColorProvider {
public static let primary = Color(red: 0.08, green: 0.10, blue: 0.14)
public static let secondary = Color(red: 0.12, green: 0.14, blue: 0.18)
public static let tertiary = Color(red: 0.16, green: 0.18, blue: 0.22)
public static let overlay = Color(red: 0.08, green: 0.12, blue: 0.18)
public static let card = Color.white.opacity(Design.Opacity.verySubtle)
public static let groupedFill = Color.white.opacity(Design.Opacity.subtle)
public static let sectionFill = Color.white.opacity(Design.Opacity.subtle)
}
// MARK: - Default Text Colors
/// Default text colors - white text for dark themes.
public enum DefaultTextColors: TextColorProvider {
public static let primary = Color.white
public static let secondary = Color.white.opacity(Design.Opacity.accent)
public static let tertiary = Color.white.opacity(Design.Opacity.medium)
public static let disabled = Color.white.opacity(Design.Opacity.light)
public static let placeholder = Color.white.opacity(Design.Opacity.overlay)
public static let inverse = Color.black
}
// MARK: - Default Accent Colors
/// Default accent colors - neutral blue accent.
///
/// CasinoKit will override these with gold accents.
public enum DefaultAccentColors: AccentColorProvider {
public static let primary = Color(red: 0.3, green: 0.6, blue: 0.9)
public static let light = Color(red: 0.4, green: 0.7, blue: 1.0)
public static let dark = Color(red: 0.2, green: 0.5, blue: 0.8)
public static let secondary = Color(red: 0.2, green: 0.7, blue: 0.7)
}
// MARK: - Default Button Colors
/// Default button colors.
public enum DefaultButtonColors: ButtonColorProvider {
public static let primaryLight = Color(red: 0.4, green: 0.7, blue: 1.0)
public static let primaryDark = Color(red: 0.3, green: 0.6, blue: 0.9)
public static let secondary = Color.white.opacity(Design.Opacity.subtle)
public static let destructive = Color.red.opacity(Design.Opacity.heavy)
public static let cancelText = Color.white.opacity(Design.Opacity.strong)
}
// MARK: - Default Status Colors
/// Default status colors - standard semantic colors.
public enum DefaultStatusColors: StatusColorProvider {
public static let success = Color(red: 0.2, green: 0.8, blue: 0.4)
public static let warning = Color(red: 1.0, green: 0.75, blue: 0.2)
public static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
public static let info = Color(red: 0.3, green: 0.6, blue: 0.9)
}
// MARK: - Default Border Colors
/// Default border colors.
public enum DefaultBorderColors: BorderColorProvider {
public static let subtle = Color.white.opacity(Design.Opacity.subtle)
public static let standard = Color.white.opacity(Design.Opacity.hint)
public static let emphasized = Color.white.opacity(Design.Opacity.light)
public static let selected = DefaultAccentColors.primary.opacity(Design.Opacity.medium)
}
// MARK: - Default Interactive Colors
/// Default interactive state colors.
public enum DefaultInteractiveColors: InteractiveColorProvider {
public static let selected = Color.white.opacity(Design.Opacity.selection)
public static let hover = Color.white.opacity(Design.Opacity.subtle)
public static let pressed = Color.white.opacity(Design.Opacity.hint)
public static let focus = Color(red: 0.4, green: 0.6, blue: 1.0)
}
// MARK: - Default Theme
/// The default Bedrock theme combining all default color providers.
///
/// Use this as a reference implementation or as the base theme for apps
/// that don't need custom colors.
public enum DefaultTheme: AppColorTheme {
public typealias Surface = DefaultSurfaceColors
public typealias Text = DefaultTextColors
public typealias Accent = DefaultAccentColors
public typealias Button = DefaultButtonColors
public typealias Status = DefaultStatusColors
public typealias Border = DefaultBorderColors
public typealias Interactive = DefaultInteractiveColors
}
// MARK: - Convenience Color Extensions
/// Convenience extensions for accessing default theme colors.
///
/// These provide the familiar `Color.Surface.primary` syntax using the
/// default theme. Apps using custom themes should access colors through
/// their theme type directly (e.g., `CasinoTheme.Surface.primary`).
public extension Color {
/// Default surface colors.
typealias Surface = DefaultSurfaceColors
/// Default text colors.
typealias Text = DefaultTextColors
/// Default accent colors.
typealias Accent = DefaultAccentColors
/// Default button colors.
typealias Button = DefaultButtonColors
/// Default status colors.
typealias Status = DefaultStatusColors
/// Default border colors.
typealias Border = DefaultBorderColors
/// Default interactive colors.
typealias Interactive = DefaultInteractiveColors
}

View File

@ -0,0 +1,275 @@
//
// Design.swift
// Bedrock
//
// Shared design constants for building consistent UI components.
//
import SwiftUI
/// Shared design constants for app UI components.
///
/// Use these constants throughout your app to maintain visual consistency.
/// All values are carefully chosen to work well across iOS devices and
/// accessibility settings.
///
/// ## Example
///
/// ```swift
/// Text("Hello")
/// .padding(Design.Spacing.medium)
/// .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
/// ```
public enum Design {
// MARK: - Debug
/// Set to true to enable debug logging in Bedrock.
public static let showDebugLogs = true
/// Logs a message only in debug builds when `showDebugLogs` is enabled.
public static func debugLog(_ message: String) {
#if DEBUG
if showDebugLogs {
print("[Bedrock] \(message)")
}
#endif
}
// MARK: - Spacing
/// Consistent spacing values for padding, margins, and gaps.
public enum Spacing {
public static let xxxSmall: CGFloat = 1
public static let xxSmall: CGFloat = 2
public static let xSmall: CGFloat = 4
public static let small: CGFloat = 8
public static let medium: CGFloat = 12
public static let large: CGFloat = 16
public static let xLarge: CGFloat = 20
public static let xxLarge: CGFloat = 24
public static let xxxLarge: CGFloat = 32
public static let xxxxLarge: CGFloat = 40
/// Slide distance for toast and notification animations.
public static let toastSlide: CGFloat = 50
}
// MARK: - Corner Radius
/// Standard corner radius values for rounded UI elements.
public enum CornerRadius {
public static let xSmall: CGFloat = 4
public static let small: CGFloat = 8
public static let medium: CGFloat = 10
public static let large: CGFloat = 12
public static let xLarge: CGFloat = 14
public static let xxLarge: CGFloat = 20
public static let xxxLarge: CGFloat = 28
}
// MARK: - Line Width
/// Standard line widths for borders and strokes.
public enum LineWidth {
public static let hairline: CGFloat = 0.5
public static let thin: CGFloat = 1
public static let standard: CGFloat = 2
public static let medium: CGFloat = 2
public static let thick: CGFloat = 3
public static let heavy: CGFloat = 4
}
// MARK: - Shadow
/// Shadow radius and offset values for depth effects.
public enum Shadow {
public static let radiusSmall: CGFloat = 4
public static let radiusMedium: CGFloat = 8
public static let radiusLarge: CGFloat = 12
public static let radiusXLarge: CGFloat = 16
public static let radiusXXLarge: CGFloat = 24
public static let offsetSmall: CGFloat = 1
public static let offsetMedium: CGFloat = 2
public static let offsetLarge: CGFloat = 3
public static let offsetXLarge: CGFloat = 5
}
// MARK: - Opacity
/// Standard opacity values for layering and emphasis.
public enum Opacity {
public static let verySubtle: Double = 0.05
public static let subtle: Double = 0.1
public static let selection: Double = 0.15
public static let hint: Double = 0.2
public static let quarter: Double = 0.25
public static let light: Double = 0.3
public static let overlay: Double = 0.4
public static let medium: Double = 0.5
public static let secondary: Double = 0.5
public static let disabled: Double = 0.5
public static let accent: Double = 0.6
public static let strong: Double = 0.7
public static let heavy: Double = 0.8
public static let nearOpaque: Double = 0.85
public static let almostFull: Double = 0.9
}
// MARK: - Animation
/// Animation timing and duration values.
public enum Animation {
public static let instant: Double = 0.1
public static let quick: Double = 0.2
public static let standard: Double = 0.3
public static let springDuration: Double = 0.4
public static let springBounce: Double = 0.3
public static let slow: Double = 0.5
public static let lazy: Double = 0.8
public static let fadeIn: Double = 0.3
public static let fadeOut: Double = 0.2
public static let selectionDuration: Double = 0.2
public static let staggerDelay1: Double = 0.1
public static let staggerDelay2: Double = 0.25
public static let staggerDelay3: Double = 0.4
/// Creates a standard spring animation.
/// - Parameter bounce: Bounce amount (0 = no bounce, 1 = very bouncy).
public static func spring(bounce: Double = springBounce) -> SwiftUI.Animation {
.spring(duration: springDuration, bounce: bounce)
}
/// Creates a smooth ease-out animation.
/// - Parameter duration: Animation duration.
public static func easeOut(duration: Double = standard) -> SwiftUI.Animation {
.easeOut(duration: duration)
}
}
// MARK: - Sizes
/// Common size values for UI elements.
public enum Size {
/// Minimum height for tappable rows (Apple HIG: 44pt minimum touch target).
public static let actionRowMinHeight: CGFloat = 44
/// Common button dimensions.
public static let buttonHeight: CGFloat = 50
public static let buttonMinWidth: CGFloat = 80
/// Checkmark and indicator sizes.
public static let checkmark: CGFloat = 22
public static let indicator: CGFloat = 24
/// Badge sizes.
public static let badgeSmall: CGFloat = 20
public static let badgeMedium: CGFloat = 26
public static let badgeLarge: CGFloat = 32
/// Avatar sizes.
public static let avatarSmall: CGFloat = 32
public static let avatarMedium: CGFloat = 44
public static let avatarLarge: CGFloat = 64
/// Icon container sizes.
public static let iconContainerSmall: CGFloat = 28
public static let iconContainerMedium: CGFloat = 40
public static let iconContainerLarge: CGFloat = 56
/// iPad max widths for overlays and content.
public static let maxContentWidthPortrait: CGFloat = 500
public static let maxContentWidthLandscape: CGFloat = 800
public static let maxModalWidth: CGFloat = 450
}
// MARK: - Icon Sizes
/// Standard icon sizes for SF Symbols and custom icons.
public enum IconSize {
public static let xSmall: CGFloat = 10
public static let small: CGFloat = 12
public static let medium: CGFloat = 16
public static let large: CGFloat = 22
public static let xLarge: CGFloat = 32
public static let xxLarge: CGFloat = 48
public static let xxxLarge: CGFloat = 64
}
// MARK: - Font Sizes (Base values for @ScaledMetric)
/// Base font sizes to use with @ScaledMetric for Dynamic Type support.
public enum BaseFontSize {
public static let xxSmall: CGFloat = 7
public static let xSmall: CGFloat = 9
public static let small: CGFloat = 10
public static let caption: CGFloat = 11
public static let body: CGFloat = 12
public static let callout: CGFloat = 13
public static let medium: CGFloat = 14
public static let subheadline: CGFloat = 15
public static let large: CGFloat = 16
public static let xLarge: CGFloat = 18
public static let xxLarge: CGFloat = 20
public static let title3: CGFloat = 22
public static let title2: CGFloat = 26
public static let title: CGFloat = 32
public static let largeTitle: CGFloat = 36
public static let display: CGFloat = 48
public static let hero: CGFloat = 64
}
// MARK: - Scale
/// Scale factors for pressed states, selections, and animations.
public enum Scale {
public static let shrunk: CGFloat = 0.5
public static let slightShrink: CGFloat = 0.8
public static let pressed: CGFloat = 0.95
public static let normal: CGFloat = 1.0
public static let slight: CGFloat = 1.05
public static let selected: CGFloat = 1.1
public static let emphasized: CGFloat = 1.15
}
// MARK: - Minimum Scale Factors
/// Minimum scale factors for text that should shrink to fit.
public enum MinScaleFactor {
public static let tight: CGFloat = 0.5
public static let comfortable: CGFloat = 0.6
public static let relaxed: CGFloat = 0.7
public static let minimal: CGFloat = 0.8
}
// MARK: - Toast Configuration
/// Toast notification display configuration.
public enum Toast {
/// Duration toasts stay visible before auto-dismiss.
public static let duration: Duration = .seconds(3)
/// Short toast duration.
public static let durationShort: Duration = .seconds(2)
/// Long toast duration.
public static let durationLong: Duration = .seconds(5)
}
// MARK: - Hit Testing
/// Minimum touch target sizes per Apple HIG.
public enum HitTarget {
/// Absolute minimum touch target (Apple HIG).
public static let minimum: CGFloat = 44
/// Comfortable touch target.
public static let comfortable: CGFloat = 48
/// Large touch target for primary actions.
public static let large: CGFloat = 56
}
}

View File

@ -0,0 +1,148 @@
//
// DeviceInfo.swift
// Bedrock
//
// Device detection utilities for responsive layouts.
//
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Device information utilities for responsive layouts and adaptive UI.
public enum DeviceInfo {
// MARK: - Device Type
#if canImport(UIKit)
/// Whether the current device is an iPad.
public static var isPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
/// Whether the current device is an iPhone.
public static var isPhone: Bool {
UIDevice.current.userInterfaceIdiom == .phone
}
/// Whether the current device is a Vision Pro.
public static var isVisionPro: Bool {
UIDevice.current.userInterfaceIdiom == .vision
}
#else
/// Whether the current device is an iPad (false on macOS).
public static var isPad: Bool { false }
/// Whether the current device is an iPhone (false on macOS).
public static var isPhone: Bool { false }
/// Whether the current device is a Vision Pro (false on macOS).
public static var isVisionPro: Bool { false }
#endif
/// Whether the current device is a Mac (via Catalyst or macOS).
public static var isMac: Bool {
#if targetEnvironment(macCatalyst)
return true
#elseif os(macOS)
return true
#else
return false
#endif
}
// MARK: - Screen Info
#if canImport(UIKit)
/// Returns the current screen bounds.
public static var screenBounds: CGRect {
currentScreen?.bounds ?? .zero
}
/// Returns the current screen scale.
public static var screenScale: CGFloat {
currentScreen?.scale ?? 1.0
}
/// Returns the current active screen.
public static var currentScreen: UIScreen? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.first?
.screen
}
#else
/// Returns the current screen bounds (main screen on macOS).
public static var screenBounds: CGRect {
NSScreen.main?.frame ?? .zero
}
/// Returns the current screen scale (main screen on macOS).
public static var screenScale: CGFloat {
NSScreen.main?.backingScaleFactor ?? 1.0
}
#endif
// MARK: - Layout Helpers
/// Whether the screen is in landscape orientation.
public static var isLandscape: Bool {
let bounds = screenBounds
return bounds.width > bounds.height
}
/// Whether the screen is in portrait orientation.
public static var isPortrait: Bool {
!isLandscape
}
/// Whether the screen is considered "compact" width (typically iPhone portrait).
public static var isCompactWidth: Bool {
screenBounds.width < 600
}
/// Whether the screen is considered "regular" width (iPad, iPhone landscape).
public static var isRegularWidth: Bool {
!isCompactWidth
}
// MARK: - System Info
#if canImport(UIKit)
/// The current iOS version string.
public static var systemVersion: String {
UIDevice.current.systemVersion
}
/// The device name (e.g., "iPhone", "iPad").
public static var deviceName: String {
UIDevice.current.name
}
#else
/// The current macOS version string.
public static var systemVersion: String {
ProcessInfo.processInfo.operatingSystemVersionString
}
/// The device name.
public static var deviceName: String {
Host.current().localizedName ?? "Mac"
}
#endif
}
// MARK: - UIScreen Extension
#if canImport(UIKit)
extension UIScreen {
/// Returns the current active screen (the one displaying the key window).
/// This is the recommended replacement for the deprecated `UIScreen.main`.
public static var current: UIScreen? {
DeviceInfo.currentScreen
}
}
#endif

View File

@ -0,0 +1,68 @@
//
// DebugBorderModifier.swift
// Bedrock
//
// Debug view modifier for visualizing layout boundaries.
//
import SwiftUI
// MARK: - Debug Border Modifier
public extension View {
/// Adds a colored border and label to a view for debugging layout.
/// - Parameters:
/// - show: Whether to show the debug border.
/// - color: The color of the border and label.
/// - label: The label text to display in the corner.
/// - Returns: The view with an optional debug border overlay.
///
/// ## Example
///
/// ```swift
/// MyComplexView()
/// .debugBorder(showDebug, color: .red, label: "Header")
/// ```
@ViewBuilder
func debugBorder(_ show: Bool, color: Color, label: String) -> some View {
if show {
self
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.strokeBorder(color, lineWidth: Design.LineWidth.thin)
)
.overlay(alignment: .topLeading) {
Text(label)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(color)
.padding(2)
.background(Color.black.opacity(Design.Opacity.strong))
}
} else {
self
}
}
}
#Preview {
VStack(spacing: Design.Spacing.large) {
Text("Header")
.frame(maxWidth: .infinity)
.padding()
.background(Color.Surface.secondary)
.debugBorder(true, color: .red, label: "Header")
Text("Content")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.Surface.tertiary)
.debugBorder(true, color: .green, label: "Content")
Text("Footer")
.frame(maxWidth: .infinity)
.padding()
.background(Color.Surface.secondary)
.debugBorder(true, color: .blue, label: "Footer")
}
.padding()
.background(Color.Surface.primary)
}

View File

@ -0,0 +1,106 @@
//
// ConfettiView.swift
// Bedrock
//
// A reusable confetti celebration effect.
//
import SwiftUI
/// A single confetti particle that falls and rotates.
public struct ConfettiPiece: View {
let color: Color
let containerSize: CGSize
@State private var position: CGPoint = .zero
@State private var rotation: Double = 0
@State private var opacity: Double = 1
private let confettiWidth: CGFloat = 8
private let confettiHeight: CGFloat = 12
public init(color: Color, containerSize: CGSize) {
self.color = color
self.containerSize = containerSize
}
public var body: some View {
Rectangle()
.fill(color)
.frame(width: confettiWidth, height: confettiHeight)
.rotationEffect(.degrees(rotation))
.position(position)
.opacity(opacity)
.onAppear {
let startX = Double.random(in: 0...containerSize.width)
position = CGPoint(x: startX, y: -20)
withAnimation(.easeIn(duration: Double.random(in: 2...4))) {
position = CGPoint(
x: startX + Double.random(in: -100...100),
y: containerSize.height + 50
)
rotation = Double.random(in: 360...1080)
opacity = 0
}
}
}
}
/// A confetti celebration overlay.
///
/// Shows colorful falling confetti particles for celebrations, wins, achievements, etc.
///
/// ## Example
///
/// ```swift
/// ZStack {
/// MainContentView()
///
/// if showCelebration {
/// ConfettiView()
/// }
/// }
/// ```
public struct ConfettiView: View {
/// The colors to use for confetti pieces.
public let colors: [Color]
/// The number of confetti pieces to show.
public let count: Int
/// Creates a confetti view with custom colors.
/// - Parameters:
/// - colors: The colors to randomly assign to confetti pieces.
/// - count: The number of confetti pieces (default: 50).
public init(
colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink],
count: Int = 50
) {
self.colors = colors
self.count = count
}
public var body: some View {
GeometryReader { geometry in
ZStack {
ForEach(0..<count, id: \.self) { _ in
ConfettiPiece(
color: colors.randomElement() ?? .yellow,
containerSize: geometry.size
)
}
}
}
.ignoresSafeArea()
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
#Preview {
ZStack {
Color.black.ignoresSafeArea()
ConfettiView()
}
}

View File

@ -0,0 +1,81 @@
//
// PulsingModifier.swift
// Bedrock
//
// Visual hint modifier that pulses to draw attention.
//
import SwiftUI
/// Modifier that adds a pulsing animation to draw attention to interactive elements.
///
/// Use this to highlight buttons, actions, or any UI element that needs user attention.
public struct PulsingModifier: ViewModifier {
let isActive: Bool
let color: Color
let scale: CGFloat
let cornerRadius: CGFloat
@State private var animationAmount: CGFloat = 1.0
public func body(content: Content) -> some View {
content
.overlay {
if isActive {
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(color, lineWidth: Design.LineWidth.medium)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
.animation(
.easeInOut(duration: 1.5)
.repeatForever(autoreverses: false),
value: animationAmount
)
.onAppear {
animationAmount = scale
}
}
}
}
}
public extension View {
/// Adds a pulsing animation to draw attention to the view.
/// - Parameters:
/// - isActive: Whether the pulsing animation is active.
/// - color: The color of the pulse (default: white).
/// - scale: How much the pulse expands (default: 1.3).
/// - cornerRadius: Corner radius of the pulse shape (default: large).
func pulsing(
isActive: Bool,
color: Color = .white,
scale: CGFloat = 1.3,
cornerRadius: CGFloat = Design.CornerRadius.large
) -> some View {
modifier(PulsingModifier(
isActive: isActive,
color: color,
scale: scale,
cornerRadius: cornerRadius
))
}
}
#Preview {
ZStack {
Color.Surface.primary.ignoresSafeArea()
VStack(spacing: Design.Spacing.xLarge) {
Button("Tap Here") { }
.padding()
.background(Color.Accent.primary)
.foregroundStyle(.black)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.pulsing(isActive: true)
Text("Pulsing highlights interactive areas")
.foregroundStyle(Color.Text.secondary)
.font(.caption)
}
}
}

View File

@ -0,0 +1,611 @@
//
// SettingsComponents.swift
// Bedrock
//
// Reusable settings UI components for building consistent settings screens.
//
import SwiftUI
// MARK: - Settings Toggle
/// A toggle setting row with title and subtitle.
///
/// Use this for boolean settings that can be turned on or off.
///
/// ```swift
/// SettingsToggle(
/// title: "Dark Mode",
/// subtitle: "Use dark appearance",
/// isOn: $settings.darkMode
/// )
/// ```
public struct SettingsToggle: View {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Binding to the toggle state.
@Binding public var isOn: Bool
/// The accent color for the toggle.
public let accentColor: Color
/// Creates a settings toggle.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - isOn: Binding to toggle state.
/// - accentColor: The accent color (default: primary accent).
public init(
title: String,
subtitle: String,
isOn: Binding<Bool>,
accentColor: Color = .Accent.primary
) {
self.title = title
self.subtitle = subtitle
self._isOn = isOn
self.accentColor = accentColor
}
public var body: some View {
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.tint(accentColor)
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Volume Picker
/// A volume slider with speaker icons.
///
/// Use this for audio volume or similar 0-1 range settings.
public struct VolumePicker: View {
/// The label for the picker.
public let label: String
/// Binding to the volume level (0.0 to 1.0).
@Binding public var volume: Float
/// The accent color for the slider.
public let accentColor: Color
/// Creates a volume picker.
/// - Parameters:
/// - label: The label text (default: "Volume").
/// - volume: Binding to volume (0.0-1.0).
/// - accentColor: The accent color (default: primary accent).
public init(
label: String = "Volume",
volume: Binding<Float>,
accentColor: Color = .Accent.primary
) {
self.label = label
self._volume = volume
self.accentColor = accentColor
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(volume * 100))%")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "speaker.fill")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(value: $volume, in: 0...1, step: 0.1)
.tint(accentColor)
Image(systemName: "speaker.wave.3.fill")
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Segmented Picker
/// A horizontal segmented picker with capsule-style buttons.
///
/// Use this for selecting from a small number of options (2-4).
///
/// ```swift
/// SegmentedPicker(
/// title: "Theme",
/// options: [("Light", "light"), ("Dark", "dark"), ("System", "system")],
/// selection: $theme
/// )
/// ```
public struct SegmentedPicker<T: Equatable>: View {
/// The title/label for the picker.
public let title: String
/// The available options as (label, value) pairs.
public let options: [(String, T)]
/// Binding to the selected value.
@Binding public var selection: T
/// The accent color for the selected button.
public let accentColor: Color
/// Creates a segmented picker.
/// - Parameters:
/// - title: The title label.
/// - options: Array of (label, value) tuples.
/// - selection: Binding to selected value.
/// - accentColor: The accent color (default: primary accent).
public init(
title: String,
options: [(String, T)],
selection: Binding<T>,
accentColor: Color = .Accent.primary
) {
self.title = title
self.options = options
self._selection = selection
self.accentColor = accentColor
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.small) {
ForEach(options.indices, id: \.self) { index in
let option = options[index]
Button {
selection = option.1
} label: {
Text(option.0)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(
Capsule()
.fill(selection == option.1 ? accentColor : Color.white.opacity(Design.Opacity.subtle))
)
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Selectable Row
/// A card-like selectable row with title, subtitle, optional badge, and selection indicator.
///
/// Use this for settings pickers, option lists, or any selectable item.
///
/// ```swift
/// SelectableRow(
/// title: "Premium",
/// subtitle: "Unlock all features",
/// isSelected: plan == .premium
/// ) {
/// plan = .premium
/// }
/// ```
public struct SelectableRow<Badge: View>: View {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Whether this row is currently selected.
public let isSelected: Bool
/// Optional badge view.
public let badge: Badge?
/// The accent color for selection highlighting.
public let accentColor: Color
/// Action when tapped.
public let action: () -> Void
/// Creates a selectable row.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - isSelected: Whether this row is selected.
/// - accentColor: Color for selection (default: primary accent).
/// - badge: Optional badge view.
/// - action: Action when tapped.
public init(
title: String,
subtitle: String,
isSelected: Bool,
accentColor: Color = .Accent.primary,
@ViewBuilder badge: () -> Badge? = { nil as EmptyView? },
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
self.accentColor = accentColor
self.badge = badge()
self.action = action
}
public var body: some View {
Button(action: action) {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer()
if let badge = badge {
badge
}
SelectionIndicator(isSelected: isSelected, accentColor: accentColor)
}
.padding()
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(isSelected ? accentColor.opacity(Design.Opacity.subtle) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.strokeBorder(
isSelected ? accentColor.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle),
lineWidth: Design.LineWidth.thin
)
)
}
.buttonStyle(.plain)
}
}
// Convenience initializer for rows without a badge
extension SelectableRow where Badge == EmptyView {
/// Creates a selectable row without a badge.
public init(
title: String,
subtitle: String,
isSelected: Bool,
accentColor: Color = .Accent.primary,
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
self.accentColor = accentColor
self.badge = nil
self.action = action
}
}
// MARK: - Selection Indicator
/// A circle indicator that shows selected (checkmark) or unselected (outline) state.
public struct SelectionIndicator: View {
/// Whether the item is selected.
public let isSelected: Bool
/// The accent color for the checkmark.
public let accentColor: Color
/// The size of the indicator.
public let size: CGFloat
/// Creates a selection indicator.
/// - Parameters:
/// - isSelected: Whether selected.
/// - accentColor: Color for checkmark (default: primary accent).
/// - size: Size of the indicator (default: checkmark size from design).
public init(
isSelected: Bool,
accentColor: Color = .Accent.primary,
size: CGFloat = Design.Size.checkmark
) {
self.isSelected = isSelected
self.accentColor = accentColor
self.size = size
}
public var body: some View {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: size))
.foregroundStyle(accentColor)
} else {
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium)
.frame(width: size, height: size)
}
}
}
// MARK: - Badge Pill
/// A capsule-shaped badge for displaying short text values.
///
/// Use this to highlight values, tags, or status indicators.
public struct BadgePill: View {
/// The text to display in the badge.
public let text: String
/// Whether the parent row is selected.
public let isSelected: Bool
/// The accent color.
public let accentColor: Color
/// Creates a badge pill.
/// - Parameters:
/// - text: The badge text.
/// - isSelected: Whether the parent row is selected.
/// - accentColor: Color for the badge (default: primary accent).
public init(
text: String,
isSelected: Bool = false,
accentColor: Color = .Accent.primary
) {
self.text = text
self.isSelected = isSelected
self.accentColor = accentColor
}
public var body: some View {
Text(text)
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
.foregroundStyle(isSelected ? .black : accentColor)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(isSelected ? accentColor : accentColor.opacity(Design.Opacity.hint))
)
}
}
// MARK: - Settings Section Header
/// A section header for settings screens.
public struct SettingsSectionHeader: View {
/// The section title.
public let title: String
/// Optional system image name.
public let systemImage: String?
/// Creates a section header.
/// - Parameters:
/// - title: The section title.
/// - systemImage: Optional SF Symbol name.
public init(title: String, systemImage: String? = nil) {
self.title = title
self.systemImage = systemImage
}
public var body: some View {
HStack(spacing: Design.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
}
Text(title)
.font(.system(size: Design.BaseFontSize.caption, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.textCase(.uppercase)
.tracking(0.5)
Spacer()
}
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xSmall)
}
}
// MARK: - Settings Row
/// A simple settings row with icon, title, and optional value/accessory.
public struct SettingsRow<Accessory: View>: View {
/// The row icon (SF Symbol name).
public let systemImage: String
/// The row title.
public let title: String
/// Optional value text.
public let value: String?
/// The icon background color.
public let iconColor: Color
/// Optional accessory view.
public let accessory: Accessory?
/// Action when tapped.
public let action: () -> Void
/// Creates a settings row.
public init(
systemImage: String,
title: String,
value: String? = nil,
iconColor: Color = .Accent.primary,
@ViewBuilder accessory: () -> Accessory? = { nil as EmptyView? },
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.value = value
self.iconColor = iconColor
self.accessory = accessory()
self.action = action
}
public var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white)
.frame(width: Design.Size.iconContainerSmall, height: Design.Size.iconContainerSmall)
.background(iconColor.opacity(Design.Opacity.heavy))
.clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall))
Text(title)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white)
Spacer()
if let value {
Text(value)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
if let accessory {
accessory
} else {
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.light))
}
}
.padding(.vertical, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.medium)
.background(Color.Surface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
.buttonStyle(.plain)
}
}
// Convenience initializer for rows without accessory
extension SettingsRow where Accessory == EmptyView {
/// Creates a settings row without an accessory.
public init(
systemImage: String,
title: String,
value: String? = nil,
iconColor: Color = .Accent.primary,
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.value = value
self.iconColor = iconColor
self.accessory = nil
self.action = action
}
}
// MARK: - Preview
#Preview {
ScrollView {
VStack(spacing: Design.Spacing.large) {
SettingsSectionHeader(title: "Appearance", systemImage: "paintbrush")
// Selectable rows
VStack(spacing: Design.Spacing.small) {
SelectableRow(
title: "Light",
subtitle: "Always use light mode",
isSelected: true,
action: {}
)
SelectableRow(
title: "Dark",
subtitle: "Always use dark mode",
isSelected: false,
action: {}
)
SelectableRow(
title: "Premium",
subtitle: "Unlock all features",
isSelected: false,
badge: { BadgePill(text: "$9.99", isSelected: false) },
action: {}
)
}
SettingsSectionHeader(title: "Preferences", systemImage: "gearshape")
SettingsToggle(
title: "Sound Effects",
subtitle: "Play sounds for events",
isOn: .constant(true)
)
SegmentedPicker(
title: "Animation Speed",
options: [("Fast", "fast"), ("Normal", "normal"), ("Slow", "slow")],
selection: .constant("normal")
)
VolumePicker(volume: .constant(0.8))
SettingsSectionHeader(title: "About", systemImage: "info.circle")
SettingsRow(
systemImage: "star.fill",
title: "Rate App",
iconColor: .Status.warning,
action: {}
)
SettingsRow(
systemImage: "envelope.fill",
title: "Contact Us",
value: "support@example.com",
iconColor: .Status.info,
action: {}
)
}
.padding()
}
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,64 @@
//
// BedrockTests.swift
// Bedrock
//
// Unit tests for the Bedrock design system.
//
import XCTest
@testable import Bedrock
final class BedrockTests: XCTestCase {
// MARK: - Design Constants Tests
func testSpacingValuesArePositive() {
XCTAssertGreaterThan(Design.Spacing.xxxSmall, 0)
XCTAssertGreaterThan(Design.Spacing.small, 0)
XCTAssertGreaterThan(Design.Spacing.medium, 0)
XCTAssertGreaterThan(Design.Spacing.large, 0)
}
func testSpacingValuesAreOrdered() {
XCTAssertLessThan(Design.Spacing.xxxSmall, Design.Spacing.xxSmall)
XCTAssertLessThan(Design.Spacing.xxSmall, Design.Spacing.xSmall)
XCTAssertLessThan(Design.Spacing.xSmall, Design.Spacing.small)
XCTAssertLessThan(Design.Spacing.small, Design.Spacing.medium)
XCTAssertLessThan(Design.Spacing.medium, Design.Spacing.large)
XCTAssertLessThan(Design.Spacing.large, Design.Spacing.xLarge)
}
func testOpacityValuesAreInRange() {
XCTAssertGreaterThanOrEqual(Design.Opacity.verySubtle, 0)
XCTAssertLessThanOrEqual(Design.Opacity.almostFull, 1)
}
func testCornerRadiusValuesArePositive() {
XCTAssertGreaterThan(Design.CornerRadius.small, 0)
XCTAssertGreaterThan(Design.CornerRadius.medium, 0)
XCTAssertGreaterThan(Design.CornerRadius.large, 0)
}
func testAnimationDurationsArePositive() {
XCTAssertGreaterThan(Design.Animation.quick, 0)
XCTAssertGreaterThan(Design.Animation.standard, 0)
XCTAssertGreaterThan(Design.Animation.springDuration, 0)
}
func testFontSizesArePositive() {
XCTAssertGreaterThan(Design.BaseFontSize.small, 0)
XCTAssertGreaterThan(Design.BaseFontSize.body, 0)
XCTAssertGreaterThan(Design.BaseFontSize.title, 0)
}
func testMinimumTouchTargetMeetsHIG() {
// Apple HIG requires minimum 44pt touch targets
XCTAssertGreaterThanOrEqual(Design.HitTarget.minimum, 44)
}
// MARK: - Version Test
func testVersionIsNotEmpty() {
XCTAssertFalse(BedrockInfo.version.isEmpty)
}
}