App: Pip's Kitchen Garden (ChefAcademy)
Reviewer: Claude Code (automated weekly audit)
Scope: Full codebase — all 88 Swift source files read
Audience lens: Ages 6+ (per March 2026 UX audit revision)
Anti-pattern categories: A=Hidden-mode taps · B=Multi-step purchases · C=Missing affordances · D=Tap target size · E=Off-screen actions · F=Text-heavy/readability · G=Inconsistent gestures · H=Missing next-step cue · I=No undo · J=Harsh feedback · K=Onboarding gaps · L=Inconsistent visual language
This week's audit covered every Swift file in the project. The codebase continues to mature: the March 2026 UX redesign has landed (MeetPipAnimated reduced to 3 dialogs, allergen skip affordance present, .trailingFade() applied to most horizontal scrolls), and the new multiplayer suite is well-engineered for a young audience. The main blockers before a 6+ ship are (1) free users receiving zero audio—pre-readers in this age group need AVSpeechSynthesizer at minimum, (2) several recipe step sequences that far exceed the 4-step cognitive limit, and (3) a cluster of sub-11pt labels scattered across game views. 14 actionable findings are documented below, ranked P0–P2. All recommended token additions follow the hard rule: no hardcoded colors, fonts, or spacing.
| # | Area | Observation |
|---|---|---|
| 1 | PipDialogView | Auto-speaks on .onAppear; first-class voice integration |
| 2 | PlantingSheet | Morph transition (matchedGeometryEffect) is delightful |
| 3 | RecipeDetailView | Sticky "Let's Cook!" footer — never requires scrolling past steps |
| 4 | FarmShopView | Info-first purchase flow; taps item → reads info → optional buy |
| 5 | Horizontal scrolls | .trailingFade() applied consistently (Garden seed row, Farm shop) |
| 6 | BouncyButtonStyle | Used broadly; gives every tap immediate tactile-like feedback |
| 7 | CookingCompletionView | Sequential star reveal with spring animation — celebration peak |
| 8 | AskPipView | Topic-grouped starter questions support pre-readers via icon groups |
| 9 | GiftVeggieSheet | PipDialog confirm before irreversible gift action — no silent undo |
| 10 | ParentPINEntryView | "Forgot PIN?" → Apple ID verification; recoverable without support |
| 11 | MeetPipAnimated | Reduced from 7 to 3 dialogs — previous audit fix implemented |
| 12 | GardenWeatherService | pipMessages are age-appropriate ("the plants are thirsty!") |
| 13 | AllergenPickerStep | "None of these" skip + kid-friendly explanations on each allergen |
| 14 | PipAIService | System prompt explicitly forbids clinical nutrition terms |
| 15 | InsulinTetrisView | Bin hit-box expanded +20pt each side — forgiving for small hands |
| 16 | InsulinTetrisView | Fiber block count derives from player's actual harvested garden veggies |
| 17 | GameCenterService | Access point at .bottomTrailing — avoids top-corner tap conflicts |
| 18 | AssetPackController/ODRManager | Pip waving animation shown during asset downloads — no blank loading |
| 19 | NearbyVersusView | Radar circle animation during peer search — strong visual feedback |
| 20 | MultiplayerManager | Error escalation reads "Ask a grown-up to turn on Game Center" — age-correct |
| 21 | SplitScreenVersusView / LocalVersusView | Both players earn coins regardless of winner — no losing-child frustration |
| 22 | PipStaticResponses | "What can I cook right now?" runs local game logic — no paywall, instant answer |
| 23 | SubscriptionManager | Cached premium state prevents paywall flash on cold launch |
| 24 | WeatherOverlayView | allowsHitTesting(false) — weather particles never steal garden taps |
| 25 | SeasonalOverlayView | Particle positions seeded once in @State — no jitter on parent re-render |
File: PipVoice.swift — .readText case in speak()
Impact: Pre-readers aged 6–7 are the primary audience. When voiceMode == .readText, PipVoice.shared.speak() returns silently. Every Pip dialogue, cooking instruction, and mini-game cue is text-only for non-subscribers. A child who cannot read is effectively locked out of the narrative layer of the game without their parent purchasing Pip Plus.
Recommendation: Add AVSpeechSynthesizer as the free-tier fallback inside the .readText branch. Use the system voice — no API cost, no subscription required. Pip Plus retains the ElevenLabs character voice as its exclusive premium benefit. This is the single highest-impact UX change for the target age group.
// PipVoice.swift — in speak()
case .readText:
// Fallback: system voice so pre-readers can hear instructions
let utterance = AVSpeechUtterance(string: text)
utterance.rate = 0.48 // slightly slower than default for young listeners
utterance.pitchMultiplier = 1.1
synthesizer.speak(utterance)No new token needed; this is logic only.
File: RecipeCardExample.swift — GardenRecipes.all
Impact: The March 2026 audit established a hard limit of 4 cooking steps for this age group. Current data:
- "Garden Veggie Omelette" — 8 steps
- "Chicken Veggie Skillet" — 11 steps
- Multiple others exceed 4 steps
A 6-year-old reading 11 sequentially numbered instructions before the first mini-game will either scroll past the "Let's Cook!" button or hand the device back to a parent.
Recommendation: Cap each recipe at 4 steps displayed. Collapse additional steps under a "Show all steps" expander that defaults to collapsed. This is additive — parents and older siblings can still see the full recipe.
Token needed in AppTheme.swift:
// Add under AppSpacing:
static let maxRecipeStepsDisplayed: Int = 4File: ChefAcademyApp.swift — HomeImageMap / HomeInteractiveTapLayer
Impact: Interactive hot-zones on the home map are implemented as Color.clear.contentShape(Rectangle()). Children have no indication that these regions are tappable. This is anti-pattern C (missing affordance) combined with L (inconsistent visual language — every other tappable element in the app has a visible button frame or bounce animation).
Recommendation: Add a subtle pulsing ring or a small animated arrow on each interactive zone when the screen first loads. Use BouncyButtonStyle() on the tap regions so touch produces the same feedback as all other buttons. If the art direction requires invisible zones, add a first-launch coach mark (using PipDialogView) that physically points at each zone.
File: AppTheme.swift — PrimaryButtonStyle; MeetPipAnimated.swift, OnboardingView.swift
Impact: The paper/cream background palette (#FDF6E3) has low contrast with goldenWheat (#DAA520). A 6-year-old scanning the screen will not be drawn to the primary action. The March 2026 UX audit flagged this and recommended bright accent colors; PrimaryButtonStyle was not updated.
Recommendation: Add three new accent tokens to AppTheme.swift and update PrimaryButtonStyle to use the first:
// AppTheme.swift — add to Color.AppTheme extension
static let brightGreen = Color(hex: "4CAF50") // high-visibility CTA (WCAG AA on cream)
static let brightBlue = Color(hex: "2196F3") // secondary CTA / informational
static let sunflowerYellow = Color(hex: "FFD600") // reward / celebration accentsUpdate PrimaryButtonStyle to use Color.AppTheme.brightGreen as the fill. TexturedButtonStyle with .tint(Color.AppTheme.brightGreen) should be the default for all "Let's go!" / "Start!" / "Done!" buttons across the app.
Files and exact locations:
| View | Element | Size | Fix |
|---|---|---|---|
PlotView.swift |
Growth percentage label | .rounded(size: 9) |
Raise to .rounded(size: 13) |
GlucoseJourneyView.swift |
Diagram node labels | 8–9pt inline |
Raise to AppTheme.caption (12pt) |
BodyBuddyView.swift |
Organ name labels | 9pt inline |
Raise to AppTheme.caption (12pt) |
InsulinTetrisView.swift |
Bin capacity counter | .rounded(size: 10) |
Raise to .rounded(size: 13) |
InsulinTetrisView.swift |
Fat bin block counter | .rounded(size: 10) |
Raise to .rounded(size: 13) |
SplitScreenVersusView.swift |
Flying food name label | .rounded(size: 9) |
Raise to .rounded(size: 12) |
Children aged 6+ with typical visual acuity struggle to read below 11pt on a 6-inch phone at arm's reach. All game feedback labels must be readable during rapid gameplay.
Token to add if not present:
// AppTheme.swift
static var micro: Font { .AppTheme.rounded(size: 12, weight: .semibold) }File: GardenHubView.swift — GardenModeButton
Impact: Mode toggle buttons use .padding(.vertical, 6) — approximate tap height ~34pt, below the 44pt minimum for ages 6+. Children navigating between Garden views will frequently mis-tap.
Recommendation: Change .padding(.vertical, 6) to .padding(.vertical, AppSpacing.sm) (which resolves to ≥10pt), giving a total tap height of ~44pt. No new token needed — AppSpacing.sm already exists.
File: AvatarModel.swift — Gender enum; also affects CharacterWalkingView.swift, SplitScreenVersusView.swift, LocalVersusView.swift, NearbyVersusView.swift, MultiplayerHealthyPicksView.swift
Impact: All five multiplayer views use child.gender == .boy ? "boy_card_..." : "girl_card_..." — a hard binary. Families with non-binary children will find their child's avatar defaulting to a gendered frame they did not choose. The March 2026 audit listed this as P1.
Recommendation:
// AvatarModel.swift
enum Gender: String, Codable, CaseIterable {
case boy, girl, nonBinary
}Add a nonBinary character frame set to CharacterFrameSet and map nonBinary → the existing girlWalking frames until dedicated assets are created. Update all binary ? "boy_..." : "girl_..." switches to three-way switch statements.
Files: SeedInfoView.swift, PantryInfoView.swift, USDAFoodService.swift topNutrients()
Impact: topNutrients() passes nutrient names like "Lycopene", "Beta-carotene", and "Lutein" directly into display strings that appear on SeedInfoView and PantryInfoView. These words are not in a 6-year-old's vocabulary. PipAIService's system prompt explicitly bans clinical terms; the same standard must apply to static UI strings.
Recommendation: In USDAFoodService.topNutrients(), the name field is already separated from value/organ/emoji — simply replace the scientific names with the kid-friendly values already in the tuple:
// Current: results.append(("Lycopene", "Red power!", "Heart", "❤️"))
// The display label should use "Red power!" not "Lycopene"
// Update the tuple order so callers use index 1 (kid label) not index 0 (scientific)No token change needed; this is a data ordering fix.
Files: PlotView.swift ("Swipe up to harvest!"), CookingMiniGames.swift (StirMiniGame "Stir in circles!", PeelMiniGame "Swipe down to peel!")
Impact: Children who cannot read the gesture instruction have no visual model for what to do. First encounters with each mini-game gesture will result in random tapping until accidental discovery — frustrating and opaque for a 6-year-old.
Recommendation: Add a one-shot gesture hint animation on first appearance: a translucent finger icon that mimes the expected gesture (arc for stir, downward swipe for peel, upward swipe for harvest). Store "has seen hint" in UserDefaults per mini-game type so it only shows once. Use AnimationConstants.pipTransition timing.
File: SiblingProfileView.swift — favorite recipe display
Impact: profile.favoriteRecipeID is rendered directly as a string when no matching recipe title is found. A child sees a UUID-style identifier like "chicken-veggie-platter" instead of a recipe name. Even the slug form is confusing to a non-reader.
Recommendation: Look up the recipe by ID in GardenRecipes.all before display. Fall back to "No favourite yet 🍴" rather than the raw ID:
let title = GardenRecipes.all.first { $0.id == profile.favoriteRecipeID }?.title ?? "No favourite yet 🍴"File: GardenWeatherService.swift — GardenWeather.iconColor; WeatherOverlayView.swift — SunshineOverlay, PartlyCloudyOverlay, CloudOverlay, RainOverlay, StormOverlay, SnowOverlay
Impact: iconColor uses .yellow, .orange, .gray, .blue, .purple, .cyan (raw SwiftUI named colors). GardenSeason.gradientColors uses Color(hex: "E8F5E9") hex literals. WeatherOverlayView particle renderers repeat .blue.opacity(0.4), .yellow.opacity(0.3), .gray.opacity(0.3), .cyan.opacity(0.05), .white.opacity(0.7), .pink.opacity(0.35). These will not respect any future dark-mode adaptation and violate the token rule.
Recommendation — tokens to add in AppTheme.swift:
// Weather icon colors
static let weatherSunny = Color(hex: "F9A825") // warm amber
static let weatherCloudy = Color(hex: "90A4AE") // blue-grey
static let weatherRainy = Color(hex: "5C9BD6") // sky blue
static let weatherStormy = Color(hex: "7E57C2") // storm purple
static let weatherSnowy = Color(hex: "81D4FA") // ice blue
static let weatherPartlyCloudy = Color(hex: "FB8C00") // warm orange
// Season gradient tokens (replace Color(hex:) literals)
static let springGradientTop = Color(hex: "C8E6C9")
static let springGradientBot = Color(hex: "F8BBD9")
static let summerGradientTop = Color(hex: "FFF9C4")
static let summerGradientBot = Color(hex: "FFCC80")
static let fallGradientTop = Color(hex: "FFE0B2")
static let fallGradientBot = Color(hex: "D7CCC8")
static let winterGradientTop = Color(hex: "E3F2FD")
static let winterGradientBot = Color(hex: "E8EAF6")Replace all raw .yellow, .blue, .gray, .cyan, .purple, .pink, .orange, .white color literals in both files with the tokens above or with existing Color.AppTheme.* tokens where appropriate (e.g. Color.AppTheme.sage already exists for wind streaks — correctly used).
Files: HealthyChoiceGameView.swift, InsulinTetrisView.swift, LocalVersusView.swift, NearbyVersusView.swift, SplitScreenVersusView.swift
Impact: "Let's Go!", "Try Again!", "Play Again!", "Start!", "Rematch!" all use manual .background(Color.AppTheme.sage).cornerRadius(...) patterns. This diverges from the standard .buttonStyle(TexturedButtonStyle(tint:)) used in RecipeDetailView, FarmShopView, and CookingSessionView. The inconsistency means some buttons feel physically pressable (textured) and others feel flat — disrupting the tactile metaphor for young players.
Recommendation: Replace all manual background + cornerRadius CTA patterns in the five files above with:
.buttonStyle(TexturedButtonStyle(tint: Color.AppTheme.sage)) // or appropriate tintFile: CookingMiniGames.swift — CookTimerMiniGame
Impact: The green-zone timer only activates "Done!" after 35% has elapsed. If a child taps too early (before the zone), there is no feedback explaining why the button is greyed out — it simply does not respond. For a 6-year-old this reads as "broken."
Recommendation: Add a brief PipSpeechBubble cue when a pre-zone tap is detected: "Wait for the green zone!" This costs one PipVoice.shared.speak() call and reuses the existing speech bubble component.
File: HomeAnimated.swift — PipMessageAnimated
Impact: When the rotating Pip message changes (every ~4 seconds), only the text updates — PipVoice.shared.speak() is never called. Every other PipSpeechBubble appearance in the app speaks on .onAppear. Pre-readers on the Home screen receive no audio even if they have Pip Plus.
Recommendation: In the onChange(of: messageIndex) handler (or equivalent animation completion), call:
PipVoice.shared.speak(messages[messageIndex])File: HealthyChoiceGameView.swift — ready screen
Impact: The mode-selection screen shows "Solo", "Split Screen", "Take Turns", and "Online Battle" as four equally-weighted buttons. Hick's Law: reaction time increases logarithmically with the number of choices. For a 6-year-old, four peer-level options cause choice paralysis. The most common mode (Solo) is not visually distinguished from less-used online features.
Recommendation: Default to Solo play with a collapsed "Play with friends ▾" section that reveals the three multiplayer modes. This reduces the initial decision to 2 choices while keeping all modes accessible.
File: VoicePickerView.swift — VoiceOptionCard for .readText
Impact: After the F-01 fix (adding AVSpeechSynthesizer to the free tier), the subtitle "Read Pip's words on screen — no voice" will be inaccurate. Free users will hear a system voice but the UI says they won't.
Recommendation: Update the subtitle to "Pip's words + system voice — free" once F-01 is implemented. This is a content fix, not a code change; flagged here so it is not overlooked.
Files that contain hardcoded colors, fonts, or spacing that must be migrated before any App Store submission:
| File | Violation | Replacement |
|---|---|---|
GardenWeatherService.swift:iconColor |
.yellow, .orange, .gray, .blue, .purple, .cyan |
New weather tokens (see L-02) |
GardenWeatherService.swift:gradientColors |
Color(hex: "E8F5E9") etc. |
New season gradient tokens (see L-02) |
WeatherOverlayView.swift:SunshineOverlay |
Color.yellow.opacity(0.3), Color.orange.opacity(0.1) |
Color.AppTheme.weatherSunny |
WeatherOverlayView.swift:PartlyCloudyOverlay |
Color.yellow.opacity(0.2), Color.white.opacity(0.6) |
Color.AppTheme.weatherPartlyCloudy, Color.AppTheme.cream |
WeatherOverlayView.swift:CloudOverlay |
Color.gray.opacity(0.3), Color.gray.opacity(0.25) |
Color.AppTheme.weatherCloudy |
WeatherOverlayView.swift:RainOverlay |
Color.blue.opacity(0.4) |
Color.AppTheme.weatherRainy |
WeatherOverlayView.swift:StormOverlay |
Color.blue.opacity(0.5) |
Color.AppTheme.weatherRainy |
WeatherOverlayView.swift:SnowOverlay |
Color.cyan.opacity(0.05), Color.white.opacity(0.7) |
Color.AppTheme.weatherSnowy |
WeatherOverlayView.swift:SpringParticles |
Color.pink.opacity(0.35) |
New token: Color.AppTheme.springPetal |
SceneEditor.swift |
.yellow, .cyan, .green, .red |
Dev tool — no user-facing impact; exempt |
SceneEditor.swift is a dev-only tool with no user-facing rendering and is explicitly exempt from the token rule.
All tokens needed by the findings above, grouped for a single PR:
// MARK: - Weather Colors (add to Color.AppTheme extension)
static let weatherSunny = Color(hex: "F9A825")
static let weatherPartlyCloudy = Color(hex: "FB8C00")
static let weatherCloudy = Color(hex: "90A4AE")
static let weatherRainy = Color(hex: "5C9BD6")
static let weatherStormy = Color(hex: "7E57C2")
static let weatherSnowy = Color(hex: "81D4FA")
// MARK: - Season Gradient Colors
static let springGradientTop = Color(hex: "C8E6C9")
static let springGradientBot = Color(hex: "F8BBD9")
static let summerGradientTop = Color(hex: "FFF9C4")
static let summerGradientBot = Color(hex: "FFCC80")
static let fallGradientTop = Color(hex: "FFE0B2")
static let fallGradientBot = Color(hex: "D7CCC8")
static let winterGradientTop = Color(hex: "E3F2FD")
static let winterGradientBot = Color(hex: "E8EAF6")
// MARK: - CTA Accent Colors (from March 2026 audit — now critical)
static let brightGreen = Color(hex: "4CAF50")
static let brightBlue = Color(hex: "2196F3")
static let sunflowerYellow = Color(hex: "FFD600")
// MARK: - Spring Particle
static let springPetal = Color(hex: "F48FB1") // soft pink
// MARK: - AppSpacing
static let maxRecipeStepsDisplayed: Int = 4
// MARK: - Font
static var micro: Font { .AppTheme.rounded(size: 12, weight: .semibold) }| ID | Priority | Category | File(s) | Description |
|---|---|---|---|---|
| F-01 | P0 | F | PipVoice.swift | Add AVSpeechSynthesizer free-tier fallback |
| F-02 | P0 | F | RecipeCardExample.swift | Cap recipe steps at 4 + "Show more" expander |
| C-01 | P0 | C/L | ChefAcademyApp.swift | Add visual affordances to HomeImageMap tap zones |
| L-01 | P0 | L | AppTheme.swift, PrimaryButtonStyle | Add brightGreen/brightBlue/sunflowerYellow tokens; update CTAs |
| F-03 | P1 | F | PlotView, GlucoseJourneyView, BodyBuddyView, InsulinTetrisView, SplitScreenVersusView | Fix 5 sub-11pt label sizes |
| D-01 | P1 | D | GardenHubView.swift | GardenModeButton vertical padding → AppSpacing.sm |
| K-01 | P1 | K | AvatarModel.swift + 5 multiplayer files | Add non-binary gender option |
| F-04 | P1 | F | USDAFoodService.swift, SeedInfoView, PantryInfoView | Replace scientific nutrient names with kid-friendly labels |
| C-02 | P1 | C | PlotView, CookingMiniGames | Add gesture demo animations on first mini-game encounter |
| G-01 | P1 | G | SiblingProfileView.swift | Show recipe title instead of raw recipeID |
| L-02 | P1 | L | GardenWeatherService, WeatherOverlayView | Replace hardcoded color literals with AppTheme tokens |
| L-03 | P1 | L | 5 game view files | Replace manual background+cornerRadius CTAs with TexturedButtonStyle |
| I-01 | P2 | I | CookingMiniGames.swift | Add PipSpeechBubble cue when pre-zone tap detected |
| H-01 | P2 | H | HomeAnimated.swift | Call PipVoice.speak() on PipMessageAnimated change |
| H-02 | P2 | H | HealthyChoiceGameView.swift | Collapse 4 multiplayer modes into Solo + expandable "Play with friends" |
| H-03 | P2 | H | VoicePickerView.swift | Update "Read Text" subtitle after F-01 AVSpeechSynthesizer fix |
The following files were read and found to have no child-facing UX concerns (infrastructure, dev tools, or correctly implemented):
AmbientAudioPlayer.swift, AppAttestService.swift, AssetPackController.swift, AssetPackImage.swift, AuthManager.swift, BackgroundView.swift, CloudKeyManager.swift, ContentView.swift, ElevenLabsVoiceService.swift, GameCenterMatchmakerView.swift, GameCenterService.swift, MorphTransition.swift, NearbyMultiplayerManager.swift, MultiplayerManager.swift, ODRManager.swift (deprecated, kept for compatibility), PINKeychain.swift, PipFoundationModelService.swift, PipGameAnimationView.swift, PipStaticResponses.swift, PipTestView.swift (dev tool), SceneEditor.swift (dev tool), SeededRandomGenerator.swift, SessionManager.swift, SubscriptionManager.swift, USDAFoodService.swift (data layer), VideoPlayerView.swift, WorkerClient.swift
| Previous Finding | Status |
|---|---|
| MeetPipAnimated: 7 dialogs → 3 | ✅ Fixed (MeetPipAnimated.swift confirmed 3-dialog flow) |
| AllergenPickerStep: add "None of these" skip | ✅ Fixed (button present, correctly styled) |
| FarmShopView: info-first purchase flow | ✅ Implemented |
.trailingFade() on horizontal scrolls |
✅ Applied to Garden seed row and Farm shop |
| PipDialogView auto-speak | ✅ Present |
| P0: AVSpeechSynthesizer free tier | ❌ Not yet implemented (F-01, remains P0) |
| P0: Brighten CTA palette | ❌ Not yet implemented (L-01, remains P0) |
| P1: Non-binary gender option | ❌ Not yet implemented (K-01) |
| P1: Cap recipe steps at 4 | ❌ Not yet implemented (F-02, escalated to P0) |
| P1: Adult nutrient names in child UI | ❌ Not yet implemented (F-04) |
Generated by automated weekly audit — 2026-05-11