Five animations, five accessibility gates: what Cursed Energy Phase D cost to ship correctly

The sigil rotation went in first. Then the gate: @Environment(\.accessibilityReduceMotion). Motion fires only when the system toggle is off, and that rule held for every animated effect across PR #10.

Five effects. Five gates. No exceptions negotiated.

What Phase D adds

Phase D is the motion layer. It’s the point at which the Cursed Energy design system moves from static illustration to something that actually expresses what it represents through movement.

The five additions in PR #10:

  • Sigil rotation
  • Energy pulse
  • Claim spark
  • Level-up celebration
  • Streak milestone overlay (flame-themed, triggered at 7, 14, 30, 60, and 100-day streaks)

Every one reads accessibilityReduceMotion from the SwiftUI environment before running. The gate is unconditional. There’s no “this motion is mild enough to keep” branch, no opacity fade standing in for a transform. Motion either runs or it doesn’t.

@Environment(\.accessibilityReduceMotion) reads the device’s system preference (Settings → Accessibility → Motion → Reduce Motion) and updates automatically when the user changes it. No manual observation required. SwiftUI triggers a re-render.

The cost

Five extra environment reads per animated view. In SwiftUI, @Environment lookups resolve at render time from the view’s environment chain. Performance overhead is negligible.

Development overhead is real but bounded. Each animated effect required two paths: motion on, motion off. The static fallback doesn’t need to be visually identical. It needs to be functionally equivalent. The streak milestone overlay’s fallback is a non-animated badge. The milestone still registers. The flame animation doesn’t run.

The convention overhead is larger and longer-lasting. Every future contributor to an animated surface in bb finance now inherits a rule: if it moves, it checks the gate. When a PR adds any animated view, the reviewer has a specific thing to verify: does the animated path guard on accessibilityReduceMotion? One grep for the variable name confirms it’s present. A missing guard fails review, same as a missing nil check or a force-unwrap. The convention is checkable, which is the only kind worth having.

Out of scope: the Reduce Motion gate covers SwiftUI-driven transforms, animations, and transitions only. Colour contrast on the Amethyst and Inferno palettes from Phase E is separate. Dynamic Type support on animated labels has its own clamp. PE-005’s dailySaveTarget field in UserProfileStore handles that. VoiceOver coverage of the celebration overlays is logged but not in this PR.

The motion gate is one layer. Accessibility is a stack.

What the gate prevents

Without it, Phase D would have shipped five potential violations of WCAG 2.1 Success Criterion 2.3.3 (Animation from Interactions). The criterion applies to motion triggered by interaction where the motion isn’t required for the function being performed.

The criterion qualifies one exemption with a precise definition. Motion that, when removed, would fundamentally change the information or function of the content qualifies: a progress indicator that communicates duration, a step-by-step animation where the motion sequence itself is the instruction. Claim sparks, level-up celebrations, and streak milestone overlays don’t meet that bar. The claim button confirms taps through its own state change. The streak milestone overlay confirms a milestone the user already knows they crossed, because the streak counter advanced. Remove the animation, nothing is lost except the amplification.

A user with vestibular disorder who has enabled Reduce Motion on their device shouldn’t receive a celebration overlay when they cross a streak milestone. That’s not an optional accommodation. It’s the minimum for shipping at WCAG 2.1 AA, which is the stated target.

The arithmetic on deferral

The alternative is to ship Phase D without the gates and address them in a follow-up accessibility audit. The audit finds the same five violations, produces a list, assigns them to a sprint, and closes them. The net code change is identical.

What changes is timing. Between Phase D shipping and the audit completing, users with motion sensitivity encounter five unsuppressed animations on a live build. The regression window is the cost of deferral.

Deferral also adds churn: five already-closed files reopened, re-verified, re-tested on both animation paths. The animated path is unchanged. The static fallback is written either way. The only variable is when.

The gate at write time is the cheaper path on every axis except one. The decision to gate must happen before the first animated view is open in the editor. It can’t be made retroactively without the deferral cost.

Phase D made it before the first animation.

Where this sits in the PR

Phase D landed in the same PR (#10) that shipped the categoryBudgetQuest slot in QuestGenerator, the dailySaveTarget preference in UserProfileStore, and the Amethyst and Inferno palette options in Phase E. All of it side-branched off the main Phase work. The motion gate didn’t get its own sprint. It was part of what “animated effects” means in this codebase, written that way from the first commit.

That’s the only version of the decision that doesn’t incur a debt.

All writing