The fifth motion addition in Phase D was the streak milestone overlay: a flame-themed celebration when the user crosses 7, 14, 30, 60, or 100 consecutive days. When I wired it up, the @Environment(\.accessibilityReduceMotion) check was already in the template before I’d thought about it. Not deliberate caution. The rule had been established for the four motions before it, and writing the check was automatic by that point.
That’s the actual outcome worth documenting: not the animations themselves, but the contract they all operate under.
What Phase D adds
PR #10 lands five animated additions to the Cursed Energy DNA (B-014). Four belong to the original Phase D scope: sigil rotation, energy pulse, claim spark, level-up celebration. The fifth, the streak milestone overlay, arrived as a side branch mid-sprint. It extends the same motion layer and operates under the same rule.
The Cursed Energy layer is the gamification surface of the app. It tracks four signals against real spending behaviour: energy, levels, quests, streaks. Phase D is where the visual identity of that layer gets motion. The sigil rotates when energy is active; the energy pulse fires on state changes; the claim spark confirms a quest reward has landed; the level-up celebration fires on promotion. The streak milestone overlay handles the longer arc, flame-themed, timed to the crossings at 7, 14, 30, 60, and 100 days.
All five are decorative in the strict sense. Remove them and the UI functions identically: quests still fire, rewards still land, streaks still count. The motion layer makes the thing feel alive; it doesn’t make it work.
Why motion is a specific concern in this context
A gamification layer in a finance app is motivated attention. The whole point is to make habitual behaviour viscerally satisfying: completing a quest, hitting a milestone, climbing a level. Motion is one of the primary tools for that. The claim spark exists to make the reward land with weight.
That’s exactly the kind of motion vestibular conditions interact with. Ambient continuous motion (sigil rotation, energy pulse) worsens exposure; sudden motion (claim spark, celebration overlay) is more jarring. A gamification layer that doesn’t gate its motion against the system toggle isn’t just being careless about accessibility. It’s using attention-directing motion in a context where some users have told the system they can’t tolerate it.
Reduce Motion isn’t a soft preference. It’s a medical accommodation for a non-trivial percentage of users, and treating it as optional gets the framing wrong.
The contract
@Environment(\.accessibilityReduceMotion) is a SwiftUI environment value that mirrors the Reduce Motion setting directly. It’s a Bool. When it’s true, the user has made a deliberate system-level choice: gone into Settings, found Accessibility, found Motion, and switched the toggle on.
The Phase D decision was to treat that choice as a hard gate on every custom animation. Not “reduce the animation duration.” Not “substitute a simpler transition.” Skip the animation entirely. The toggle is on: nothing moves.
That’s a trade-off worth naming explicitly. The claim spark is functional as well as decorative. It’s the most direct visual confirmation that a quest reward has been claimed. A user with Reduce Motion on loses that signal in its current form. The right long-term answer is a non-animated fallback: a brief tint change, a static glow, something that preserves the confirmation without motion. Out of scope for this sprint. The hard gate closes the accessibility gap; the fallback refinement is a future item.
The alternative was never seriously considered: adding Phase D’s animations without a gate, then coming back to handle accessibility. “We’ll add accessibility later” is how motion ends up shipping without a gate indefinitely.
The implementation split
The streak milestone is the clearest example of where the toggle check belongs in the call chain.
StreakMilestones is a pure struct. Its crossed(previous:current:) -> Int? method takes the before and after streak counts and returns the highest milestone crossed, if any. The struct has no knowledge of the render context. It doesn’t know it’s in a SwiftUI view, doesn’t know about accessibilityReduceMotion, doesn’t know what “showing an overlay” means.
The view layer reads the return value, reads the environment, and decides whether to present the overlay. Detection runs regardless of accessibility state. Presentation is what the toggle gates.
That split matters beyond the streak milestone. If the domain logic knew about the reduce-motion toggle, it would be carrying render context it has no business knowing about. The view layer is the right place for “should I show this?” questions. Adding the accessibility check there is consistent with the boundary rather than working around it.
Why a rule instead of case-by-case
Five motions across two devlog entries on the same PR. The streak milestone side branch extended Phase D’s scope mid-sprint. Without a rule, every new motion addition comes with its own judgment call: is this one prominent enough to warrant the check? Is the duration short enough to get away without it?
That’s how partial compliance happens. One addition skips the check because it felt like a small touch. Then another. For this app, the Reduce Motion toggle stops meaning what the user expects it to mean. The setting is on; the motion fires anyway.
The rule removes the judgment call. If it moves, it checks accessibilityReduceMotion. Every addition in Phase D followed it because the question was never raised. The constraint was already established when the side branch arrived. Adding the streak milestone without the check would have been a visible deviation from the pattern, not a defensible judgment call.
Custom vs. system animations
The SwiftUI .animation modifier is aware of accessibilityReduceMotion in most cases. System-provided animations (navigation transitions, sheet presentations, withAnimation blocks in many contexts) reduce or skip automatically when the toggle is on.
Custom animations don’t inherit that behaviour. The Phase D additions are all custom: sigil rotation, energy pulse, claim spark, level-up celebration, streak milestone overlay. Without the explicit @Environment(\.accessibilityReduceMotion) read at each site, none of them would respond to the setting at all. The system doesn’t know they exist.
That’s the failure mode worth being clear about. A developer used to SwiftUI handling motion accessibility automatically, and then adds a custom animation, may not realise the coverage has dropped. The explicit check is the only gate. If it’s missing, it’s absent entirely.
What this doesn’t cover
The palette picker and daily save target setting landed in the same PR. Neither introduces motion. Out of scope here.
The category-budget quest type, also in PR #10, is domain logic only. No UI motion. Out of scope.
Navigation and sheet transitions throughout the app are handled by SwiftUI’s native motion-awareness. Out of scope.
The non-animated fallbacks for claim spark and the celebration states are the legitimate follow-up. Not shipped in Phase D.



