From e998661ce1b2dbfce5d91c77d598d278a60a94c6 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 27 Jan 2026 09:50:33 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Sources/Bedrock/Theme/Design.swift | 155 ++++++- Sources/Bedrock/Theme/TYPOGRAPHY_GUIDE.md | 378 ++++++++++++++++++ .../Bedrock/Views/Components/SymbolIcon.swift | 182 +++++++++ .../Bedrock/Views/Settings/BadgePill.swift | 2 +- .../Bedrock/Views/Settings/LicensesView.swift | 8 +- .../Views/Settings/SegmentedPicker.swift | 4 +- .../Views/Settings/SelectableRow.swift | 4 +- .../Views/Settings/SelectionIndicator.swift | 8 +- .../Settings/SettingsNavigationRow.swift | 12 +- .../Bedrock/Views/Settings/SettingsRow.swift | 16 +- .../Settings/SettingsSectionHeader.swift | 6 +- .../Settings/SettingsSegmentedPicker.swift | 12 +- .../Views/Settings/SettingsSlider.swift | 15 +- .../Views/Settings/SettingsToggle.swift | 10 +- .../Settings/iCloudSyncSettingsView.swift | 8 +- Sources/Bedrock/Views/Text/StyledText.swift | 298 ++++++++++++++ 16 files changed, 1032 insertions(+), 86 deletions(-) create mode 100644 Sources/Bedrock/Theme/TYPOGRAPHY_GUIDE.md create mode 100644 Sources/Bedrock/Views/Components/SymbolIcon.swift create mode 100644 Sources/Bedrock/Views/Text/StyledText.swift diff --git a/Sources/Bedrock/Theme/Design.swift b/Sources/Bedrock/Theme/Design.swift index 3d10c9e..b23cb91 100644 --- a/Sources/Bedrock/Theme/Design.swift +++ b/Sources/Bedrock/Theme/Design.swift @@ -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. diff --git a/Sources/Bedrock/Theme/TYPOGRAPHY_GUIDE.md b/Sources/Bedrock/Theme/TYPOGRAPHY_GUIDE.md new file mode 100644 index 0000000..e127282 --- /dev/null +++ b/Sources/Bedrock/Theme/TYPOGRAPHY_GUIDE.md @@ -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 | diff --git a/Sources/Bedrock/Views/Components/SymbolIcon.swift b/Sources/Bedrock/Views/Components/SymbolIcon.swift new file mode 100644 index 0000000..9c5dd6b --- /dev/null +++ b/Sources/Bedrock/Views/Components/SymbolIcon.swift @@ -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() +} diff --git a/Sources/Bedrock/Views/Settings/BadgePill.swift b/Sources/Bedrock/Views/Settings/BadgePill.swift index 8b3cee3..c4f59e0 100644 --- a/Sources/Bedrock/Views/Settings/BadgePill.swift +++ b/Sources/Bedrock/Views/Settings/BadgePill.swift @@ -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) diff --git a/Sources/Bedrock/Views/Settings/LicensesView.swift b/Sources/Bedrock/Views/Settings/LicensesView.swift index cc29b31..509928a 100644 --- a/Sources/Bedrock/Views/Settings/LicensesView.swift +++ b/Sources/Bedrock/Views/Settings/LicensesView.swift @@ -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) } } diff --git a/Sources/Bedrock/Views/Settings/SegmentedPicker.swift b/Sources/Bedrock/Views/Settings/SegmentedPicker.swift index 182dcd6..7dbd4b4 100644 --- a/Sources/Bedrock/Views/Settings/SegmentedPicker.swift +++ b/Sources/Bedrock/Views/Settings/SegmentedPicker.swift @@ -52,7 +52,7 @@ public struct SegmentedPicker: 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: 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) diff --git a/Sources/Bedrock/Views/Settings/SelectableRow.swift b/Sources/Bedrock/Views/Settings/SelectableRow.swift index 4f796e6..88e31eb 100644 --- a/Sources/Bedrock/Views/Settings/SelectableRow.swift +++ b/Sources/Bedrock/Views/Settings/SelectableRow.swift @@ -68,11 +68,11 @@ public struct SelectableRow: 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)) } diff --git a/Sources/Bedrock/Views/Settings/SelectionIndicator.swift b/Sources/Bedrock/Views/Settings/SelectionIndicator.swift index 3e4042d..e2a84d4 100644 --- a/Sources/Bedrock/Views/Settings/SelectionIndicator.swift +++ b/Sources/Bedrock/Views/Settings/SelectionIndicator.swift @@ -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) diff --git a/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift b/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift index bde6f85..08bbc1b 100644 --- a/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift +++ b/Sources/Bedrock/Views/Settings/SettingsNavigationRow.swift @@ -67,22 +67,16 @@ public struct SettingsNavigationRow: 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)) diff --git a/Sources/Bedrock/Views/Settings/SettingsRow.swift b/Sources/Bedrock/Views/Settings/SettingsRow.swift index 6e55f02..8699946 100644 --- a/Sources/Bedrock/Views/Settings/SettingsRow.swift +++ b/Sources/Bedrock/Views/Settings/SettingsRow.swift @@ -54,31 +54,23 @@ public struct SettingsRow: 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) diff --git a/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift b/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift index f475627..3883f21 100644 --- a/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift +++ b/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift @@ -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) diff --git a/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift b/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift index 75426dc..eeb5fa4 100644 --- a/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift +++ b/Sources/Bedrock/Views/Settings/SettingsSegmentedPicker.swift @@ -79,20 +79,16 @@ public struct SettingsSegmentedPicker: 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: 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) diff --git a/Sources/Bedrock/Views/Settings/SettingsSlider.swift b/Sources/Bedrock/Views/Settings/SettingsSlider.swift index 0c38ad7..c93152f 100644 --- a/Sources/Bedrock/Views/Settings/SettingsSlider.swift +++ b/Sources/Bedrock/Views/Settings/SettingsSlider.swift @@ -110,26 +110,23 @@ public struct SettingsSlider: 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: View where if let trailingIcon { trailingIcon - .font(.callout) + .font(.system(size: Design.SymbolSize.inline)) .foregroundStyle(.secondary) } } diff --git a/Sources/Bedrock/Views/Settings/SettingsToggle.swift b/Sources/Bedrock/Views/Settings/SettingsToggle.swift index aafbf88..75fd7df 100644 --- a/Sources/Bedrock/Views/Settings/SettingsToggle.swift +++ b/Sources/Bedrock/Views/Settings/SettingsToggle.swift @@ -78,19 +78,15 @@ public struct SettingsToggle: 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) diff --git a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift index 4e8a4b5..2faf190 100644 --- a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift +++ b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift @@ -115,12 +115,10 @@ public struct iCloudSyncSettingsView: 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: View { viewModel.forceSync() } label: { Text(String(localized: "Sync Now")) - .font(.caption.weight(.medium)) + .font(Design.Typography.captionMedium) .foregroundStyle(accentColor) } } diff --git a/Sources/Bedrock/Views/Text/StyledText.swift b/Sources/Bedrock/Views/Text/StyledText.swift new file mode 100644 index 0000000..385a86e --- /dev/null +++ b/Sources/Bedrock/Views/Text/StyledText.swift @@ -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() +}