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.
| Type | SF Symbol | Usage |
|---|---|---|
| Feeding | bb.baby.bottle | All feeding contexts (Tabler) |
| Diaper | bb.diaper | All diaper contexts (Tabler) |
| Sleep | moon.zzz.fill | All sleep contexts |
| Note | note.text | All note contexts |
| Settings | gear | Settings tab |
| Add/Plus | plus | Toolbar add buttons |
| Back | chevron.left | Navigation back (SwiftUI default) |
| Close | xmark | Sheet 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:
- 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.
- 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" - 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.
- Convert to Custom SF Symbol (optional, recommended): Run
swiftdraw <input.svg> --format sfsymbol --insets autoto convert the SVG into a proper Custom SF Symbol format. This enables weight/scale variants and better Xcode integration. Install viabrew install swiftdraw. - Name the file
bb.<name>.svg(e.g.,bb.baby.bottle.svg,bb.diaper.svg) - 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) - Reference in code:
Image("bb.<name>").renderingMode(.template)— the.renderingMode(.template)is required even when the asset catalog says Template, as a defensive measure - Register in BBTypeIcon: Add the mapping in
Components/Timeline/BBTypeIcon.swift - Update this spec: Add the new icon to the Icons table above
Current custom icons:
| Asset Name | Source | Used For |
|---|---|---|
bb.baby.bottle | Tabler baby-bottle (filled) | Feeding events |
bb.diaper | Tabler diaper (filled) | Diaper events |
Colors
Semantic Palette
Defined as adaptive Color Sets in the asset catalog. Light and dark variants for each.
| Token | Light | Dark | Usage |
|---|---|---|---|
bbPrimary | Soft indigo | Muted indigo | Primary buttons, active states, links, .borderedProminent tint |
bbSecondary | Light lavender | Muted lavender | .bordered button tint, card borders |
bbBackground | Off-white | Near-black (not pure) | Main screen background |
bbSurface | Light gray | Dark gray | Card backgrounds, sheet backgrounds |
bbText | Near-black | Off-white | Primary text |
bbTextSecondary | Medium gray | Light gray | Timestamps, secondary labels, captions |
bbSuccess | Green | Muted green | Positive feedback (used sparingly) |
bbWarning | Amber | Muted amber | Warning toasts, retry indicators |
bbDanger | Red | Muted red | Error 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 (
.title2and 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
.borderedProminentbuttons, 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?) -> ThemePaletteresolvedCategoryStrategy(for childId: UUID?) -> CategoryColorStrategyresolvedIconStyle(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):
| Property | Usage |
|---|---|
primary | Primary buttons, active states, links, avatar background |
secondary | Bordered button tint, card border accents |
background | Main screen background |
surface | Card backgrounds, sheet backgrounds |
text | Primary text |
textSecondary | Timestamps, secondary labels, captions |
success | Positive feedback banners |
warning | Warning toasts, retry indicators |
danger | Error toasts, destructive actions |
sleepIcon | Sleep icon color (WCAG 3:1 on background, separate from primary) |
diaperIcon | Diaper 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):
| ID | Name | Primary (light / dark) | Character |
|---|---|---|---|
warm-nightlight | Warm Nightlight (default) | #A07830 / #E8B86D | Amber/warm, calming at 3 AM |
current-indigo | Indigo | #5856D6 / #7B79E0 | Original indigo — matches legacy Color.bb* tokens |
ocean-calm | Ocean Calm | #2E8B8B / #4DB8B8 | Cool blue-green |
sage-garden | Sage Garden | #6B8F71 / #8FB896 | Muted sage green |
midnight-harbor | Midnight Harbor | #3D5A80 / #5B82AB | Deep navy |
Implementation details:
- Colors are constructed via
adaptiveColor(light:dark:)(package-internal helper inCore/Theme/Theme.swift) andhex(_ hex: UInt)(UInt literal toColor) adaptiveColorusesUIColor { traitCollection in ... }bridge (iOS 17+)- All palettes are static
letconstants — 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:
CategoryColorMapping—struct { light: Color, dark: Color }with aresolved(colorScheme:) -> ColormethodCategoryColorStrategy.color(for eventType: EventType, colorScheme: ColorScheme) -> Color— primary call site inBBTypeIcon
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):
| ID | Name | Character |
|---|---|---|
desaturated-jewel | Desaturated Jewel (default) | Feed=sage, Diaper=clay, Sleep=dusty blue, Note=warm gray |
warm-cool-split | Warm Cool Split | Feed=teal, Diaper=coral, Sleep=lavender, Note=neutral |
apple-health | Apple Health | Inspired by Apple Health color language |
muted-earth | Muted Earth | All earth tones |
pastel-harmony | Pastel Harmony | Soft pastels |
IconStyle (Core/Theme/IconStyle.swift)
enum IconStyle: String, CaseIterable, Identifiable — controls the visual rendering mode of BBTypeIcon.
| Case | Description | Visual |
|---|---|---|
.filled (default) | Solid colored square background, white icon on top | Rounded rectangle badge |
.tinted | Colored icon only, no background | Flat 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.
| Context | Text Style | Weight | Example |
|---|---|---|---|
| Screen titles | .title2 | .bold | "Baby Basics", "Today" |
| Card labels | .headline | default | "Feed", "Diaper", "Sleep" |
| Elapsed time on cards | .title3 | .semibold | "2h 15m ago" |
| Side indicator | .caption | .bold | "L", "R", "L+R" |
| Today's summary | .body | default | "6 feeds", "4 pee" |
| Timeline time | .caption | default | "2:30 PM" |
| Timeline detail | .body | default | "Left breast - 15 min" |
| Sheet titles | .headline | .bold | "Log Feeding" |
| Button labels | .body | .semibold | "Left", "Right", "Both" |
| Snackbar/toast | .callout | default | "Logged left breast" |
| Empty state title | .headline | default | "No events yet today" |
| Empty state subtext | .subheadline | default | "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
.captionor.secondary— too small for legibility at 3 AM / 20% brightness
BBTimestampChip Label
The BBTimestampChip component includes a "When" label above the DatePicker row:
- Layout:
VStackwith "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
bbSurfacebeing noticeably lighter thanbbBackground+ a subtle border:.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)) - Padding:
Spacing.minternal - Theming note: All elevation logic lives in a
ViewModifier(BBCardStyle) that switches oncolorScheme. Individual views just apply.modifier(BBCardStyle())and never think about shadows vs borders.
Sheet Detents
| Sheet | Detent | Rationale |
|---|---|---|
| Diaper (3 buttons) | .medium | Simple, fits in half |
| Sleep (start/wake) | .medium | Simple, fits in half |
| Feeding (select + save) | .height(500), .large | Custom compact detent, "Advanced" expands |
| Note (category + text) | .medium, .large | Starts half, text area may need space |
| Edit any event | .medium, .large | Pre-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
.largeto compact resetsfeedingTypeto.breastand clearsdurationMinutes/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:
- NavigationStack wrapper: Required for
.toolbar(placement: .keyboard)Done button to work. Use.toolbar(.hidden, for: .navigationBar)to hide the nav bar. - Custom detent: Define readable named detents via
private extension PresentationDetent { static let compact = .height(500) }. - Keyboard toolbar: Done button via
.toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { dismissKeyboard() } } }. Essential for decimal pad which has no return key. - Background tap dismiss:
.contentShape(Rectangle()).onTapGesture { dismissKeyboard() }on the main content VStack. Works alongside the toolbar Done button. - Content: Form fields using consistent label pattern (see Form Field Labels above).
- 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.mhorizontal 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.mhorizontal padding, height: 44 pt - Subtle tinted background (
bbPrimaryat ~15% opacity),bbPrimaryforeground text - Disabled state: opacity 0.5
Tier 3: Tertiary (.borderless)
- Low-emphasis: "Cancel", "Skip", "Delete"
.buttonStyle(.borderless),.font(.body)- No background, no border,
bbPrimarytinted text .frame(minHeight: 44)— ensures 44pt minimum touch target even without visible background- Destructive actions: use
Button("Delete", role: .destructive)— auto-renders inbbDanger
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)
- Suggested:
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:
bbPrimarytinted 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
.confirmationDialogor.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),bbPrimarycolor,.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_sidefield. Pre-selects the side BBTogglePicker to the suggested side (opposite of last breast feeding'sstarted_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 (
.largedetent): 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:
nilanimation (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:
| Config | Icon | Example | Style | Duration | Action |
|---|---|---|---|---|---|
| Success | checkmark.circle.fill | "Logged left breast" | bbSurface background | 10 seconds | "Edit" button (opens edit sheet for the entry) |
| Warning | exclamationmark.triangle.fill | "Couldn't save · Will retry" | bbWarning tinted | 4 seconds | None |
| Error | xmark.octagon.fill | "Failed to save feeding · Tap to retry" | bbDanger tinted | Persistent | Tap 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.mhorizontal 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:
- Icon swaps from
xmark.octagon.filltoProgressView()(system spinner) - Text changes to
"Retrying..." - Banner tap is disabled (prevents duplicate requests)
- Banner height stays the same (no layout shift)
- On success: banner auto-dismisses with brief
"Saved"+checkmark.circle.fill(2 seconds) - 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 solidbbSurface - 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
.refreshabledefault 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:
bbWarningbackground,bbTextforeground - 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,bbDangercolor, 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.
| Method | UIKit Generator | When to use |
|---|---|---|
BBHaptic.selection() | UISelectionFeedbackGenerator | Toggle 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 inSheetPresenterononFeedingCreated,onDiaperCreated,onSleepAction,onNoteCreatedcallbacks — before showing the success banner. - Sheet open:
BBHaptic.selection()on quick-action card tap (to open sheet). - Toggle change:
BBHaptic.selection()onBBTogglePickerselection 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.
Navigation
Tab Layout
2-tab TabView at app root:
- Tab 1: Dashboard —
DashboardView, system symbolhouse.fill - Tab 2: Today —
TimelineView, system symbollist.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:
.whitein dark mode,theme.currentPalette.backgroundin 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
checkmarktrailing 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):
BBHaptic.success()fires immediatelybannerState.show(.success(message:onEdit:))with spring animationTask { 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
.largeand 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
BBCardStylemodifier). Light mode uses shadow, dark mode uses lighter surface + subtle border.