Skip to main content

iOS Design System

Version: 1.5.0 Last Reviewed: 2026-02-18 Status: Approved

Purpose

Canonical source of truth for iOS visual decisions. Prevents implementation drift across features built by AI agents. When in doubt, ask before guessing.

Design Philosophy

  • Premium and polished: Indigo/purple primary color, calm and distinctive
  • Native iOS feel: Use Apple's patterns (.borderedProminent, .bordered, .borderless), not custom frameworks
  • Optimized for 3 AM: Dark mode is primary, light mode is secondary
  • Speed of entry: Large tap targets, minimal navigation, quick-dismiss sheets
  • Dynamic Type support: Mandatory, never hardcode font sizes
  • Themeable architecture: All colors via design tokens (Color Sets in asset catalog). Easy to swap palette later. No hardcoded colors in views — always use bbPrimary, bbSurface, etc.

Icons

One canonical symbol per event type. Used everywhere: dashboard cards, timeline entries, tab bar, sheets.

TypeSF SymbolUsage
Feedingbb.baby.bottleAll feeding contexts (Tabler)
Diaperbb.diaperAll diaper contexts (Tabler)
Sleepmoon.zzz.fillAll sleep contexts
Notenote.textAll note contexts
SettingsgearSettings tab
Add/PlusplusToolbar add buttons
Backchevron.leftNavigation back (SwiftUI default)
ClosexmarkSheet close buttons
Undo(text only)Undo snackbar uses text, not icon

BBTypeIcon component (Components/Timeline/BBTypeIcon.swift): Maps event type to icon + color. Uses custom asset catalog images for feeding and diaper, SF Symbols for sleep and note. Single source of truth. All views use this component. Reads ThemeManager from the environment to apply the active IconStyle (.filled or .tinted) and derive the icon color from CategoryColorStrategy. Requires ThemeManager to be in the environment.

Custom Icons (Tabler)

When no suitable SF Symbol exists for an event type, we source icons from Tabler Icons — a free, MIT-licensed icon set with 5,800+ consistent stroke-based icons.

When to use custom icons vs SF Symbols:

  • Prefer SF Symbols for standard UI actions (close, back, add, settings) and common concepts (checkmark, warning, gear)
  • Use Tabler icons only for domain-specific concepts where SF Symbols lack a clear match (baby bottle, diaper)

Process for adding a new custom icon:

  1. Search using the Iconify MCP server: ask Claude to search for icons matching your concept (e.g., "search for baby bottle icons in the Tabler set"). The MCP returns SVG data directly.
  2. Alternative search: Browse tabler.io/icons or use the Iconify API directly: curl -s "https://api.iconify.design/search?query=baby+bottle&prefix=tabler&limit=20"
  3. Download the SVG — use the filled variant for consistency with our existing icons. The Iconify MCP can retrieve the SVG directly, or download from tabler.io.
  4. Convert to Custom SF Symbol (optional, recommended): Run swiftdraw <input.svg> --format sfsymbol --insets auto to convert the SVG into a proper Custom SF Symbol format. This enables weight/scale variants and better Xcode integration. Install via brew install swiftdraw.
  5. Name the file bb.<name>.svg (e.g., bb.baby.bottle.svg, bb.diaper.svg)
  6. Add to asset catalog: Create a new Image Set in Resources/Assets.xcassets/ with the SVG file. Set "Render As" to Template Image (allows tinting with semantic colors)
  7. Reference in code: Image("bb.<name>").renderingMode(.template) — the .renderingMode(.template) is required even when the asset catalog says Template, as a defensive measure
  8. Register in BBTypeIcon: Add the mapping in Components/Timeline/BBTypeIcon.swift
  9. Update this spec: Add the new icon to the Icons table above

Current custom icons:

Asset NameSourceUsed For
bb.baby.bottleTabler baby-bottle (filled)Feeding events
bb.diaperTabler diaper (filled)Diaper events

Colors

Semantic Palette

Defined as adaptive Color Sets in the asset catalog. Light and dark variants for each.

TokenLightDarkUsage
bbPrimarySoft indigoMuted indigoPrimary buttons, active states, links, .borderedProminent tint
bbSecondaryLight lavenderMuted lavender.bordered button tint, card borders
bbBackgroundOff-whiteNear-black (not pure)Main screen background
bbSurfaceLight grayDark grayCard backgrounds, sheet backgrounds
bbTextNear-blackOff-whitePrimary text
bbTextSecondaryMedium grayLight grayTimestamps, secondary labels, captions
bbSuccessGreenMuted greenPositive feedback (used sparingly)
bbWarningAmberMuted amberWarning toasts, retry indicators
bbDangerRedMuted redError toasts, destructive actions (also used by Button(role: .destructive))

Exact hex values: Determined during Theme.swift implementation. The implementing agent should propose a palette based on the indigo/purple family and ask for approval before committing. Start with Apple's .indigo as a reference point and adjust for the premium, calming feel.

Dark mode priority: This app is used at 3 AM. Dark mode colors tested first. Avoid pure white (#FFFFFF) and pure black (#000000). Use off-white text on near-black backgrounds. Muted, desaturated indigo for dark mode primary. Critical: bbSurface (dark) must be noticeably lighter than bbBackground (dark) — this is how cards achieve elevation in dark mode (not via shadows). Target ~10-15% white overlay difference (e.g., background #1C1C1E, surface #2C2C2E).

Contrast requirements (WCAG AA):

  • Normal text on background: minimum 4.5:1 contrast ratio
  • Large text (.title2 and above) on background: minimum 3:1
  • UI components and icons: minimum 3:1 against adjacent colors
  • All color pairings must be verified in both light and dark mode
  • Test at 20% screen brightness (realistic 3 AM scenario)
  • Implementing agent must verify contrast ratios and include them in the PR description
  • Physical device test at 20% brightness in a dark room is mandatory before shipping. Simulator is insufficient.
  • Fallback if indigo fails contrast: Increase luminance of dark mode indigo (lighter, more saturated), or add subtle light border to .borderedProminent buttons, or pivot to higher-luminance primary (teal, soft blue). Ask before pivoting.

Theming architecture: All color references go through these tokens. Never use Color.blue or hardcoded values in views. This ensures we can swap the entire palette later by updating Color Sets in the asset catalog without touching any view code.

Theming System

The app supports runtime palette and icon style switching via a layered theming architecture. All theme types live in Core/Theme/.

ThemeManager (Core/Theme/ThemeManager.swift)

@Observable @MainActor final class ThemeManager is the single source of truth for visual theming. It is instantiated once in BabyBasicsApp and injected at the root:

// BabyBasicsApp.swift
@State private var themeManager = ThemeManager()

WindowGroup {
ContentView()
.environment(appState)
.environment(themeManager)
}

Properties:

  • currentPalette: ThemePalette — active color palette (global default)
  • categoryColorStrategy: CategoryColorStrategy — active event-type color mapping (global default)
  • iconStyle: IconStyle — active icon rendering mode (global default)
  • childOverrides: [UUID: ThemeOverride] — per-child overrides (in-memory only; persistence deferred to post-MVP)

Resolution methods (prefer these over direct property access when child context is available):

  • resolvedPalette(for childId: UUID?) -> ThemePalette
  • resolvedCategoryStrategy(for childId: UUID?) -> CategoryColorStrategy
  • resolvedIconStyle(for childId: UUID?) -> IconStyle

Each falls back to the global setting when no per-child override exists.

Persistence: Global settings are persisted to UserDefaults.standard automatically via didSet observers. Keys: theme.palette, theme.categoryStrategy, theme.iconStyle. Values are the string id of each preset (stable across app versions).

Sheet inheritance caveat: SwiftUI sheets do NOT inherit the environment of the presenting view. Any sheet that needs ThemeManager must receive an explicit .environment(themeManager) modifier. Failure to do this results in @Environment(ThemeManager.self) crashing at runtime inside the sheet.

Accessing ThemeManager in views:

@Environment(ThemeManager.self) private var theme
// Then: theme.currentPalette.primary, theme.iconStyle, etc.

Bindable pattern for @Observable in Settings:

// SettingsView.swift — @Observable requires @Bindable for two-way bindings
var body: some View {
@Bindable var theme = theme // local rebind inside body
Picker("Theme", selection: $theme.currentPalette) { ... }
}

ThemePalette (Core/Theme/ThemePalette.swift)

struct ThemePalette: Equatable, Hashable, Identifiable — defines a complete set of semantic colors for a visual theme. Equality and hashing are by id only.

11 semantic colors (each an adaptive Color that switches automatically between light and dark mode):

PropertyUsage
primaryPrimary buttons, active states, links, avatar background
secondaryBordered button tint, card border accents
backgroundMain screen background
surfaceCard backgrounds, sheet backgrounds
textPrimary text
textSecondaryTimestamps, secondary labels, captions
successPositive feedback banners
warningWarning toasts, retry indicators
dangerError toasts, destructive actions
sleepIconSleep icon color (WCAG 3:1 on background, separate from primary)
diaperIconDiaper icon color (WCAG 3:1 on background)

Note: sleepIcon and diaperIcon exist because the category color strategy governs icon colors used in BBTypeIcon, while ThemePalette governs UI chrome. These two palette fields are used in legacy contexts only — prefer CategoryColorStrategy for event-type icon colors going forward.

5 preset palettes (ThemePalette.allPresets):

IDNamePrimary (light / dark)Character
warm-nightlightWarm Nightlight (default)#A07830 / #E8B86DAmber/warm, calming at 3 AM
current-indigoIndigo#5856D6 / #7B79E0Original indigo — matches legacy Color.bb* tokens
ocean-calmOcean Calm#2E8B8B / #4DB8B8Cool blue-green
sage-gardenSage Garden#6B8F71 / #8FB896Muted sage green
midnight-harborMidnight Harbor#3D5A80 / #5B82ABDeep navy

Implementation details:

  • Colors are constructed via adaptiveColor(light:dark:) (package-internal helper in Core/Theme/Theme.swift) and hex(_ hex: UInt) (UInt literal to Color)
  • adaptiveColor uses UIColor { traitCollection in ... } bridge (iOS 17+)
  • All palettes are static let constants — no dynamic allocation at runtime

CategoryColorStrategy (Core/Theme/CategoryColorStrategy.swift)

struct CategoryColorStrategy: Equatable, Hashable, Identifiable — maps each EventType to a pair of adaptive colors (light + dark) for use in event-type icons and timeline entries.

Types:

  • CategoryColorMappingstruct { light: Color, dark: Color } with a resolved(colorScheme:) -> Color method
  • CategoryColorStrategy.color(for eventType: EventType, colorScheme: ColorScheme) -> Color — primary call site in BBTypeIcon

Color tone conventions:

  • Feed: green family tones
  • Diaper: warm/brown/clay tones
  • Sleep: blue/purple tones
  • Note: neutral gray tones

5 preset strategies (CategoryColorStrategy.allPresets):

IDNameCharacter
desaturated-jewelDesaturated Jewel (default)Feed=sage, Diaper=clay, Sleep=dusty blue, Note=warm gray
warm-cool-splitWarm Cool SplitFeed=teal, Diaper=coral, Sleep=lavender, Note=neutral
apple-healthApple HealthInspired by Apple Health color language
muted-earthMuted EarthAll earth tones
pastel-harmonyPastel HarmonySoft pastels

IconStyle (Core/Theme/IconStyle.swift)

enum IconStyle: String, CaseIterable, Identifiable — controls the visual rendering mode of BBTypeIcon.

CaseDescriptionVisual
.filled (default)Solid colored square background, white icon on topRounded rectangle badge
.tintedColored icon only, no backgroundFlat icon in category color

BBTypeIcon reads theme.iconStyle from the environment and renders accordingly. The 32×32 pt frame and 8 pt corner radius (filled mode) are fixed across both styles.

ThemeOverride (Core/Theme/ThemeManager.swift)

struct ThemeOverride { var palette, categoryColorStrategy, iconStyle } — all fields are optional. Used to override any subset of global theme settings per child. Currently in-memory only; full per-child persistence is post-MVP.

Typography

Use Apple's semantic text styles exclusively. Never hardcode point sizes.

ContextText StyleWeightExample
Screen titles.title2.bold"Baby Basics", "Today"
Card labels.headlinedefault"Feed", "Diaper", "Sleep"
Elapsed time on cards.title3.semibold"2h 15m ago"
Side indicator.caption.bold"L", "R", "L+R"
Today's summary.bodydefault"6 feeds", "4 pee"
Timeline time.captiondefault"2:30 PM"
Timeline detail.bodydefault"Left breast - 15 min"
Sheet titles.headline.bold"Log Feeding"
Button labels.body.semibold"Left", "Right", "Both"
Snackbar/toast.calloutdefault"Logged left breast"
Empty state title.headlinedefault"No events yet today"
Empty state subtext.subheadlinedefault"Tap a card..."
Form field labels.subheadline.medium"When", "Amount", "Duration"

Form Field Labels

All form fields in sheets use a consistent label style:

  • Font: .subheadline.weight(.medium)
  • Color: Color.bbTextSecondary
  • Positioned above the field control in a VStack(alignment: .leading)
  • Examples: "When" (above BBTimestampChip DatePicker), "Amount" (above BBNumberField), "Started on" (above BBTogglePicker)
  • Do NOT use .caption or .secondary — too small for legibility at 3 AM / 20% brightness

BBTimestampChip Label

The BBTimestampChip component includes a "When" label above the DatePicker row:

  • Layout: VStack with "When" label + DatePicker row (HStack)
  • Label uses the standard form field label style (.subheadline.weight(.medium) + Color.bbTextSecondary)
  • Matches the visual pattern of all other labeled form fields

Spacing

enum Spacing {
static let xs: CGFloat = 4 // Between icon and label
static let s: CGFloat = 8 // Between related elements
static let m: CGFloat = 16 // Standard padding, between cards
static let l: CGFloat = 24 // Section spacing
static let xl: CGFloat = 32 // Top-level section gaps
}

Layout

Touch Targets

  • Minimum: 44x44 pt (Apple HIG)
  • Quick action buttons (BBQuickActionButton): 60 pt height, full-width
  • Dashboard cards: Fill available width in 2-column grid, minimum 44 pt height
  • Spacing between tappable elements: minimum Spacing.s (8 pt)

Corner Radius

  • Cards: 12 pt
  • Buttons: 10 pt
  • Sheets: System default (SwiftUI handles this)
  • Toasts/Snackbars: 8 pt

Cards (BBDashboardCard)

  • Background: bbSurface
  • Corner radius: 12 pt
  • Light mode elevation: .shadow(color: .black.opacity(0.08), radius: 4, x: 0, y: 2)
  • Dark mode elevation: No shadow (dark shadows on dark backgrounds are invisible). Instead, elevation is achieved by bbSurface being noticeably lighter than bbBackground + a subtle border: .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
  • Padding: Spacing.m internal
  • Theming note: All elevation logic lives in a ViewModifier (BBCardStyle) that switches on colorScheme. Individual views just apply .modifier(BBCardStyle()) and never think about shadows vs borders.

Sheet Detents

SheetDetentRationale
Diaper (3 buttons).mediumSimple, fits in half
Sleep (start/wake).mediumSimple, fits in half
Feeding (select + save).height(500), .largeCustom compact detent, "Advanced" expands
Note (category + text).medium, .largeStarts half, text area may need space
Edit any event.medium, .largePre-populated form, may need space

All sheets: .presentationDragIndicator(.visible), dismiss via swipe or close button.

Clear when Hidden (Detent Transition Behavior)

Principle: Only visible fields contribute to submission. When a user collapses a sheet from an expanded detent (.large) back to a compact detent, all fields that become hidden reset to their compact-mode defaults. This ensures the compact view is always a clean starting state, not polluted by partially-filled advanced fields.

Rationale: Follows established patterns from Apple Reminders (collapsing hides and resets advanced fields), React Hook Form (shouldUnregister), and NNG cognitive load research. Hidden fields create invisible state that confuses sleep-deprived users — if they can't see it, it shouldn't affect the result.

Applies to all sheets with expandable detents:

  • Feeding sheet: Collapsing from .large to compact resets feedingType to .breast and clears durationMinutes/amountMl. Compact mode is always the clean "quick breast feeding" path.
  • Note sheet: Collapsing clears any expanded fields beyond the compact defaults.
  • Edit sheets: Same principle — collapsing resets to the compact subset of editable fields.
  • Sleep sheet ("Log Sleep" mode): Collapsing clears manual entry fields, returning to the simple "Start Sleep" quick-action.

Implementation: The ViewModel's reset handler fires when the detent transitions from .large to compact. This is a one-way reset — expanding does NOT restore previously cleared values.

Sheet Layout Pattern

All logging/editing sheets follow this structure:

  1. NavigationStack wrapper: Required for .toolbar(placement: .keyboard) Done button to work. Use .toolbar(.hidden, for: .navigationBar) to hide the nav bar.
  2. Custom detent: Define readable named detents via private extension PresentationDetent { static let compact = .height(500) }.
  3. Keyboard toolbar: Done button via .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { dismissKeyboard() } } }. Essential for decimal pad which has no return key.
  4. Background tap dismiss: .contentShape(Rectangle()).onTapGesture { dismissKeyboard() } on the main content VStack. Works alongside the toolbar Done button.
  5. Content: Form fields using consistent label pattern (see Form Field Labels above).
  6. Save button: Bottom of sheet, full-width BBPrimaryButton.
NavigationStack {
VStack(spacing: Spacing.m) {
// Sheet title
// Form fields with labels
// "Advanced" text link (if applicable)
// Save button (bottom)
}
.contentShape(Rectangle())
.onTapGesture { dismissKeyboard() }
.navigationBarHidden(true)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { dismissKeyboard() }
}
}
}
.presentationDetents([.compact])
.presentationDragIndicator(.visible)

Buttons

Three-tier hierarchy using SwiftUI's built-in button styles. One primary action per context.

Tier 1: BBPrimaryButton (.borderedProminent)

  • Single main CTA per screen/sheet: "Save", "Wake Up", "Create"
  • .buttonStyle(.borderedProminent), .tint(.bbPrimary), .controlSize(.large)
  • Full width, Spacing.m horizontal padding, height: 50 pt
  • Corner radius: 10 pt
  • Loading state: Label replaced with ProgressView(), button disabled, opacity 0.7. Retain accessible label: .accessibilityLabel("Save, loading") so VoiceOver announces context.
  • Disabled state: opacity 0.5, not tappable

Tier 2: BBSecondaryButton (.bordered)

  • Alternative valid actions: "More Details", non-suggested options
  • .buttonStyle(.bordered), .tint(.bbPrimary), .controlSize(.large)
  • Full width or inline, Spacing.m horizontal padding, height: 44 pt
  • Subtle tinted background (bbPrimary at ~15% opacity), bbPrimary foreground text
  • Disabled state: opacity 0.5

Tier 3: Tertiary (.borderless)

  • Low-emphasis: "Cancel", "Skip", "Delete"
  • .buttonStyle(.borderless), .font(.body)
  • No background, no border, bbPrimary tinted text
  • .frame(minHeight: 44) — ensures 44pt minimum touch target even without visible background
  • Destructive actions: use Button("Delete", role: .destructive) — auto-renders in bbDanger

BBQuickActionButton (special: quick-log buttons)

  • Rapid-fire action targets optimized for 3 AM: "Left", "Right", "Pee", "Poop"
  • Height: 60 pt, fills column width
  • .controlSize(.large), bold label, icon optional
  • On tap: immediate sheet dismiss + undo snackbar (no loading state)
  • Two visual variants based on suggestion state (see Suggested Option Pattern below):
    • Suggested: .borderedProminent + .tint(.bbPrimary) — filled background
    • Normal: .bordered + .tint(.bbPrimary) — subtle tinted background
    • No suggestion exists: All buttons render as .bordered (equal weight)

BBTogglePicker (segmented control)

Reusable two-option segmented control with sliding indicator. Used for feeding side selection ("Started on: Left | Right") and breast count ("Breasts: One | Both").

Implementation: GeometryReader + ZStack + onTapGesture. Do NOT use matchedGeometryEffect + @Namespace + Button — that causes appear/disappear transitions instead of smooth sliding.

  • Height: 50 pt
  • Full-area tap targets (each half is tappable, not just the label)
  • Sliding indicator animation: .easeInOut(duration: 0.15)
  • Background: bbSurface (or system grouped background)
  • Selected indicator: bbPrimary tinted capsule/rounded rect
  • Labels: .body.weight(.medium), contrasting color on selected vs unselected
  • Corner radius: matches system segmented control appearance
  • Post-MVP: Respect @Environment(\.accessibilityReduceMotion) — instant switch when enabled

Accessibility: Each option has .accessibilityAddTraits(.isButton). Selected option has .accessibilityAddTraits(.isSelected). VoiceOver announces "Left, selected" / "Right" pattern.

Destructive Confirmation Pattern

For destructive actions (delete feeding, revoke access), follow Apple's HIG:

  • Use .confirmationDialog or .alert
  • "Cancel" is the bold/prominent button (protects sleep-deprived users)
  • "Delete" uses role: .destructive (renders in red but non-prominent)

Suggested Option Pattern

Generic pattern for indicating a "recommended" option among equals. Used for feeding side suggestion, extensible to future suggestions.

Visual Treatment

  • Suggested button: .borderedProminent (filled background) + "Suggested" text label above
  • Non-suggested buttons: .bordered (subtle tinted background), no label
  • All buttons same size — differentiation via fill style + text label only
  • No suggestion available (nil): All buttons render as .bordered, no labels

"Suggested" Label

  • Positioned directly above the suggested button
  • .caption.weight(.semibold), bbPrimary color, .opacity(0.9).caption (12pt min) chosen over .caption2 (11pt min) for legibility at 3 AM / 20% brightness
  • Non-suggested buttons have an invisible placeholder to maintain vertical alignment
  • accessibilityHidden(true) on the label (information conveyed via button's accessibility label instead)

Accessibility

  • Suggested button: .accessibilityLabel("Left, suggested")
  • Suggested button: .accessibilityHint("Based on your last feeding")
  • Non-suggested button: standard label only (e.g., "Right")
  • Differentiation does NOT rely on color alone — text label provides redundant signal

Feeding Sheet Specifics (Select + Save Pattern)

The feeding sheet uses a select + save pattern with BBTogglePicker controls instead of quick-action buttons. Users select options via toggle pickers and confirm with Save.

  • Suggested side: From dashboard API suggested_side field. Pre-selects the side BBTogglePicker to the suggested side (opposite of last breast feeding's started_side). No explicit "Suggested" label — the default selection IS the suggestion.
  • Both sides: BBTogglePicker toggles between "One" and "Both". When "Both" is selected, hint text fades in: "Started on [Side], then switched" with .animation(.easeInOut(duration: 0.2))
  • No suggestion available (suggested_side=null): Defaults to Left
  • Compact mode (default .height(500) detent): Timestamp, side picker, one/both picker, Save
  • Advanced mode (.large detent): Adds type picker (Breast/Bottle) and duration field. Triggered by "Advanced" text link.

Sheet Layout Order (example: feeding, compact mode)

"Log Feeding"                                  [X close]
─────────────────────────────────────────────
When: [DatePicker] [Now]

Started on: ┌──────────┬──────────┐
│ Left │ Right │ ← BBTogglePicker
└──────────┴──────────┘
Breasts: ┌──────────┬──────────┐
│ One │ Both │ ← BBTogglePicker
└──────────┴──────────┘
"Started on Left, then switched"

Advanced ← text link, expands to .large

┌───────────────────────┐
│ Save │ ← BBPrimaryButton
└───────────────────────┘

Animations

Reduced Motion

All animations must respect @Environment(\.accessibilityReduceMotion). When enabled:

  • Replace repeating animations with static visual states
  • Keep sheet/toast transitions (SwiftUI handles these automatically)

Active Sleep Pulsing

The active sleep indicator uses a gentle opacity-only glow. No scale effect — the icon stays at its natural size to avoid drawing excessive attention.

Image(systemName: "moon.zzz.fill")
.foregroundStyle(theme.currentPalette.primary)
.opacity(isPulsing ? 0.9 : 0.5)
.animation(
reduceMotion
? nil
: .easeInOut(duration: 3.0).repeatForever(autoreverses: true),
value: isPulsing
)
.onAppear { isPulsing = true }
  • Opacity range: 0.5 → 0.9 (not 0→1, never fully transparent)
  • Duration: 3.0 seconds (slow, calming — not urgent)
  • No scale effect (gentle glow, not a bounce)
  • Color: theme.currentPalette.primary (not category color — this is chrome, not an event-type icon)
  • Reduced motion: nil animation (static at 0.9 opacity). Icon shape alone conveys "active sleep."

BBFeedbackBanner (unified component)

Replaces the separate "snackbar" and "toast" concepts. One component, three configurations:

ConfigIconExampleStyleDurationAction
Successcheckmark.circle.fill"Logged left breast"bbSurface background10 seconds"Edit" button (opens edit sheet for the entry)
Warningexclamationmark.triangle.fill"Couldn't save · Will retry"bbWarning tinted4 secondsNone
Errorxmark.octagon.fill"Failed to save feeding · Tap to retry"bbDanger tintedPersistentTap re-enqueues request (see loading state below)

Icons are mandatory — they provide non-color redundancy for color-blind users (WCAG). Icon shape conveys severity independent of color: circle (positive) / triangle (caution) / octagon (critical).

Shared behavior:

  • Slides up from bottom with .transition(.move(edge: .bottom))
  • animation(.spring(response: 0.3, dampingFraction: 0.8))
  • Positioned above tab bar, respects safe area
  • Full width minus Spacing.m horizontal padding, corner radius 8 pt
  • MVP: Only one banner visible at a time (new replaces old). Acceptable trade-off: an error banner can replace an active undo banner, losing the undo opportunity. User can still delete from timeline.
  • All banners dismissable by swiping down
  • "Undo" action button: minimum 44x44 tap target
  • Background/foreground: Undo and warning timers expire naturally during background (banner gone on return). Persistent error banners re-display on foreground if still unresolved. Retry queue continues attempting within iOS background execution limits.

Error banner retry loading state: When the user taps a persistent error banner to retry:

  1. Icon swaps from xmark.octagon.fill to ProgressView() (system spinner)
  2. Text changes to "Retrying..."
  3. Banner tap is disabled (prevents duplicate requests)
  4. Banner height stays the same (no layout shift)
  5. On success: banner auto-dismisses with brief "Saved" + checkmark.circle.fill (2 seconds)
  6. On failure: banner returns to error state with "Still couldn't save · Tap to retry"

Post-MVP: Priority Queue + Notification Hub:

  • Replace "new replaces old" with priority-based queue: Error (persistent) > Warning (retry) > Undo (timed)
  • Higher-priority banners interrupt lower (push to queue, timer keeps ticking)
  • When active banner resolves, next queued banner resurfaces
  • Badge indicator on banner when queue has additional items (e.g., dot or "1 more" chip)
  • Eventually feeds into a dedicated Notification Hub (nav bar icon with badge count) that aggregates: sync successes/failures, retries in progress, caregiver activity, snoozed errors. The banner system becomes the real-time surface; the hub becomes the persistent log.

Sheet

  • Use SwiftUI default sheet presentation (no custom transitions)

Loading Skeleton

  • Shimmer effect: linear gradient sweep left-to-right, 1.5s duration, repeating
  • Base color: bbSurface, shimmer highlight: bbBackground
  • Show skeleton shapes matching the real content layout

Materials and Vibrancy (Optional Enhancement)

iOS adaptive materials (.ultraThinMaterial, .thinMaterial, etc.) can add premium depth and automatically respect light/dark/Reduce Transparency settings. Available for use but not required for MVP — solid semantic colors are the primary system.

Where materials may enhance the design (implementing agent can test and propose):

  • Sheet backgrounds: .presentationBackground(.ultraThinMaterial) instead of solid bbSurface
  • BBFeedbackBanner: .background(.thinMaterial) with tinted overlay for warning/error states

When to ASK before adopting: If materials feel distracting, reduce clarity at low brightness, or hurt performance on older devices, stick with solid semantic colors. Clarity at 3 AM > visual polish.

States

Loading

  • Dashboard first load: 4 skeleton cards in 2x2 grid + skeleton summary rows
  • Timeline first load: 3-4 skeleton timeline entries
  • Button loading: ProgressView() replaces label, button disabled
  • Pull to refresh: SwiftUI .refreshable default spinner

Error (GET failed, no data)

  • Centered vertically
  • SF Symbol: exclamationmark.triangle (48 pt, bbTextSecondary)
  • Title: .headline, e.g., "Couldn't load dashboard"
  • Subtitle: .subheadline, e.g., "Tap to retry"
  • Retry button: BBPrimaryButton (full width)

Stale Data (refresh failed, has data)

  • Subtle banner at top of content: bbWarning background, bbText foreground
  • Text: "Offline - Showing last known data"
  • Height: auto, padding Spacing.s
  • Does NOT block interaction with existing data

Form Validation Errors

  • When: On submit only (tap Save/Create). No live/as-you-type validation for MVP.
  • Inline error text: .caption, bbDanger color, positioned directly below the invalid field
  • Field highlight: Invalid field gets .overlay(RoundedRectangle(...).stroke(.bbDanger)) border
  • Submit button: Stays enabled (don't disable based on client-side validation — server is source of truth)
  • On error: Scroll to first invalid field if not visible, keep sheet open
  • Server errors (non-validation 5xx): Show BBFeedbackBanner (error config), not inline errors

Empty State

  • Centered vertically
  • No illustration (MVP)
  • Title: .headline, bbText
  • Subtitle: .subheadline, bbTextSecondary

Agent Guidelines

When to DECIDE (obvious, follow the spec)

  • Which SF Symbol to use (see table above)
  • Which spacing value to use (use the scale)
  • Which text style to use (see typography table)
  • Sheet detent size (see table above)
  • Button type for an action (primary vs secondary vs quick action)

When to ASK (subjective, multiple valid answers)

  • Proposing exact hex color values for the palette
  • Layout changes not covered by this spec (new component types)
  • Animation for something not listed above
  • Any accessibility concern that might affect UX
  • Adding a new semantic color beyond the defined palette
  • Deviating from Apple HIG patterns

When to SKIP (don't over-engineer)

  • Custom transitions beyond what's specified
  • Custom fonts or font weights beyond Apple semantic styles
  • iPad layout optimization
  • Custom tab bar styling (use SwiftUI default)

Haptics

Haptic feedback is implemented and shipped (previously marked "nice-to-have, not MVP" — now promoted). All haptic calls go through BBHaptic (Core/Haptics/BBHaptic.swift).

BBHaptic (Core/Haptics/BBHaptic.swift)

@MainActor enum BBHaptic — namespace for haptic feedback helpers. All methods call prepare() before triggering to reduce latency on first use.

MethodUIKit GeneratorWhen to use
BBHaptic.selection()UISelectionFeedbackGeneratorToggle picker changes, segmented control taps, sheet open
BBHaptic.success()UINotificationFeedbackGenerator(.success)Successful save (feeding, diaper, sleep, note logged)
BBHaptic.error()UINotificationFeedbackGenerator(.error)Permanent save failure (5 retries exhausted)
BBHaptic.impact(_ style:)UIImpactFeedbackGenerator(style:)Sleep start/stop button, cancel confirm (.medium default)

Placement in practice:

  • Save success: BBHaptic.success() fires in SheetPresenter on onFeedingCreated, onDiaperCreated, onSleepAction, onNoteCreated callbacks — before showing the success banner.
  • Sheet open: BBHaptic.selection() on quick-action card tap (to open sheet).
  • Toggle change: BBHaptic.selection() on BBTogglePicker selection change.
  • Sleep start/stop: BBHaptic.impact(.medium) on "Start Sleep" / "Wake Up" tap.
  • Cancel confirm: BBHaptic.impact(.medium) on destructive confirmation.

Haptics are not conditional on reduced motion — iOS itself suppresses haptics when "Reduce Motion" is combined with device haptic settings. No app-level guard is needed.

Tab Layout

2-tab TabView at app root:

  • Tab 1: DashboardDashboardView, system symbol house.fill
  • Tab 2: TodayTimelineView, system symbol list.bullet

Settings is NOT a tab. It is accessed via ProfileAvatarButton in the Dashboard navigation bar, presented as a NavigationLink (push navigation within the Dashboard NavigationStack).

ProfileAvatarButton (Components/Navigation/ProfileAvatarButton.swift)

struct ProfileAvatarButton: View — top-right toolbar item on Dashboard. Combines child switching and Settings access into a single compact control.

Appearance:

  • 32×32 pt circle
  • Background: theme.currentPalette.primary
  • Content: child's first initial (uppercase), .caption.weight(.bold)
  • Foreground color: .white in dark mode, theme.currentPalette.background in light mode (adaptive contrast)
  • Fallback label: "?" when no active child is selected

Behavior:

  • Tapping presents a .menu
  • Menu shows each child by name; active child has a checkmark trailing icon
  • Menu has a Divider() between the child list and Settings
  • Settings entry uses Label("Settings", systemImage: "gearshape")

Environment requirements: Needs ThemeManager in the environment (reads theme.currentPalette.primary and theme.currentPalette.background).

Accessibility: .accessibilityLabel("Child profile and settings")

Sheet Infrastructure

SheetPresenter (Components/Sheets/SheetPresenter.swift)

SheetPresenter is a ViewModifier that encapsulates all four logging sheet modifiers (feeding, diaper, sleep, note) into one reusable unit. Both DashboardView and TimelineView share identical sheet logic — this modifier eliminates that duplication.

Types:

struct SheetVisibility {
let feeding: Binding<Bool>
let diaper: Binding<Bool>
let sleep: Binding<Bool>
let note: Binding<Bool>
}

struct SheetPresenterConfig {
let childId: UUID?
let suggestedSide: FeedingSide?
let activeSleep: ActiveSleep?

static func dashboard(childId:suggestedSide:activeSleep:) -> Self
static func timeline(childId:) -> Self // suggestedSide=nil, activeSleep=nil
}

Usage:

.sheetPresenter(
visibility: SheetVisibility(
feeding: $showFeedingSheet,
diaper: $showDiaperSheet,
sleep: $showSleepSheet,
note: $showNoteSheet
),
config: .dashboard(
childId: activeChildId,
suggestedSide: viewModel.suggestedSide,
activeSleep: viewModel.activeSleep
),
bannerState: bannerState,
onRefresh: { await viewModel.refresh(childId: activeChildId) }
)

Behavior on successful save (same for all 4 sheets):

  1. BBHaptic.success() fires immediately
  2. bannerState.show(.success(message:onEdit:)) with spring animation
  3. Task { await onRefresh() } to reload data

BBBannerState is an @Observable class passed by reference — not a Binding. onRefresh is async and called in a detached Task.

Unified Sheet Detent

All logging sheets use a custom compact detent of 480 pt (named PresentationDetent.compact):

private extension PresentationDetent {
static let compact = PresentationDetent.height(480)
}

The feeding sheet additionally supports .large for advanced mode. Other sheets use [.compact] only (note sheet uses [.compact, .large] for text expansion).

Cards

BBDashboardCard (Components/Cards/BBDashboardCard.swift)

struct BBDashboardCard<SecondaryContent: View>: View — the 2×2 grid card component.

Card height: Uses @ScaledMetric private var cardHeight: CGFloat = 120 — the card grows proportionally with the user's Dynamic Type size setting. Never hardcode a fixed height.

Press feedback: BBCardButtonStyle applies scaleEffect(configuration.isPressed ? 0.97 : 1.0) with .easeInOut(duration: 0.15) — a subtle press-down feedback on tap.

Tappability: .contentShape(Rectangle()) on the inner VStack ensures the entire card area (including padding) is tappable, not just the text labels.

Theme integration: Reads theme.currentPalette.text and theme.currentPalette.textSecondary directly. BBTypeIcon inside the card reads theme independently. Requires ThemeManager in the environment.

SecondaryContent slot: Generic @ViewBuilder parameter for card-specific secondary content (e.g., side indicator for feeding, elapsed timer for active sleep). Use BBDashboardCard(eventType:label:elapsedText:action:) (no trailing closure) for cards with no secondary content.

Known Issues

SwiftUI Sheet Zoom/Grow Bug (BB-6be5p0sg)

System-level SwiftUI bug: any tap inside a .sheet with .presentationDetents causes a subtle zoom/grow effect on first interaction. Reproducible with a bare minimum Text("Tap me").presentationDetents([.medium]) — no custom code needed.

  • 12 approaches tried: removing matchedGeometryEffect, switching to GeometryReader, removing animations, using static layouts, etc. None resolve it.
  • Workaround: The effect resolves after expanding to .large and back, or after the first interaction.
  • Impact: Cosmetic only. Does not affect functionality or accessibility.
  • Status: Deferred. Likely requires an Apple fix in a future iOS release.

Boundaries

  • SF Symbols preferred; custom Tabler icons only for domain-specific event types (see Custom Icons section)
  • No custom fonts (Apple semantic text styles only)
  • Haptic feedback: implemented via BBHaptic (see Haptics section above)
  • No landscape mode optimization
  • No iPad optimization
  • No custom navigation transitions
  • Shadow/elevation only on dashboard cards (via BBCardStyle modifier). Light mode uses shadow, dark mode uses lighter surface + subtle border.