The palette picker landed first. BBPalette.current was a static let. That was immediately wrong. A compile-time constant doesn’t survive a user tapping “Inferno” at runtime. Before anything else in PR #10 could be wired, that property needed to change.
Five features went into PR #10 for an iOS budget app across two scopes: two UI additions (palette picker and motion layer) and three game-system additions (streak milestone overlay, category-budget quest, save-target preference). Five devlog entries, all dated 2026-06-14, all tagged side-branch. The common thread wasn’t a shared module or a shared view. It was two shared-state objects that weren’t in the right shape when the features arrived.
The PR
Five additions:
- Palette picker (Phase E, PE-004): Amethyst and Inferno themes selectable at runtime via a new
PaletteChoiceenum - Motion layer (Phase D): sigil rotation, energy pulse, claim spark, and level-up celebration, all gated by
@Environment(\.accessibilityReduceMotion) - Streak milestone overlay: flame-themed celebration at 7, 14, 30, 60, and 100 days via a
StreakMilestonespure type - Category-budget quest: a new
categoryBudgetQuestslot inQuestGeneratorthat fires when any category hits 3+ outflow transactions in the last 7 days with total spend ≥ £20 - Daily save target preference (PE-005), adding
dailySaveTarget: DecimalonUserProfileStore, default £15, persisted as aDecimal-string inUserDefaults, wiring the SaveX quest to a real per-user target
None were long. Each was scoped tightly. The state promotions are what made them land together.
First promotion: BBPalette.current
BBPalette.current was a static let. That declaration is the correct pattern when there’s one palette and it never changes. The moment you add a picker, it’s the wrong pattern.
The promotion: static let becomes nonisolated(unsafe) static var, initialised from UserDefaults at process start. The nonisolated(unsafe) annotation is the honest form here. The variable is mutable global state, shared across isolation domains, with no actor protecting it. Swift’s concurrency model doesn’t offer a clean alternative short of wrapping the property in a global actor, which would make every read site async. That’s the wrong trade-off for a property written once per palette swap and read everywhere the theme applies.
nonisolated(unsafe) says “yes, this is unsafe, and I’ve thought about it.” The safety argument is narrow: palette swaps are user-driven, infrequent, and happen on the main thread. There is no concurrent writer. The annotation is accurate; the risk is bounded.
The new PaletteChoice enum (default, amethyst, inferno) sits alongside the var. BBPalette.current reads the stored choice from UserDefaults at init and returns the corresponding palette. The picker writes back to UserDefaults and reassigns BBPalette.current. Views that depend on the palette redraw via SwiftUI’s normal observation machinery.
Without this promotion first, the palette picker is a UI that pretends to do something. Every other feature that touches palette values at runtime reads a frozen value. The motion layer’s animation colours stale after a swap. The milestone overlay renders in the wrong theme.
Second promotion: UserProfileStore
UserProfileStore needed a new property: dailySaveTarget: Decimal, marked @Published, default £15, persisted as a Decimal-string in UserDefaults.
This is a simpler promotion than the palette one. UserProfileStore already existed as an ObservableObject. Adding a @Published property is the standard shape for per-user preference state in a SwiftUI app. The non-trivial decision is the type.
Decimal for a currency target, not Double. Double arithmetic on money produces floating-point rounding errors that accumulate and eventually show up in the UI. Decimal is exact. UserDefaults doesn’t store Decimal natively, so the persisted form is a string ("15.00"), round-tripping through Decimal(string:) on read and .description on write.
The SaveX quest needed this. Before PE-005, the save target was either hardcoded or absent from the quest’s logic entirely. After, QuestGenerator reads UserProfileStore.dailySaveTarget and uses it as the threshold. The feature is two things: a preference UI that writes the property, and the quest reading it. The state object is the bridge between them.
PE-005 also addressed Dynamic Type clamping for the preference view. That’s a layout concern, not a state concern. Out of scope here.
The motion layer
The motion layer (Phase D) is independent of both promotions in terms of its own wiring. Five animations: sigil rotation, energy pulse, claim spark, and level-up celebration. All five check @Environment(\.accessibilityReduceMotion) before firing.
SwiftUI injects \.accessibilityReduceMotion from the system setting. The guard is structural. If the environment value is true, the animation branch is never entered. No motion fires without the user’s implicit consent. Per-animation overrides are out of scope; the system toggle is the correct and sufficient gate.
The motion layer does depend on the palette promotion indirectly. Animation colours pull from BBPalette.current at runtime. With a static let, those colours are frozen at launch. With the nonisolated(unsafe) static var, they update correctly after a swap. That dependency is why order matters within the PR.
Streak milestones
StreakMilestones is a pure type. crossed(previous:current:) -> Int? takes two streak counts and returns the highest milestone crossed between them, if any. The milestone list is fixed: 7, 14, 30, 60, 100. The logic is a single pass checking whether previous < milestone && current >= milestone.
Pure means no dependencies. StreakMilestones doesn’t read from UserProfileStore. It doesn’t touch BBPalette.current. It answers one question: did the user cross a milestone on this update? The celebration overlay reads the result and fires if non-nil.
The flame-themed overlay is the presentation layer. The crossing logic is a unit-testable pure function. When game logic is cleanly separable from its presentation, that’s the shape it should take.
Category-budget quest
The categoryBudgetQuest slot in QuestGenerator fires when any spending category has 3+ outflow transactions in the last 7 days with total spend at or above £20. The generator picks the category with the largest spend and builds the quest from it.
The threshold is fixed. Those numbers make the quest rare enough to feel earned, specific enough to be actionable. Per-user thresholds or configurable windows are out of scope for this slot.
QuestGenerator already knew how to rotate quest types; categoryBudgetQuest is another type in the rotation. The data source is the transaction history the generator already had access to. No new dependencies, no shared-state requirements.
What the PR exposed
Two patterns came out of this sprint.
Shared mutable state needs the right declaration before features can rely on it. static let and @Published var are different contracts. A static let says “this is fixed.” A published var says “this changes, and observers see it.” If a feature needs the second contract, the declaration needs to be in the right form before the feature ships. The alternative is landing features that silently read stale state and produce bugs that only surface after a palette swap or a preference change.
Pure types absorb complexity without importing it. StreakMilestones has no imports, no dependencies, no setup cost. The celebration overlay is a thin layer on top of it. When game logic can be written as a pure function with no external reads, write it that way. The unit-test surface is the return value.
Five features, one PR, two promotions. The promotions came first.
