Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-27 09:50:33 -06:00
parent 4d84e2ec14
commit e998661ce1
16 changed files with 1032 additions and 86 deletions

View File

@ -153,6 +153,9 @@ public enum Design {
// MARK: - Sizes
/// Common size values for UI elements.
///
/// For icon sizes, use `SymbolSize` instead.
/// For icon container backgrounds, use `iconContainerSmall/Medium/Large`.
public enum Size {
/// Minimum height for tappable rows (Apple HIG: 44pt minimum touch target).
public static let actionRowMinHeight: CGFloat = 44
@ -161,21 +164,18 @@ public enum Design {
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.
/// Badge container sizes (for badge UI elements, not icon sizes).
public static let badgeSmall: CGFloat = 20
public static let badgeMedium: CGFloat = 26
public static let badgeLarge: CGFloat = 32
/// Avatar sizes.
/// Avatar/profile image sizes (not icons - use for user photos).
public static let avatarSmall: CGFloat = 32
public static let avatarMedium: CGFloat = 44
public static let avatarLarge: CGFloat = 64
/// Icon container sizes.
/// Icon container sizes (background frames for icons, not the icon itself).
/// Use with `SymbolSize` for the icon inside.
public static let iconContainerSmall: CGFloat = 28
public static let iconContainerMedium: CGFloat = 40
public static let iconContainerLarge: CGFloat = 56
@ -186,30 +186,149 @@ public enum Design {
public static let maxModalWidth: CGFloat = 450
}
// MARK: - Icon Sizes
// MARK: - Icon Sizes (Raw Values)
/// Standard icon sizes for SF Symbols and custom icons.
/// Raw icon point sizes for SF Symbols and custom icons.
///
/// **Prefer using `SymbolSize` or `SymbolIcon` instead** for semantic naming.
/// This enum provides the underlying values that `SymbolSize` references.
///
/// ## Migration
///
/// ```swift
/// // Old (raw sizes)
/// Image(systemName: "star").font(.system(size: Design.IconSize.hero))
///
/// // New (semantic)
/// SymbolIcon("star", size: .hero, color: .accent)
/// ```
public enum IconSize {
/// Extra small icon (10pt) - tiny indicators.
/// 10pt - tiny indicators.
public static let xSmall: CGFloat = 10
/// Small icon (12pt) - inline with caption text.
/// 12pt - inline with caption text, chevrons.
public static let small: CGFloat = 12
/// Medium icon (16pt) - inline with body text.
/// 16pt - inline with body text, indicators.
public static let medium: CGFloat = 16
/// Large icon (22pt) - standalone icons.
/// 22pt - row icons, checkmarks.
public static let large: CGFloat = 22
/// Extra large icon (28pt) - row icons, list items.
/// 28pt - row icons with containers.
public static let xLarge: CGFloat = 28
/// Double extra large icon (36pt) - card icons, buttons.
/// 36pt - card and button icons.
public static let xxLarge: CGFloat = 36
/// Triple extra large icon (48pt) - feature icons.
/// 48pt - feature callout icons.
public static let xxxLarge: CGFloat = 48
/// Display icon (64pt) - section headers, prominent features.
/// 64pt - section headers, prominent features.
public static let display: CGFloat = 64
/// Hero icon (80pt) - empty states, splash screens.
/// 80pt - empty states, splash screens.
public static let hero: CGFloat = 80
}
// MARK: - Symbol Sizes (Semantic)
/// Semantic icon sizes for common use cases.
///
/// Use these semantic sizes instead of raw `IconSize` values for clearer intent.
///
/// ## Example
///
/// ```swift
/// Image(systemName: "star.fill")
/// .font(.system(size: Design.SymbolSize.row))
/// ```
public enum SymbolSize {
// MARK: - Content Sizes
/// Inline with body text (16pt).
public static let inline: CGFloat = IconSize.medium
/// List row icons (22pt).
public static let row: CGFloat = IconSize.large
/// Row icons with background container (28pt).
public static let rowContainer: CGFloat = IconSize.xLarge
/// Card and button icons (36pt).
public static let card: CGFloat = IconSize.xxLarge
/// Feature callout icons (48pt).
public static let feature: CGFloat = IconSize.xxxLarge
/// Section header icons (64pt).
public static let section: CGFloat = IconSize.display
/// Empty state and hero icons (80pt).
public static let hero: CGFloat = IconSize.hero
// MARK: - Accessory Sizes
/// Navigation chevrons (12pt).
public static let chevron: CGFloat = IconSize.small
/// Status indicators (16pt).
public static let indicator: CGFloat = IconSize.medium
/// Badge icons (12pt).
public static let badge: CGFloat = IconSize.small
}
// MARK: - Typography
/// Semantic font styles for consistent text appearance.
///
/// Use these semantic styles instead of inline `.font()` modifiers.
/// All fonts use SwiftUI system fonts that scale with Dynamic Type.
///
/// ## Example
///
/// ```swift
/// Text("Settings")
/// .font(Design.Typography.settingsTitle)
/// ```
public enum Typography {
// MARK: - Display
/// Large display text for splash screens and heroes.
public static let displayLarge = Font.largeTitle.weight(.bold)
/// Medium display text for major headings.
public static let displayMedium = Font.title.weight(.bold)
/// Small display text for section titles.
public static let displaySmall = Font.title2.weight(.bold)
// MARK: - Headlines
/// Standard headline for card titles and section headers.
public static let headline = Font.headline
/// Bold headline for emphasized titles.
public static let headlineBold = Font.headline.weight(.bold)
// MARK: - Body
/// Large body text.
public static let bodyLarge = Font.body
/// Standard body text for most content.
public static let body = Font.subheadline
/// Medium weight body text for row titles.
public static let bodyMedium = Font.subheadline.weight(.medium)
/// Bold body text for emphasis.
public static let bodyBold = Font.subheadline.weight(.bold)
// MARK: - Caption
/// Standard caption for metadata and descriptions.
public static let caption = Font.caption
/// Medium weight caption for labels.
public static let captionMedium = Font.caption.weight(.medium)
/// Bold caption for section headers and badges.
public static let captionBold = Font.caption.weight(.semibold)
/// Small caption for fine print.
public static let captionSmall = Font.caption2
// MARK: - Semantic Styles
/// Settings row title text.
public static let settingsTitle = Font.subheadline.weight(.medium)
/// Uppercase section header text.
public static let sectionHeader = Font.caption.weight(.semibold)
/// Badge and pill text with rounded design.
public static let badge = Font.subheadline.weight(.bold)
/// Callout text for emphasized rows.
public static let callout = Font.callout
/// Semibold callout for selectable row titles.
public static let calloutBold = Font.callout.weight(.semibold)
}
// MARK: - Scale
/// Scale factors for pressed states, selections, and animations.

View File

@ -0,0 +1,378 @@
# Typography and Iconography Guide
This guide documents the typography and iconography systems in Bedrock for building consistent UI components.
## Quick Reference
| What you need | Use this |
|--------------|----------|
| Font for text | `Design.Typography.*` |
| Icon sizing | `SymbolIcon` component or `Design.SymbolSize.*` |
| Icon container background | `Design.Size.iconContainerSmall/Medium/Large` |
| Avatar/profile image | `Design.Size.avatarSmall/Medium/Large` |
| Badge container | `Design.Size.badgeSmall/Medium/Large` |
**Deprecated:** `Design.IconSize.*` - use `SymbolSize` instead (kept for backwards compatibility).
---
## Typography
All fonts are defined in `Design.Typography` and scale automatically with Dynamic Type.
### Semantic Font Styles
Use these semantic styles instead of inline `.font()` modifiers:
| Style | Usage | SwiftUI Equivalent |
|-------|-------|-------------------|
| `displayLarge` | Splash screens, heroes | `.largeTitle.weight(.bold)` |
| `displayMedium` | Major headings | `.title.weight(.bold)` |
| `displaySmall` | Section titles | `.title2.weight(.bold)` |
| `headline` | Card titles, section headers | `.headline` |
| `headlineBold` | Emphasized titles | `.headline.weight(.bold)` |
| `bodyLarge` | Large body text | `.body` |
| `body` | Standard content | `.subheadline` |
| `bodyMedium` | Row titles | `.subheadline.weight(.medium)` |
| `bodyBold` | Emphasized text | `.subheadline.weight(.bold)` |
| `caption` | Metadata, descriptions | `.caption` |
| `captionMedium` | Labels | `.caption.weight(.medium)` |
| `captionBold` | Section headers, badges | `.caption.weight(.semibold)` |
| `captionSmall` | Fine print | `.caption2` |
| `settingsTitle` | Settings row titles | `.subheadline.weight(.medium)` |
| `sectionHeader` | Uppercase section headers | `.caption.weight(.semibold)` |
| `badge` | Badge/pill text | `.subheadline.weight(.bold)` |
| `callout` | Callout text | `.callout` |
| `calloutBold` | Selectable row titles | `.callout.weight(.semibold)` |
### Two Approaches
There are two ways to apply typography. Choose based on your needs:
| Approach | Best For |
|----------|----------|
| `*Label` components | Simple text with default styling |
| `Text().font(Design.Typography.*)` | Complex layouts, additional modifiers |
### Preferred: StyledText Components
**Use these for most cases.** They combine font + color with sensible defaults:
```swift
// Titles
TitleLabel("Settings", style: .settings)
TitleLabel("Morning Ritual", style: .headline)
TitleLabel("Welcome", style: .displayLarge)
// Body text
BodyLabel("Description text", emphasis: .secondary)
BodyLabel("Important note", emphasis: .primary, weight: .medium)
// Captions
CaptionLabel("Day 3 of 28")
CaptionLabel("PRO", style: .badge)
// Section headers
SectionHeader("General")
SectionHeader("Notifications", icon: "bell.fill")
```
### Fallback: Direct Typography Usage
**Use `Design.Typography.*` when you need more control:**
```swift
// Need .multilineTextAlignment or .lineLimit
Text("A longer description that might wrap")
.font(Design.Typography.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.lineLimit(3)
// Localized strings with interpolation
Text("Day \(dayNumber) of \(totalDays)")
.font(Design.Typography.caption)
.foregroundStyle(AppTextColors.tertiary)
// Custom colors not in the component
Text("Warning message")
.font(Design.Typography.bodyMedium)
.foregroundStyle(AppStatus.warning)
// Animated text
Text(isExpanded ? "Collapse" : "Expand")
.font(Design.Typography.callout)
.animation(.easeInOut, value: isExpanded)
```
### Migration Examples
```swift
// Before (inconsistent)
Text("Settings")
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
// After (using component)
TitleLabel("Settings", style: .settings)
// After (using direct typography, if more control needed)
Text("Settings")
.font(Design.Typography.settingsTitle)
.foregroundStyle(.primary)
```
---
## Iconography
All SF Symbol sizing is defined in `Design.SymbolSize` with semantic names.
### Semantic Icon Sizes
| Size | Points | Usage |
|------|--------|-------|
| `inline` | 16pt | Inline with body text |
| `row` | 22pt | List row icons |
| `rowContainer` | 28pt | Row icons with background |
| `card` | 36pt | Card/button icons |
| `feature` | 48pt | Feature callout icons |
| `section` | 64pt | Section header icons |
| `hero` | 80pt | Empty state/hero icons |
| `chevron` | 12pt | Navigation chevrons |
| `indicator` | 16pt | Status indicators |
| `badge` | 12pt | Badge icons |
### Using SymbolIcon
Use `SymbolIcon` for consistent SF Symbol styling:
```swift
// Before (inconsistent)
Image(systemName: "star.fill")
.font(.subheadline)
.foregroundStyle(.secondary)
// After (consistent)
SymbolIcon("star.fill", size: .row, color: .secondary)
```
### Size Examples
```swift
// Inline with text
SymbolIcon("checkmark", size: .inline, color: .green)
// Row icon in a list
SymbolIcon("star.fill", size: .row, color: .yellow)
// Card icon
SymbolIcon("sparkles", size: .card, color: .accent)
// Hero/empty state icon
SymbolIcon("moon.stars.fill", size: .hero, color: .accent)
// Navigation chevron
SymbolIcon.chevron() // Convenience method
// Checkmark indicator
SymbolIcon.checkmark(color: .green) // Convenience method
```
### Icon + Container Pattern
For icons with backgrounds, combine `SymbolIcon` with `Design.Size.iconContainer*`:
```swift
SymbolIcon("star.fill", size: .inline, color: .white)
.frame(width: Design.Size.iconContainerSmall, height: Design.Size.iconContainerSmall)
.background(AppAccent.primary.opacity(Design.Opacity.heavy))
.clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall))
```
Container sizes:
- `iconContainerSmall`: 28pt
- `iconContainerMedium`: 40pt
- `iconContainerLarge`: 56pt
---
## When to Use Fallback Approach
The `*Label` components work for ~80% of cases. Use `Text().font(Design.Typography.*)` for these exceptions:
### 1. String Interpolation
```swift
// Label components only accept String, not interpolated strings
Text(String(localized: "Arc \(arcNumber) Complete"))
.font(Design.Typography.body)
.foregroundStyle(AppTextColors.secondary)
```
### 2. Additional Modifiers
```swift
// Need .multilineTextAlignment(), .lineLimit(), or .frame()
Text(description)
.font(Design.Typography.caption)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
```
### 3. Font Design Variants
```swift
// Need .fontDesign(.rounded) or similar
Text(formattedValue)
.font(Design.Typography.settingsTitle)
.fontDesign(.rounded)
.foregroundStyle(.secondary)
```
### 4. Conditional Colors
```swift
// Color changes based on state
Text(title)
.font(Design.Typography.headline)
.foregroundStyle(isActive ? AppTextColors.primary : AppTextColors.tertiary)
```
### 5. Button Labels with Layout
```swift
// Need .frame() for button sizing
Text(actionTitle)
.font(Design.Typography.headline)
.foregroundStyle(AppTextColors.primary)
.frame(maxWidth: .infinity)
.frame(height: 50)
```
### 6. Custom Theme Colors (Bedrock)
```swift
// Bedrock uses .white for dark backgrounds, not semantic colors
Text(title)
.font(Design.Typography.settingsTitle)
.foregroundStyle(.white)
```
---
## Migration Guide
### From Inline Fonts
Replace inline font modifiers with `Design.Typography`:
```swift
// Old
.font(.subheadline.weight(.medium))
// New
.font(Design.Typography.settingsTitle)
// Old
.font(.caption.weight(.semibold))
// New
.font(Design.Typography.sectionHeader)
// Old
.font(.headline)
// New
.font(Design.Typography.headline)
```
### From Inline Icons
Replace inline Image styling with `SymbolIcon`:
```swift
// Old
Image(systemName: "star.fill")
.font(.body)
.foregroundStyle(.accent)
// New
SymbolIcon("star.fill", size: .row, color: .accent)
```
### From Hero Icons
Replace hero icon patterns with semantic sizes:
```swift
// Old
Image(systemName: "sparkles")
.font(.system(size: Design.IconSize.hero))
.foregroundStyle(AppAccent.primary)
// New
SymbolIcon("sparkles", size: .hero, color: AppAccent.primary)
```
---
## Best Practices
1. **Always use semantic styles** - Use `Design.Typography.settingsTitle` instead of `.subheadline.weight(.medium)`
2. **Consistent icon sizing** - Use `SymbolIcon` sizes that match the context (`.row` for lists, `.hero` for empty states)
3. **Preserve Dynamic Type** - All typography scales automatically; avoid fixed sizes
4. **Color consistency** - Pair typography with semantic colors (`AppTextColors.primary`, `.secondary`, `.tertiary`)
5. **Container sizing** - Use `Design.Size.iconContainer*` for icon backgrounds, `Design.SymbolSize.*` for the icon itself
6. **Audit regularly** - Search for inline `.font()` and `Image(systemName:)` to find inconsistencies
---
## What NOT to Use
These patterns are deprecated or should be avoided:
| Avoid | Use Instead |
|-------|-------------|
| `Design.IconSize.*` | `SymbolIcon` or `Design.SymbolSize.*` |
| `.font(.subheadline.weight(.medium))` | `Design.Typography.settingsTitle` |
| `.font(.system(size: 80))` | `SymbolIcon("icon", size: .hero)` |
| Hardcoded frame sizes like `44x44` | `Design.Size.actionRowMinHeight` or `Design.Size.iconContainer*` |
---
## Size Reference
### Design.Size (UI Elements)
| Constant | Value | Use For |
|----------|-------|---------|
| `actionRowMinHeight` | 44pt | Minimum tappable row height |
| `buttonHeight` | 50pt | Standard button height |
| `iconContainerSmall` | 28pt | Small icon backgrounds |
| `iconContainerMedium` | 40pt | Medium icon backgrounds |
| `iconContainerLarge` | 56pt | Large icon backgrounds |
| `avatarSmall` | 32pt | Small profile images |
| `avatarMedium` | 44pt | Medium profile images |
| `avatarLarge` | 64pt | Large profile images |
| `badgeSmall` | 20pt | Small badge containers |
| `badgeMedium` | 26pt | Medium badge containers |
| `badgeLarge` | 32pt | Large badge containers |
### Design.SymbolSize (Icon Point Sizes)
| Constant | Value | Use For |
|----------|-------|---------|
| `inline` | 16pt | Inline with body text |
| `row` | 22pt | List row icons |
| `rowContainer` | 28pt | Row icons with background |
| `card` | 36pt | Card/button icons |
| `feature` | 48pt | Feature callouts |
| `section` | 64pt | Section headers |
| `hero` | 80pt | Empty states |
| `chevron` | 12pt | Navigation chevrons |
| `indicator` | 16pt | Status indicators |
| `badge` | 12pt | Badge icons |

View File

@ -0,0 +1,182 @@
//
// SymbolIcon.swift
// Bedrock
//
// A consistent SF Symbol icon component with semantic sizing.
//
import SwiftUI
/// A consistent SF Symbol icon with semantic sizing.
///
/// Use `SymbolIcon` instead of inline `Image(systemName:)` with manual font
/// and color modifiers to ensure visual consistency across your app.
///
/// ## Example
///
/// ```swift
/// // Before
/// Image(systemName: "star.fill")
/// .font(.subheadline)
/// .foregroundStyle(.secondary)
///
/// // After
/// SymbolIcon("star.fill", size: .row, color: .secondary)
/// ```
///
/// ## Sizes
///
/// - `inline`: 16pt - inline with body text
/// - `row`: 22pt - list row icons
/// - `rowContainer`: 28pt - row icons with background
/// - `card`: 36pt - card and button icons
/// - `feature`: 48pt - feature callout icons
/// - `section`: 64pt - section header icons
/// - `hero`: 80pt - empty state and hero icons
/// - `chevron`: 12pt - navigation chevrons
/// - `indicator`: 16pt - status indicators
/// - `badge`: 12pt - badge icons
public struct SymbolIcon: View {
private let systemName: String
private let size: Size
private let color: Color
private let weight: Font.Weight
/// Semantic icon sizes.
public enum Size {
/// Inline with body text (16pt).
case inline
/// List row icons (22pt).
case row
/// Row icons with background container (28pt).
case rowContainer
/// Card and button icons (36pt).
case card
/// Feature callout icons (48pt).
case feature
/// Section header icons (64pt).
case section
/// Empty state and hero icons (80pt).
case hero
/// Navigation chevrons (12pt).
case chevron
/// Status indicators (16pt).
case indicator
/// Badge icons (12pt).
case badge
/// The point size for this semantic size.
var pointSize: CGFloat {
switch self {
case .inline: Design.SymbolSize.inline
case .row: Design.SymbolSize.row
case .rowContainer: Design.SymbolSize.rowContainer
case .card: Design.SymbolSize.card
case .feature: Design.SymbolSize.feature
case .section: Design.SymbolSize.section
case .hero: Design.SymbolSize.hero
case .chevron: Design.SymbolSize.chevron
case .indicator: Design.SymbolSize.indicator
case .badge: Design.SymbolSize.badge
}
}
}
/// Creates a symbol icon with semantic sizing.
/// - Parameters:
/// - systemName: The SF Symbol name.
/// - size: The semantic size (default: `.row`).
/// - color: The icon color (default: `.primary`).
/// - weight: The font weight (default: `.regular`).
public init(
_ systemName: String,
size: Size = .row,
color: Color = .primary,
weight: Font.Weight = .regular
) {
self.systemName = systemName
self.size = size
self.color = color
self.weight = weight
}
public var body: some View {
Image(systemName: systemName)
.font(.system(size: size.pointSize, weight: weight))
.foregroundStyle(color)
}
}
// MARK: - Convenience Initializers
extension SymbolIcon {
/// Creates a chevron icon for navigation rows.
/// - Parameter color: The chevron color (default: `.secondary`).
public static func chevron(color: Color = .secondary) -> SymbolIcon {
SymbolIcon("chevron.right", size: .chevron, color: color, weight: .medium)
}
/// Creates a checkmark icon for selection indicators.
/// - Parameter color: The checkmark color.
public static func checkmark(color: Color) -> SymbolIcon {
SymbolIcon("checkmark.circle.fill", size: .row, color: color)
}
}
// MARK: - Preview
#Preview {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
Group {
HStack {
SymbolIcon("star.fill", size: .badge, color: .yellow)
Text("Badge (12pt)")
}
HStack {
SymbolIcon("star.fill", size: .chevron, color: .yellow)
Text("Chevron (12pt)")
}
HStack {
SymbolIcon("star.fill", size: .inline, color: .yellow)
Text("Inline (16pt)")
}
HStack {
SymbolIcon("star.fill", size: .row, color: .yellow)
Text("Row (22pt)")
}
HStack {
SymbolIcon("star.fill", size: .rowContainer, color: .yellow)
Text("Row Container (28pt)")
}
HStack {
SymbolIcon("star.fill", size: .card, color: .yellow)
Text("Card (36pt)")
}
HStack {
SymbolIcon("star.fill", size: .feature, color: .yellow)
Text("Feature (48pt)")
}
}
Divider()
HStack {
SymbolIcon("star.fill", size: .section, color: .yellow)
Text("Section (64pt)")
}
HStack {
SymbolIcon("star.fill", size: .hero, color: .yellow)
Text("Hero (80pt)")
}
Divider()
HStack {
Text("Settings Row")
Spacer()
SymbolIcon.chevron()
}
}
.padding()
}

View File

@ -37,7 +37,7 @@ public struct BadgePill: View {
public var body: some View {
Text(text)
.font(.subheadline.weight(.bold))
.font(Design.Typography.badge)
.fontDesign(.rounded)
.foregroundStyle(isSelected ? .black : accentColor)
.padding(.horizontal, Design.Spacing.small)

View File

@ -76,16 +76,16 @@ public struct LicensesView: View {
SettingsCard(backgroundColor: cardBackgroundColor, borderColor: cardBorderColor) {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(license.name)
.font(.subheadline.weight(.bold))
.font(Design.Typography.bodyBold)
.foregroundStyle(.white)
Text(license.description)
.font(.caption)
.font(Design.Typography.caption)
.foregroundStyle(.white.opacity(Design.Opacity.strong))
HStack {
Label(license.licenseType, systemImage: "doc.text")
.font(.caption2)
.font(Design.Typography.captionSmall)
.foregroundStyle(accentColor)
Spacer()
@ -93,7 +93,7 @@ public struct LicensesView: View {
if let linkURL = URL(string: license.url) {
Link(destination: linkURL) {
Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square")
.font(.caption2)
.font(Design.Typography.captionSmall)
.foregroundStyle(accentColor)
}
}

View File

@ -52,7 +52,7 @@ public struct SegmentedPicker<T: Equatable>: View {
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(title)
.font(.subheadline.weight(.medium))
.font(Design.Typography.settingsTitle)
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.small) {
@ -62,7 +62,7 @@ public struct SegmentedPicker<T: Equatable>: View {
selection = option.1
} label: {
Text(option.0)
.font(.subheadline.weight(.medium))
.font(Design.Typography.settingsTitle)
.foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)

View File

@ -68,11 +68,11 @@ public struct SelectableRow<Badge: View>: View {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.callout.weight(.semibold))
.font(Design.Typography.calloutBold)
.foregroundStyle(.white)
Text(subtitle)
.font(.subheadline)
.font(Design.Typography.body)
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}

View File

@ -22,11 +22,11 @@ public struct SelectionIndicator: View {
/// - Parameters:
/// - isSelected: Whether selected.
/// - accentColor: Color for checkmark (default: primary accent).
/// - size: Size of the indicator (default: checkmark size from design).
/// - size: Size of the indicator (default: row symbol size).
public init(
isSelected: Bool,
accentColor: Color = .Accent.primary,
size: CGFloat = Design.Size.checkmark
size: CGFloat = Design.SymbolSize.row
) {
self.isSelected = isSelected
self.accentColor = accentColor
@ -35,9 +35,7 @@ public struct SelectionIndicator: View {
public var body: some View {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: size))
.foregroundStyle(accentColor)
SymbolIcon("checkmark.circle.fill", size: .row, color: accentColor)
} else {
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium)

View File

@ -67,22 +67,16 @@ public struct SettingsNavigationRow<Destination: View>: View {
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
TitleLabel(title, style: .settings)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
CaptionLabel(subtitle)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
SymbolIcon.chevron(color: .secondary)
}
.padding(Design.Spacing.medium)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))

View File

@ -54,31 +54,23 @@ public struct SettingsRow<Accessory: View>: View {
public var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.font(.subheadline)
.foregroundStyle(.white)
SymbolIcon(systemImage, size: .inline, color: .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(.subheadline)
.foregroundStyle(.primary)
BodyLabel(title)
Spacer()
if let value {
Text(value)
.font(.subheadline)
.foregroundStyle(.secondary)
BodyLabel(value, emphasis: .secondary)
}
if let accessory {
accessory
} else {
Image(systemName: "chevron.right")
.font(.subheadline.weight(.medium))
.foregroundStyle(.tertiary)
SymbolIcon.chevron()
}
}
.padding(.vertical, Design.Spacing.medium)

View File

@ -36,13 +36,11 @@ public struct SettingsSectionHeader: View {
public var body: some View {
HStack(spacing: Design.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
.font(.subheadline)
.foregroundStyle(accentColor.opacity(Design.Opacity.strong))
SymbolIcon(systemImage, size: .inline, color: accentColor.opacity(Design.Opacity.strong))
}
Text(title)
.font(.caption.weight(.semibold))
.font(Design.Typography.sectionHeader)
.foregroundStyle(.secondary)
.textCase(.uppercase)
.tracking(0.5)

View File

@ -79,20 +79,16 @@ public struct SettingsSegmentedPicker<T: Equatable, Accessory: View>: View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Title row with optional accessory
HStack(spacing: Design.Spacing.xSmall) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
TitleLabel(title, style: .settings)
if let titleAccessory {
titleAccessory
.font(.caption2)
.font(Design.Typography.captionSmall)
}
}
// Subtitle
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
CaptionLabel(subtitle)
// Segmented buttons
HStack(spacing: Design.Spacing.small) {
@ -102,7 +98,7 @@ public struct SettingsSegmentedPicker<T: Equatable, Accessory: View>: View {
selection = option.1
} label: {
Text(option.0)
.font(.subheadline.weight(.medium))
.font(Design.Typography.settingsTitle)
.foregroundStyle(selection == option.1 ? Color.white : .primary)
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)

View File

@ -110,26 +110,23 @@ public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
TitleLabel(title, style: .settings)
Spacer()
// Exception: Needs .fontDesign(.rounded) modifier
Text(format(value))
.font(.subheadline.weight(.medium))
.font(Design.Typography.settingsTitle)
.fontDesign(.rounded)
.foregroundStyle(.secondary)
}
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
CaptionLabel(subtitle)
HStack(spacing: Design.Spacing.medium) {
if let leadingIcon {
leadingIcon
.font(.caption2)
.font(.system(size: Design.SymbolSize.badge))
.foregroundStyle(.secondary)
}
@ -138,7 +135,7 @@ public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where
if let trailingIcon {
trailingIcon
.font(.callout)
.font(.system(size: Design.SymbolSize.inline))
.foregroundStyle(.secondary)
}
}

View File

@ -78,19 +78,15 @@ public struct SettingsToggle<Accessory: View>: View {
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
TitleLabel(title, style: .settings)
if let titleAccessory {
titleAccessory
.font(.caption2)
.font(Design.Typography.captionSmall)
}
}
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
BodyLabel(subtitle, emphasis: .secondary)
}
}
.tint(accentColor)

View File

@ -115,12 +115,10 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
// Sync status (show when enabled and available)
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
HStack(spacing: Design.Spacing.small) {
Image(systemName: syncStatusIcon)
.font(.subheadline)
.foregroundStyle(syncStatusColor)
SymbolIcon(syncStatusIcon, size: .inline, color: syncStatusColor)
Text(syncStatusText)
.font(.caption)
.font(Design.Typography.caption)
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Spacer()
@ -129,7 +127,7 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
viewModel.forceSync()
} label: {
Text(String(localized: "Sync Now"))
.font(.caption.weight(.medium))
.font(Design.Typography.captionMedium)
.foregroundStyle(accentColor)
}
}

View File

@ -0,0 +1,298 @@
//
// StyledText.swift
// Bedrock
//
// Semantic text components for consistent typography.
//
import SwiftUI
// MARK: - Title Label
/// A title label with semantic styling.
///
/// Use `TitleLabel` for card titles, section headers, and row titles
/// to ensure consistent typography across your app.
///
/// ## Example
///
/// ```swift
/// TitleLabel("Settings", style: .settings)
/// TitleLabel("Morning Ritual", style: .card)
/// ```
public struct TitleLabel: View {
private let text: String
private let style: Style
private let color: Color?
/// Title label styles.
public enum Style {
/// Large display title for splash screens.
case displayLarge
/// Medium display title for major headings.
case displayMedium
/// Small display title for section titles.
case displaySmall
/// Standard headline for cards and sections.
case headline
/// Settings row title.
case settings
/// Callout style for selectable rows.
case callout
var font: Font {
switch self {
case .displayLarge: Design.Typography.displayLarge
case .displayMedium: Design.Typography.displayMedium
case .displaySmall: Design.Typography.displaySmall
case .headline: Design.Typography.headline
case .settings: Design.Typography.settingsTitle
case .callout: Design.Typography.calloutBold
}
}
}
/// Creates a title label.
/// - Parameters:
/// - text: The text to display.
/// - style: The title style (default: `.headline`).
/// - color: Optional color override (default: `.primary`).
public init(_ text: String, style: Style = .headline, color: Color? = nil) {
self.text = text
self.style = style
self.color = color
}
public var body: some View {
Text(text)
.font(style.font)
.foregroundStyle(color ?? .primary)
}
}
// MARK: - Body Label
/// A body text label with emphasis levels.
///
/// Use `BodyLabel` for descriptive text, row subtitles, and content
/// to ensure consistent typography across your app.
///
/// ## Example
///
/// ```swift
/// BodyLabel("Your daily rituals", emphasis: .secondary)
/// BodyLabel("Important note", emphasis: .primary, weight: .medium)
/// BodyLabel("Custom color", color: AppTextColors.primary)
/// ```
public struct BodyLabel: View {
private let text: String
private let emphasis: Emphasis
private let weight: Weight
private let color: Color?
/// Body text emphasis levels.
public enum Emphasis {
/// Primary text color.
case primary
/// Secondary text color.
case secondary
var color: Color {
switch self {
case .primary: .primary
case .secondary: .secondary
}
}
}
/// Body text weight.
public enum Weight {
case regular
case medium
case bold
var font: Font {
switch self {
case .regular: Design.Typography.body
case .medium: Design.Typography.bodyMedium
case .bold: Design.Typography.bodyBold
}
}
}
/// Creates a body label.
/// - Parameters:
/// - text: The text to display.
/// - emphasis: The emphasis level (default: `.primary`).
/// - weight: The font weight (default: `.regular`)one /// - color: Optional color override (takes precedence over emphasis).
public init(_ text: String, emphasis: Emphasis = .primary, weight: Weight = .regular, color: Color? = nil) {
self.text = text
self.emphasis = emphasis
self.weight = weight
self.color = color
}
public var body: some View {
Text(text)
.font(weight.font)
.foregroundStyle(color ?? emphasis.color)
}
}
// MARK: - Caption Label
/// A caption label for metadata and small text.
///
/// Use `CaptionLabel` for timestamps, metadata, badges, and fine print
/// to ensure consistent typography across your app.
///
/// ## Example
///
/// ```swift
/// CaptionLabel("Day 6 of 28", style: .badge)
/// CaptionLabel("Last updated 2 hours ago", style: .standard)
/// ```
public struct CaptionLabel: View {
private let text: String
private let style: Style
private let color: Color?
/// Caption label styles.
public enum Style {
/// Standard caption for metadata.
case standard
/// Medium weight caption for labels.
case medium
/// Bold caption for section headers.
case bold
/// Small caption for fine print.
case small
/// Badge text with bold weight.
case badge
/// Uppercase section header.
case sectionHeader
var font: Font {
switch self {
case .standard: Design.Typography.caption
case .medium: Design.Typography.captionMedium
case .bold: Design.Typography.captionBold
case .small: Design.Typography.captionSmall
case .badge: Design.Typography.badge
case .sectionHeader: Design.Typography.sectionHeader
}
}
var isUppercase: Bool {
self == .sectionHeader
}
}
/// Creates a caption label.
/// - Parameters:
/// - text: The text to display.
/// - style: The caption style (default: `.standard`).
/// - color: Optional color override (default: `.secondary`).
public init(_ text: String, style: Style = .standard, color: Color? = nil) {
self.text = text
self.style = style
self.color = color
}
public var body: some View {
Text(text)
.font(style.font)
.foregroundStyle(color ?? .secondary)
.textCase(style.isUppercase ? .uppercase : nil)
}
}
// MARK: - Section Header
/// A section header label with uppercase styling.
///
/// Use `SectionHeader` for grouped list section titles
/// to match iOS Settings app conventions.
///
/// ## Example
///
/// ```swift
/// SectionHeader("GENERAL")
/// SectionHeader("Notifications", icon: "bell.fill")
/// ```
public struct SectionHeader: View {
private let title: String
private let icon: String?
private let accentColor: Color
/// Creates a section header.
/// - Parameters:
/// - title: The header text (will be uppercased).
/// - icon: Optional SF Symbol name.
/// - accentColor: Icon accent color (default: `.Accent.primary`).
public init(_ title: String, icon: String? = nil, accentColor: Color = .Accent.primary) {
self.title = title
self.icon = icon
self.accentColor = accentColor
}
public var body: some View {
HStack(spacing: Design.Spacing.small) {
if let icon {
SymbolIcon(icon, size: .inline, color: accentColor.opacity(Design.Opacity.strong))
}
Text(title)
.font(Design.Typography.sectionHeader)
.foregroundStyle(.secondary)
.textCase(.uppercase)
.tracking(0.5)
}
}
}
// MARK: - Preview
#Preview("Title Labels") {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
TitleLabel("Display Large", style: .displayLarge)
TitleLabel("Display Medium", style: .displayMedium)
TitleLabel("Display Small", style: .displaySmall)
TitleLabel("Headline", style: .headline)
TitleLabel("Settings Title", style: .settings)
TitleLabel("Callout", style: .callout)
}
.padding()
}
#Preview("Body Labels") {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
BodyLabel("Primary body text")
BodyLabel("Secondary body text", emphasis: .secondary)
BodyLabel("Custom color body", color: .gray)
BodyLabel("Medium weight body", weight: .medium)
BodyLabel("Bold body text", weight: .bold)
}
.padding()
}
#Preview("Caption Labels") {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
CaptionLabel("Standard caption")
CaptionLabel("Medium caption", style: .medium)
CaptionLabel("Bold caption", style: .bold)
CaptionLabel("Small caption", style: .small)
CaptionLabel("Badge text", style: .badge)
CaptionLabel("Section Header", style: .sectionHeader)
}
.padding()
}
#Preview("Section Headers") {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
SectionHeader("General")
SectionHeader("Notifications", icon: "bell.fill")
SectionHeader("Privacy", icon: "lock.fill", accentColor: .blue)
}
.padding()
}