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

This commit is contained in:
Matt Bruce 2026-01-27 11:55:51 -06:00
parent 7859b22927
commit 492e088cef
15 changed files with 189 additions and 202 deletions

View File

@ -9,9 +9,7 @@ The typography system consists of:
1. **`Theme`** - Global theme registration for all color providers
2. **`Typography` enum** - All font definitions with explicit naming
3. **`TextEmphasis` enum** - Semantic text colors
4. **`StyledLabel`** - Simple text component combining typography + emphasis
5. **`IconLabel`** - Icon + text component
6. **`.typography()` modifier** - View extension for applying fonts
4. **`Text().styled()` / `Label().styled()`** - View extensions for semantic styling
---
@ -37,8 +35,8 @@ This enables Bedrock components to use your app's colors automatically.
After registration, use `Theme.*` to access your registered colors:
```swift
// In Bedrock components (automatic via TextEmphasis)
StyledLabel("Title", .heading) // Uses Theme.Text.primary
// In views (automatic via TextEmphasis)
Text("Title").styled(.heading) // Uses Theme.Text.primary
// Direct access when needed
let bgColor = Theme.Surface.card
@ -88,7 +86,7 @@ All fonts are defined in the `Typography` enum. Each case name explicitly descri
## TextEmphasis Enum
Semantic text colors resolved from the registered `TextTheme`:
Semantic text colors resolved from the registered Theme:
| Emphasis | Description |
|----------|-------------|
@ -101,52 +99,67 @@ Semantic text colors resolved from the registered `TextTheme`:
### When to Use `.custom()`
Only use `.custom()` for colors that aren't text colors:
Only use `.custom()` for colors that aren't semantic text colors:
```swift
// ✅ Semantic text colors - use the emphasis value directly
StyledLabel("Title", .heading) // .primary is default
StyledLabel("Subtitle", .subheading, emphasis: .secondary)
StyledLabel("Hint", .caption, emphasis: .tertiary)
Text("Title").styled(.heading) // .primary is default
Text("Subtitle").styled(.subheading, emphasis: .secondary)
Text("Hint").styled(.caption, emphasis: .tertiary)
// ✅ Use .custom() for non-text colors
StyledLabel("Success!", .heading, emphasis: .custom(AppStatus.success))
StyledLabel("Arc 3", .caption, emphasis: .custom(AppAccent.primary))
Text("Success!").styled(.heading, emphasis: .custom(AppStatus.success))
Text("Arc 3").styled(.caption, emphasis: .custom(AppAccent.primary))
// ✅ Use .custom() for conditional colors
StyledLabel(text, .body, emphasis: .custom(isActive ? AppStatus.success : AppTextColors.tertiary))
Text(text).styled(.body, emphasis: .custom(isActive ? AppStatus.success : AppTextColors.tertiary))
```
---
## StyledLabel Component
## Text().styled() Extension
A single component for all styled text:
The primary way to style text:
```swift
StyledLabel(
_ text: String,
_ typography: Typography = .body,
emphasis: TextEmphasis = .primary,
alignment: TextAlignment? = nil,
lineLimit: Int? = nil
)
Text(_ text: String)
.styled(_ typography: Typography = .body, emphasis: TextEmphasis = .primary)
```
### Examples
```swift
// Simple - uses registered theme's primary color
StyledLabel("Morning Ritual", .heading)
StyledLabel("Day 6 of 28", .caption, emphasis: .secondary)
Text("Morning Ritual").styled(.heading)
Text("Day 6 of 28").styled(.caption, emphasis: .secondary)
// With alignment and line limit
StyledLabel("Centered text", .body, alignment: .center)
StyledLabel("One line", .caption, emphasis: .tertiary, lineLimit: 1)
// String interpolation
Text("Count: \(count)").styled(.bodyEmphasis)
// With additional modifiers
Text("Centered text")
.styled(.body)
.multilineTextAlignment(.center)
Text("One line")
.styled(.caption, emphasis: .tertiary)
.lineLimit(1)
// Font design variants
Text("$9.99")
.styled(.subheadingEmphasis)
.fontDesign(.rounded)
// Uppercase headers
Text("SECTION")
.styled(.captionEmphasis, emphasis: .secondary)
.textCase(.uppercase)
.tracking(0.5)
// In buttons
Button(action: onContinue) {
StyledLabel("Continue", .heading, emphasis: .inverse)
Text("Continue")
.styled(.heading, emphasis: .inverse)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(AppAccent.primary)
@ -155,66 +168,48 @@ Button(action: onContinue) {
---
## IconLabel Component
## Label().styled() Extension
For icon + text combinations:
```swift
IconLabel(_ icon: String, _ text: String, _ typography: Typography = .body, emphasis: TextEmphasis = .primary)
Label(_ text: String, systemImage: String)
.styled(_ typography: Typography = .body, emphasis: TextEmphasis = .primary)
```
### Examples
```swift
IconLabel("bell.fill", "Notifications", .subheading)
IconLabel("star.fill", "Favorites", .body, emphasis: .secondary)
IconLabel("flame.fill", "5-day streak", .caption, emphasis: .custom(AppStatus.success))
Label("Notifications", systemImage: "bell.fill")
.styled(.subheading)
Label("Favorites", systemImage: "star.fill")
.styled(.body, emphasis: .secondary)
Label("5-day streak", systemImage: "flame.fill")
.styled(.caption, emphasis: .custom(AppStatus.success))
// In buttons
Button(action: addItem) {
Label("Add to Rituals", systemImage: "plus.circle.fill")
.styled(.heading, emphasis: .inverse)
.frame(maxWidth: .infinity)
.background(AppAccent.primary)
}
```
---
## When to Use StyledLabel vs Text()
### Use StyledLabel (Preferred)
Use `StyledLabel` for 95% of text:
```swift
StyledLabel("Settings", .subheadingEmphasis)
StyledLabel("Description", .subheading, emphasis: .secondary)
StyledLabel("Centered", .body, alignment: .center)
```
### Use Text() with .typography() (Rare Exceptions)
Only when you need modifiers `StyledLabel` doesn't support:
```swift
// Font design variants
Text(formattedValue)
.typography(.subheadingEmphasis)
.fontDesign(.rounded)
// TextField styling
TextField("Placeholder", text: $value)
.typography(.heading)
// Inside special view builders (like Charts AxisValueLabel)
AxisValueLabel {
Text("\(value)%")
.font(Typography.caption2.font)
.foregroundStyle(color)
}
```
### Never Use Raw `.font()` with System Fonts
## Never Use Raw `.font()` with System Fonts
```swift
// ❌ BAD
Text("Title").font(.headline)
Text("Body").font(.body).foregroundStyle(.white)
// ✅ GOOD
StyledLabel("Title", .heading)
Text("Title").styled(.heading)
Text("Body").styled(.body, emphasis: .inverse)
```
---
@ -227,16 +222,21 @@ StyledLabel("Title", .heading)
// OLD: Design.Typography
Text("Title").font(Design.Typography.headline)
// NEW: StyledLabel
StyledLabel("Title", .heading)
// NEW: Text().styled()
Text("Title").styled(.heading)
// OLD: Multiple Label components
TitleLabel("Title", style: .headline, color: .white)
BodyLabel("Body", emphasis: .secondary)
// NEW: Single StyledLabel
// OLD: StyledLabel component
StyledLabel("Title", .heading, emphasis: .inverse)
StyledLabel("Body", .subheading, emphasis: .secondary)
// NEW: Text().styled()
Text("Title").styled(.heading, emphasis: .inverse)
// OLD: IconLabel component
IconLabel("bell.fill", "Notifications", .subheading)
// NEW: Label().styled()
Label("Notifications", systemImage: "bell.fill")
.styled(.subheading)
```
### Mapping Table

View File

@ -26,9 +26,9 @@ import SwiftUI
/// )
///
/// // Then use emphasis levels directly
/// StyledLabel("Primary text", .body) // uses .primary
/// StyledLabel("Secondary text", .caption, emphasis: .secondary)
/// StyledLabel("Custom color", .heading, emphasis: .custom(.red))
/// Text("Primary text").styled(.body) // uses .primary
/// Text("Secondary text").styled(.caption, emphasis: .secondary)
/// Text("Custom color").styled(.heading, emphasis: .custom(.red))
/// ```
public enum TextEmphasis: Sendable {
/// Primary text color (highest emphasis). Default.

View File

@ -12,7 +12,7 @@ import SwiftUI
/// Global theme provider for Bedrock components.
///
/// Register your app's theme once at launch to enable semantic colors
/// in `StyledLabel`, `IconLabel`, and other Bedrock components.
/// in `Text().styled()`, `Label().styled()`, and other Bedrock components.
///
/// ## Usage
///
@ -26,8 +26,8 @@ import SwiftUI
/// )
///
/// // Then use semantic emphasis directly
/// StyledLabel("Title", .heading) // uses Theme.Text.primary
/// StyledLabel("Subtitle", .subheading, emphasis: .secondary) // uses Theme.Text.secondary
/// Text("Title").styled(.heading) // uses Theme.Text.primary
/// Text("Subtitle").styled(.subheading, emphasis: .secondary) // uses Theme.Text.secondary
/// ```
public enum Theme {
// MARK: - Registered Providers

View File

@ -73,7 +73,7 @@ public extension View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.pulsing(isActive: true)
StyledLabel("Pulsing highlights interactive areas", .caption, emphasis: .secondary)
Text("Pulsing highlights interactive areas").styled(.caption, emphasis: .secondary)
}
}
}

View File

@ -75,18 +75,20 @@ public struct LicensesView: View {
private func licenseCard(_ license: License) -> some View {
SettingsCard(backgroundColor: cardBackgroundColor, borderColor: cardBorderColor) {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
StyledLabel(license.name, .bodyEmphasis, emphasis: .inverse)
Text(license.name).styled(.bodyEmphasis, emphasis: .inverse)
StyledLabel(license.description, .caption, emphasis: .secondary)
Text(license.description).styled(.caption, emphasis: .secondary)
HStack {
IconLabel("doc.text", license.licenseType, .caption2, emphasis: .custom(accentColor))
Label(license.licenseType, systemImage: "doc.text")
.styled(.caption2, emphasis: .custom(accentColor))
Spacer()
if let linkURL = URL(string: license.url) {
Link(destination: linkURL) {
IconLabel("arrow.up.right.square", String(localized: "View on GitHub"), .caption2, emphasis: .custom(accentColor))
Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square")
.styled(.caption2, emphasis: .custom(accentColor))
}
}
}

View File

@ -51,7 +51,7 @@ public struct SegmentedPicker<T: Equatable>: View {
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
StyledLabel(title, .subheadingEmphasis, emphasis: .inverse)
Text(title).styled(.subheadingEmphasis, emphasis: .inverse)
HStack(spacing: Design.Spacing.small) {
ForEach(options.indices, id: \.self) { index in
@ -59,7 +59,8 @@ public struct SegmentedPicker<T: Equatable>: View {
Button {
selection = option.1
} label: {
StyledLabel(option.0, .subheadingEmphasis, emphasis: .custom(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong)))
Text(option.0)
.styled(.subheadingEmphasis, emphasis: .custom(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong)))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(

View File

@ -67,9 +67,9 @@ public struct SelectableRow<Badge: View>: View {
Button(action: action) {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
StyledLabel(title, .calloutEmphasis, emphasis: .inverse)
Text(title).styled(.calloutEmphasis, emphasis: .inverse)
StyledLabel(subtitle, .subheading, emphasis: .tertiary)
Text(subtitle).styled(.subheading, emphasis: .tertiary)
}
Spacer()

View File

@ -67,10 +67,10 @@ public struct SettingsNavigationRow<Destination: View>: View {
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
StyledLabel(title, .subheadingEmphasis)
Text(title).styled(.subheadingEmphasis)
if let subtitle {
StyledLabel(subtitle, .caption, emphasis: .secondary)
Text(subtitle).styled(.caption, emphasis: .secondary)
}
}

View File

@ -59,12 +59,12 @@ public struct SettingsRow<Accessory: View>: View {
.background(iconColor.opacity(Design.Opacity.heavy))
.clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall))
StyledLabel(title, .subheading)
Text(title).styled(.subheading)
Spacer()
if let value {
StyledLabel(value, .subheading, emphasis: .secondary)
Text(value).styled(.subheading, emphasis: .secondary)
}
if let accessory {

View File

@ -40,8 +40,7 @@ public struct SettingsSectionHeader: View {
}
Text(title)
.font(Typography.captionEmphasis.font)
.foregroundStyle(Theme.Text.secondary)
.styled(.captionEmphasis, emphasis: .secondary)
.textCase(.uppercase)
.tracking(0.5)

View File

@ -79,7 +79,7 @@ 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) {
StyledLabel(title, .subheadingEmphasis)
Text(title).styled(.subheadingEmphasis)
if let titleAccessory {
titleAccessory
@ -88,7 +88,7 @@ public struct SettingsSegmentedPicker<T: Equatable, Accessory: View>: View {
}
// Subtitle
StyledLabel(subtitle, .caption, emphasis: .secondary)
Text(subtitle).styled(.caption, emphasis: .secondary)
// Segmented buttons
HStack(spacing: Design.Spacing.small) {
@ -97,7 +97,8 @@ public struct SettingsSegmentedPicker<T: Equatable, Accessory: View>: View {
Button {
selection = option.1
} label: {
StyledLabel(option.0, .subheadingEmphasis, emphasis: .custom(selection == option.1 ? Color.white : Theme.Text.primary))
Text(option.0)
.styled(.subheadingEmphasis, emphasis: .custom(selection == option.1 ? Color.white : Theme.Text.primary))
.padding(.vertical, Design.Spacing.small)
.frame(maxWidth: .infinity)
.background(

View File

@ -110,18 +110,17 @@ public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
StyledLabel(title, .subheadingEmphasis)
Text(title).styled(.subheadingEmphasis)
Spacer()
// Exception: Needs .fontDesign(.rounded) modifier
Text(format(value))
.font(Typography.subheadingEmphasis.font)
.styled(.subheadingEmphasis, emphasis: .secondary)
.fontDesign(.rounded)
.foregroundStyle(.secondary)
}
StyledLabel(subtitle, .caption, emphasis: .secondary)
Text(subtitle).styled(.caption, emphasis: .secondary)
HStack(spacing: Design.Spacing.medium) {
if let leadingIcon {

View File

@ -78,7 +78,7 @@ public struct SettingsToggle<Accessory: View>: View {
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) {
StyledLabel(title, .subheadingEmphasis)
Text(title).styled(.subheadingEmphasis)
if let titleAccessory {
titleAccessory
@ -86,7 +86,7 @@ public struct SettingsToggle<Accessory: View>: View {
}
}
StyledLabel(subtitle, .subheading, emphasis: .secondary)
Text(subtitle).styled(.subheading, emphasis: .secondary)
}
}
.tint(accentColor)

View File

@ -117,14 +117,14 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
HStack(spacing: Design.Spacing.small) {
SymbolIcon(syncStatusIcon, size: .inline, color: syncStatusColor)
StyledLabel(syncStatusText, .caption, emphasis: .tertiary)
Text(syncStatusText).styled(.caption, emphasis: .tertiary)
Spacer()
Button {
viewModel.forceSync()
} label: {
StyledLabel(String(localized: "Sync Now"), .captionEmphasis, emphasis: .custom(accentColor))
Text(String(localized: "Sync Now")).styled(.captionEmphasis, emphasis: .custom(accentColor))
}
}
.padding(.top, Design.Spacing.xSmall)

View File

@ -2,100 +2,70 @@
// StyledText.swift
// Bedrock
//
// Semantic text components for consistent typography.
// Semantic text styling extensions for consistent typography.
//
import SwiftUI
// MARK: - Styled Label
// MARK: - Text Extension
/// A text label with semantic typography and color.
///
/// Use `StyledLabel` for all text that follows the typography system.
/// It combines a `Typography` style with a `TextEmphasis` color.
public extension Text {
/// Applies semantic typography and emphasis styling to a Text view.
///
/// ## Example
///
/// ```swift
/// StyledLabel("Morning Ritual", .heading)
/// StyledLabel("Day 6 of 28", .caption, emphasis: .secondary)
/// StyledLabel("Warning", .bodyEmphasis, emphasis: .custom(.red))
/// Text("Hello, \(name)!")
/// .styled(.bodyEmphasis, emphasis: .secondary)
///
/// Text(attributedString)
/// .styled(.caption)
///
/// // With additional modifiers
/// Text("HEADER")
/// .styled(.captionEmphasis, emphasis: .secondary)
/// .textCase(.uppercase)
/// .tracking(0.5)
/// ```
public struct StyledLabel: View {
private let text: String
private let typography: Typography
private let emphasis: TextEmphasis
private let alignment: TextAlignment?
private let lineLimit: Int?
/// Creates a styled label.
///
/// - Parameters:
/// - text: The text to display.
/// - typography: The typography style (default: `.body`).
/// - emphasis: The text emphasis/color (default: `.primary`).
/// - alignment: Optional multiline text alignment.
/// - lineLimit: Optional line limit.
public init(
_ text: String,
_ typography: Typography = .body,
emphasis: TextEmphasis = .primary,
alignment: TextAlignment? = nil,
lineLimit: Int? = nil
) {
self.text = text
self.typography = typography
self.emphasis = emphasis
self.alignment = alignment
self.lineLimit = lineLimit
}
public var body: some View {
Text(text)
.font(typography.font)
.foregroundStyle(emphasis.color)
.multilineTextAlignment(alignment ?? .leading)
.lineLimit(lineLimit)
}
}
// MARK: - Icon Label
/// A label with an icon and text, styled with semantic typography.
///
/// Use `IconLabel` for icon + text combinations like menu items or list rows.
///
/// ## Example
///
/// ```swift
/// IconLabel("bell.fill", "Notifications", .subheading)
/// IconLabel("star.fill", "Favorites", .body, emphasis: .secondary)
/// ```
public struct IconLabel: View {
private let icon: String
private let text: String
private let typography: Typography
private let emphasis: TextEmphasis
/// Creates an icon label.
/// - Parameters:
/// - icon: The SF Symbol name.
/// - text: The text to display.
/// - typography: The typography style (default: `.body`).
/// - emphasis: The text emphasis/color (default: `.primary`).
public init(
_ icon: String,
_ text: String,
/// - Returns: A styled Text view.
func styled(
_ typography: Typography = .body,
emphasis: TextEmphasis = .primary
) {
self.icon = icon
self.text = text
self.typography = typography
self.emphasis = emphasis
) -> some View {
self
.font(typography.font)
.foregroundStyle(emphasis.color)
}
}
public var body: some View {
Label(text, systemImage: icon)
// MARK: - Label Extension
public extension Label where Title == Text, Icon == Image {
/// Applies semantic typography and emphasis styling to a Label view.
///
/// ## Example
///
/// ```swift
/// Label("Notifications", systemImage: "bell.fill")
/// .styled(.subheading, emphasis: .secondary)
///
/// Label("Settings", systemImage: "gear")
/// .styled(.body)
/// ```
///
/// - Parameters:
/// - typography: The typography style (default: `.body`).
/// - emphasis: The text emphasis/color (default: `.primary`).
/// - Returns: A styled Label view.
func styled(
_ typography: Typography = .body,
emphasis: TextEmphasis = .primary
) -> some View {
self
.font(typography.font)
.foregroundStyle(emphasis.color)
}
@ -137,8 +107,7 @@ public struct SectionHeader: View {
}
Text(title)
.font(Typography.caption.font)
.foregroundStyle(Theme.Text.secondary)
.styled(.caption, emphasis: .secondary)
.textCase(.uppercase)
.tracking(0.5)
}
@ -147,41 +116,57 @@ public struct SectionHeader: View {
// MARK: - Previews
#Preview("Styled Labels") {
#Preview("Text.styled()") {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Group {
StyledLabel("Hero Bold", .heroBold)
StyledLabel("Title Bold", .titleBold)
StyledLabel("Title 2", .title2)
StyledLabel("Heading", .heading)
StyledLabel("Heading Emphasis", .headingEmphasis)
Text("Hero Bold").styled(.heroBold)
Text("Title Bold").styled(.titleBold)
Text("Title 2").styled(.title2)
Text("Heading").styled(.heading)
Text("Heading Emphasis").styled(.headingEmphasis)
}
Divider()
Group {
StyledLabel("Body", .body)
StyledLabel("Body Emphasis", .bodyEmphasis)
StyledLabel("Subheading", .subheading, emphasis: .secondary)
StyledLabel("Caption", .caption, emphasis: .secondary)
StyledLabel("Caption 2", .caption2, emphasis: .tertiary)
Text("Body").styled(.body)
Text("Body Emphasis").styled(.bodyEmphasis)
Text("Subheading").styled(.subheading, emphasis: .secondary)
Text("Caption").styled(.caption, emphasis: .secondary)
Text("Caption 2").styled(.caption2, emphasis: .tertiary)
}
Divider()
Group {
StyledLabel("Custom Color", .body, emphasis: .custom(.orange))
StyledLabel("Disabled", .body, emphasis: .disabled)
Text("Custom Color").styled(.body, emphasis: .custom(.orange))
Text("Disabled").styled(.body, emphasis: .disabled)
}
Divider()
Group {
Text("Count: \(42)").styled(.heading)
Text("UPPERCASE")
.styled(.captionEmphasis, emphasis: .secondary)
.textCase(.uppercase)
.tracking(0.5)
Text("$9.99")
.styled(.subheadingEmphasis)
.fontDesign(.rounded)
}
}
.padding()
}
#Preview("Icon Labels") {
#Preview("Label.styled()") {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
IconLabel("bell.fill", "Notifications", .subheading)
IconLabel("star.fill", "Favorites", .body, emphasis: .secondary)
IconLabel("gear", "Settings", .caption)
Label("Notifications", systemImage: "bell.fill")
.styled(.subheading)
Label("Favorites", systemImage: "star.fill")
.styled(.body, emphasis: .secondary)
Label("Settings", systemImage: "gear")
.styled(.caption)
}
.padding()
}