The accessibility gate that made stacking five motion effects into one PR survivable

The streak celebration was the fifth motion effect to land in Phase D. StreakMilestones.crossed(previous:current:) is a pure function. It takes previous and current streak counts and returns the highest milestone the user has just crossed. That output fed a flame-themed overlay. The overlay had an @Environment(\.accessibilityReduceMotion) gate. Writing it took ten seconds. It was the fifth time I’d written the same gate in this PR. That repetition is the point.

PR #10 is a side-branch accumulation. Five features developed in parallel across game logic and UI, merged in one shot: Phase D’s full motion layer, a new categoryBudgetQuest type in QuestGenerator, PE-005 closing the daily save target setting, and Phase E’s Amethyst | Inferno palette picker. The PR wasn’t small. The motion layer’s accessibility audit didn’t expand with the PR’s scope because the motion layer owned its own gate.

What Phase D built

Five additions to the Cursed Energy visual layer:

  • Sigil rotation: the central glyph turning slowly when the user has active quests
  • Energy pulse: a radial breathing animation on the energy orb
  • Claim spark: particle burst on quest completion
  • Level-up celebration: full-screen effect on level transition
  • Streak milestone overlay: flame-themed, fires at 7 / 14 / 30 / 60 / 100 days

The motion layer sits on top of Phase B and C’s static Cursed Energy DNA: the sigil glyph, the energy orb, the colour palette, the quest card surface. Phase D adds behaviour to what Phase B and C left inert. All of that behaviour is conditional on accessibilityReduceMotion. For a user with Reduce Motion on, Phase D shipped no visible change.

Every one of the five effects carries @Environment(\.accessibilityReduceMotion). When that value is true, the effect does not fire. The static version of the component renders instead. No motion, no surprise.

The two approaches

There are two places to put an accessibilityReduceMotion check: at the source of the effect, or at every call site that invokes the effect.

Call-site gating distributes the concern across every consumer. Each caller wraps the invocation in a conditional. Add a second effect, add a second conditional at every call site. Add a fifth, and you have five conditionals scattered across consumers. Each one is a place where a future developer can miss the check. None are visible from within the component itself.

Source gating puts the check inside the component or modifier that owns the motion. If accessibilityReduceMotion is true, the effect is a no-op at source. Callers get correct behaviour without knowing the gate is there. Adding a fifth effect means adding one gate at one source, not five more conditionals distributed across the codebase.

The debt profile is different. Call-site gating is additive per-feature, multiplicative per-call-site. Source gating is one gate per effect, regardless of how many consumers there are. When a PR lands five effects at once, those are very different numbers.

Why it matters when effects stack

Phase D didn’t land one animation. It landed five, in different components, triggering on different events, firing under different conditions. With call-site gating, five effects across N call sites is N times five conditionals: a large surface where a missed check can slip through review.

With source gating, five effects means five gates. Each is owned by the component responsible for that motion. When the sixth effect arrives, it adds one gate at its source. Phase E will bring more. The review question becomes “is this component gated?” rather than “is every call site to this component gated?”

A subtler benefit follows. When every effect carries its own gate, coverage is verifiable at the file level. You inspect the motion components and confirm the gate without tracing through callers. Call-site gating cannot offer that property. The components themselves cannot tell you whether their consumers gated correctly.

That narrower audit surface is why the accessibility review on PR #10 was ten minutes rather than a day. Each effect was audited at its source. One answer per effect. Consistent pattern. No gaps to hunt.

The streak milestone as a worked example

StreakMilestones is a pure type. crossed(previous:current:) -> Int? returns the highest milestone the user just crossed, or nil if none. It does no animation. It is a logic boundary, not a UI boundary.

The overlay that consumes its output is where the motion lives, and where the gate lives. That separation is deliberate. Whether the user just crossed day thirty is a business logic question. Whether to animate the response to that event is a UI concern. The accessibility check belongs in the UI layer, in the component that decides whether motion fires at all.

Phase D follows the same logic throughout. The energy pulse lives in the orb component, the sigil rotation in the sigil component. Neither calls out to a shared accessibility service or reads a global flag. Each reads its local @Environment value and acts.

The palette picker: a different concern

Phase E’s PE-004 (Amethyst | Inferno palette picker) also landed in PR #10. BBPalette.current changed from a static let to a nonisolated(unsafe) static var, initialised from UserDefaults at process start. That is not a motion concern. It is a state initialisation and actor isolation concern, a different kind of audit entirely.

The nonisolated(unsafe) annotation is the right call here. The var is written once at startup before Swift concurrency is in flight; the race the annotation suppresses does not actually exist at runtime. It’s a pragmatic trade-off: the annotation signals that the concurrency surface has been examined, not that the problem has been waved away.

Out of scope: whether that annotation is the permanent answer for palette state. If the initialisation pattern changes, revisit. Not a Phase D concern.

What the pattern costs

@Environment(\.accessibilityReduceMotion) is a SwiftUI property wrapper that reads from the environment hierarchy. Source gating ties each effect component to the SwiftUI view tree. Triggering these effects from outside the view layer, say from a coordinator or a view model, would require passing the environment value in.

That’s a real constraint. For Phase D it costs nothing: all five effects are view-layer concerns. If a future phase needs to drive motion from outside the view hierarchy, the pattern adapts. Until then, the constraint is theoretical.

What the pattern buys

Five motion effects in one PR is manageable when the accessibility concern doesn’t spread across call sites. Source gating keeps it contained: one check per effect source, zero overhead per additional consumer.

The streak milestone celebration took ten seconds of accessibility work. Each of the four preceding Phase D effects took ten seconds. The total accessibility cost of the entire motion layer was fifty seconds, plus a review pass confirming each component held the pattern.

That’s the receipt for a decision made on the first animation and held through the fifth.

All writing