OpenFoodJournal UI/UX Design System
Living reference for every visual and interaction pattern in the app. Consult before creating or modifying any SwiftUI view. Update whenever a new pattern is established or an existing one changes.
Quick Reference
| Token | Value | Where Used |
|---|
| Card corner radius | 20 pt | MacroSummaryBar, glass cards |
| Button/chip radius | 12 pt | Remaining calorie pill, macro chips |
| Badge radius | 8 pt | Status badges, small pills |
| List row insets | (8, 16, 8, 16) | All List rows |
| Section gap | 12–20 pt | Between major sections |
| Min hit target | 44 pt | All tappable elements |
| Glass availability | iOS 26+ (no guards) | Entire codebase |
Color System
Macro Colors (Canonical)
| Macro | Color | Usage |
|---|
| Calories | .orange | Calorie countdown, energy metrics |
| Protein | .blue | Protein goals, progress rings |
| Carbs | .green | Carbohydrate goals, progress |
| Fat | .yellow | Fat goals, progress |
Progress Status Colors
| Status | Color | Threshold |
|---|
| Met | .green | 95–105% of goal |
| Near | .yellow | 50–80% of goal |
| Slightly over | .orange | 105–120% of goal |
| Way over | .purple | >120% of goal |
| Under | .red | <50% of goal |
Opacity Conventions
| Element | Opacity |
|---|
| Glass tint | 0.35 |
| Background highlight | 0.12 |
| Disabled state | 0.5 |
| Separator lines | .secondary.opacity(0.4) |
| Secondary text | .foregroundStyle(.secondary) (system) |
| Tertiary text | .foregroundStyle(.tertiary) (system) |
Typography
| Role | Font | Weight | Size |
|---|
| Display macro total | .system(size: 32, weight: .bold, design: .rounded) | bold | 32 |
| Section headline | .headline | default | ~17 |
| Row title | .body | .fontWeight(.medium) | ~17 |
| Row subtitle | .caption | default | ~12 |
| Form label | .subheadline | default | ~15 |
| Numeric alignment | .monospacedDigit() | — | inherited |
Convention: All numeric displays use .system(design: .rounded). Precise alignment uses .monospacedDigit().
Glassmorphism (iOS 26+)
The app targets iOS 26+ exclusively — no #available guards needed.
Standard Patterns
// Card surface
.glassEffect(in: .rect(cornerRadius: 20))
// Circular element (macro rings)
.glassEffect(in: .circle)
// Tinted interactive element
.glassEffect(
.regular.tint(color.opacity(0.35)),
in: .circle
)
// Multiple glass elements in proximity
GlassEffectContainer(spacing: 20) {
HStack { /* glass children */ }
}
// Buttons
.buttonStyle(.glass) // Standard
.buttonStyle(.glassProminent) // Primary action
Glass Rules
- Apply
.glassEffect() after layout and appearance modifiers
- Wrap multiple glass siblings in
GlassEffectContainer
- Use
.interactive() only on tappable/focusable elements
- Use
.glassEffectID(_:in:) + @Namespace for morphing transitions
- Prefer
.glassEffect() over .background(.ultraThinMaterial) for new UI
See the swiftui-liquid-glass skill for the complete Liquid Glass API reference.
Navigation Architecture
Tab Structure
4-tab TabView at root with .tabBarMinimizeBehavior(.never):
| Tab | View | Icon |
|---|
| Journal | DailyLogView | book.pages |
| Food Bank | FoodBankView | refrigerator |
| History | HistoryView | chart.xyaxis.line |
| Settings | SettingsView | gearshape |
Each tab wraps its content in NavigationStack.
Sheet Management (Enum-Driven)
Always use a single enum for all sheets within a page:
enum DailyLogSheet: Identifiable {
case scan, manualEntry, editEntry(NutritionEntry)
case foodBank, containers
var id: String { /* unique per case */ }
}
@State private var presentedSheet: DailyLogSheet?
// In body:
.sheet(item: $presentedSheet) { sheet in
switch sheet {
case .scan: ScanCaptureView(logDate: selectedDate)
case .manualEntry: ManualEntryView(defaultDate: selectedDate)
// ...
}
}
Never use multiple @State booleans for sheet presentation.
Sheet Configuration
Every sheet must include:
NavigationStack wrapper for internal nav
.navigationTitle() + .navigationBarTitleDisplayMode(.inline)
- Cancel button:
ToolbarItem(placement: .cancellationAction)
- Save button:
ToolbarItem(placement: .confirmationAction) with .fontWeight(.semibold) and .disabled() guard
@Environment(\.dismiss) private var dismiss
Component Catalog
Shared Components (Views/Shared/)
| Component | Purpose | Size/Shape |
|---|
MacroRingView | Circular progress for one macro | 56×56 pt, circle |
MacroSummaryBar | 3-column macro cards + calorie headline | Full width, glass card |
RadialMenuButton | Floating "+" FAB with radial menu | Circular, bottom-aligned |
MicronutrientSummaryView | Progress bars for all micros | Full width section |
NutrientBreakdownView | Donut chart + per-food bars | NavigationDestination |
NutritionDetailView | Period picker + macro cards + micros | Full screen section |
ServingMappingSection | Reusable Form section for unit maps | Form section |
CursorEndModifier | UITextField cursor fix | Applied at app root |
Row Components
| Component | Context | Key Elements |
|---|
EntryRowView | List row in DailyLog | Name + brand + calories + macro chips + swipe |
SavedFoodRowView | List row in FoodBank | Source icon + name + serving + calories |
MealSectionView | Section wrapper in List | Header with meal icon + calorie total |
Inline Component Pattern
Extract reusable card builders as private @ViewBuilder functions:
@ViewBuilder
private func macroCard(_ macro: NutrientKind.MacroType, value: Double,
goal: Double, color: Color) -> some View {
Button { selectedMacro = macro } label: {
VStack(spacing: 4) { /* content */ }
}
.buttonStyle(.plain)
}
Layout Patterns
List (Preferred for Swipeable Content)
List {
MealSectionView(...) // Returns Section{}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
Gotcha: .swipeActions is silently ignored outside List. Never use ScrollView + LazyVStack for rows that need swipe actions.
LazyVGrid (2-Column Cards)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
macroCard(...) // 4 cards in 2×2
}
List Row Customization
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
Empty States
ContentUnavailableView {
Label("No Saved Foods", systemImage: "refrigerator")
} description: {
Text("Save a food from a scan first...")
}
Animation Conventions
| Context | Animation | Value |
|---|
| Menu open/close | .spring(duration: 0.4, bounce: 0.3) | RadialMenuButton |
| Micro-interaction | .spring(duration: 0.2) | Option highlight |
| Disclosure toggle | .spring(duration: 0.3) | Section expand |
| Progress rings | .easeInOut | Value transitions |
| View transitions | .opacity.combined(with: .move(edge: .top)) | Expanding sections |
| Glass morphing | .glassEffectTransition(.matchedGeometry) | RadialMenu items |
| Haptic feedback | .sensoryFeedback(.impact(flexibility: .soft)) | Menu open/close |
| Sheet chain delay | asyncAfter(deadline: .now() + 0.15) | Before next sheet |
Convention: Use withAnimation(.spring(...)) for user-initiated actions. Use .animation(.easeInOut, value:) for data-driven transitions.
Form/Input Patterns
Numeric Text Fields
// Always use text-backed fields to avoid cursor-jump artifacts
@State private var quantityText: String
let quantity = Double(quantityText) ?? 0
TextField("Weight", text: $quantityText)
.keyboardType(.decimalPad)
Focus Chain
fileprivate enum FormField: Hashable {
case name, calories, protein, carbs, fat
case micronutrient(String)
}
@FocusState private var focusedField: FormField?
// Chain fields:
TextField("Calories", text: $caloriesText)
.focused($focusedField, equals: .calories)
.submitLabel(.next)
.onSubmit { focusedField = .protein }
Picker Styles
| Style | Use Case |
|---|
.pickerStyle(.menu) | Compact inline (meal type in cards) |
.pickerStyle(.segmented) | 3–4 options (period picker) |
| Default wheel | In-form selection |
Confirmation Dialogs
.confirmationDialog(
"Delete \(food.name)?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { /* action */ }
}
State Management Patterns
Service Injection
// App creates @Observable services, injects via .environment()
@Environment(NutritionStore.self) private var store
@Environment(UserGoals.self) private var goals
SwiftData Bindings
@Bindable var entry: NutritionEntry // Two-way binding to @Model
TextField("Name", text: $entry.name) // Auto-persists
Derived State
// Compute from primary sources — never duplicate as @State
private var filteredFoods: [SavedFood] {
searchText.isEmpty ? allFoods : allFoods.filter { ... }
}
Persistence
// UserGoals uses AppStorage with @ObservationIgnored to avoid conflicts
@ObservationIgnored @AppStorage("goals.calories")
var dailyCalories: Double = 2000
SF Symbols Reference
| Action | Symbol |
|---|
| Scan | camera.fill |
| Manual entry | pencil |
| Food Bank | refrigerator |
| Containers | scalemass |
| History | chart.xyaxis.line |
| Settings | gearshape |
| Delete | trash |
| Add | plus / plus.circle |
| Breakfast | sunrise |
| Lunch | sun.max |
| Dinner | moon.stars |
| Snack | leaf |
| Estimate mode | wand.and.sparkles |
| Label mode | barcode.viewfinder |
Checklist for New Views
Before merging any new view:
UI Roadmap
Food Bank Improvements
Keyboard UX
Calendar Strip → Continuous Scrollable Calendar