fix: DEBUG-587 practice haptics β close the crisis-surface gate and re-anchor the breath on resume - #482
Merged
MP2EZ merged 1 commit intoSep 10, 2026
Conversation
β¦e the breath where it left off AC3 β the stale-gate window was not ~1-2 frames. `activeRef` had TWO writers with different meanings: the render body wrote raw `isActive`, and a passive effect wrote `isActive && isFocused`. Blur ran the effect once and set it false; every later re-render restored it, and all three practice screens re-render at about 1Hz while blurred on their elapsed-time tick, so the gate was stale-true for the whole time the practitioner sat on the crisis screen. Two consequences the item did not name: the AppState 'active' handler read that ref and re-armed the scheduler on returning from a `tel:988` call β a sustained exposure on the single most sensitive sequence in the app β and pending stagger timers, cleared only in effect cleanup, spoke over the crisis screen up to 150ms after the navigation. Fixed structurally rather than by moving an assignment: one writer per ref per meaning, gate composed at read time from `activeRef && focusedRef`, matching the anchor engine's already-correct shape. Moving the effect write into a layout effect β the obvious fix β closes only the intra-commit gap and leaves the every-tick re-open and the AppState re-arm wide open while looking fixed. Stagger handles are now cancelled on the pause/blur transition. AC1/AC2 β ruled the SCHEDULER authoritative and moved the visuals to meet it. `cueScheduler` is pattern-agnostic and shared with the interval and body-region timelines, which are correct precisely because their targets are absolute; re-anchoring it on resume would have made the cue count a function of pause history, left the schedule's tail undelivered, and reintroduced the drift `phaseAtElapsed` exists to eliminate. `BreathingCircle` now derives its resume position from that same model on the same pause-excluding clock, and announces the phase actually being resumed into instead of always "Breathe in". Also fixed, found by the planning panel and outside the ACs as written: `BreathingCircle` announced every phase through `announceForAccessibility` with no focus awareness at all, so a VoiceOver practitioner who tapped 988 kept hearing the practice over CrisisResources indefinitely. That path is not behind `practice_haptics`, so it shipped to every VoiceOver user on all four screens that render the component. The visible reduced-motion label still tracks the breath while silent β it is on-screen state, not an interruption. Recorded rather than fixed, with its bound: React Navigation emits `blur` from an effect after the incoming push commits, leaving a 1-3 frame gap upstream of every signal this hook has. Tests first, both mechanisms observed red before either fix, asserting what reaches `expo-haptics` and `announceForAccessibility` rather than scheduler internals. The blur path had been pinned by zero tests: no existing suite supplies a NavigationContext, so `useIsFocusedSafe` degraded to always-focused and blur could not be expressed at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkZagJp7dzojvrjY7uYwkh
MP2EZ
deleted the
fix/DEBUG-587-practice-haptics-pause-resume-desync
branch
September 10, 2026 04:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes DEBUG-587
What this actually was
The item recorded AC3's stale-gate window as "~1-2 frames" and "under 1% per crisis tap". It is neither.
activeRefhad two writers with different meanings β the render body wrote rawisActive(no focus term), a passive effect wroteisActive && isFocused. Blur ran the effect once and set the ref false; every subsequent render restored it, and the effect's deps had not changed so it never re-ran. All three practice screens re-render at roughly 1Hz while blurred on their elapsed-time tick, so the gate was stale-true for the entire time the practitioner sat on the crisis screen.Two consequences the ACs did not name, both reproduced in a test before being fixed:
onAppStateChange('active')read that ref and re-armed the scheduler. Dialling 988 from CrisisResources backgrounds the app, so this handler runs when the practitioner comes back from the call β and cues then fired over the crisis screen for the rest of the session.The fix
Structural, not a timing patch: one writer per ref per meaning, gate composed at read time from
activeRef && focusedRef, matching the anchor engine's already-correct shape. The obvious fix β moving the effect's write into auseLayoutEffectβ closes only the intra-commit gap and leaves both consequences above wide open while looking fixed; it is rejected in a comment so it does not get re-proposed. Stagger handles are now cancelled on the pause/blur transition.AC1/AC2 β the scheduler is authoritative
Re-anchoring
cueScheduleron resume was considered and refused: it is deliberately pattern-agnostic and shared with ReflectionTimerScreen's interval cadence and BodyScanScreen's region timeline, both correct precisely because their targets are absolute against a fixed origin. Snapping would have made cue count a function of pause history (the "signature" the cue catalog forbids), left the tail of a fixed-length schedule undelivered, and reintroduced the driftphaseAtElapsedexists to eliminate.So the visuals moved instead.
BreathingCirclederives its resume position fromphaseAtElapsedon the same pause-excluding clock, and announces the phase actually being resumed into rather than always "Breathe in". The ruling is recorded in thecueScheduler,BreathingCircleandphaseAtElapsedmodule headers, andpauseResumeSync.test.tsxgoes red if anyone re-anchors the scheduler.Also fixed β outside the ACs, and more severe than AC3
BreathingCircleannounced every breath phase throughAccessibilityInfo.announceForAccessibilitywith no focus awareness at all. A VoiceOver practitioner who tapped 988 kept hearing "Breathe in" / "Breathe out" over CrisisResources indefinitely. That path is not behindpractice_haptics, so unlike everything else here it shipped to every VoiceOver user on all four screens that render the component. The visible reduced-motion label still tracks the breath while silent β it is on-screen state, not an interruption.Recorded rather than fixed, with its bound
React Navigation emits
blurfrom an effect after the incoming push commits, leaving a 1-3 frame gap upstream of every signal this hook has. That residual is documented in the hook's header. Nothing else qualified β the tick re-open, the AppState re-arm and the stagger timers were defects, not residuals.Tests
Test-first. Both mechanisms observed red before either fix, each with green controls alongside so a red could not be a dead harness; the new focus gate was additionally proven by mutation. Assertions are at the
expo-hapticsandannounceForAccessibilityboundaries, never on scheduler internals.Worth recording: the blur path had been pinned by zero tests. No existing suite supplies a
NavigationContext, souseIsFocusedSafedegraded to always-focused and blur could not be expressed at all β which is why this survived a suite that otherwise covers the module well.Local: 545 passed across the haptics/practices/screen suites; full
precommitgreen;test:accessibility456 passed;test:integration162 passed;check:breathing-workletsgreen.Gate status
Phase 2.5 correctly self-skips β no path in this diff is in the grep. That is itself the finding below.
Follow-up owed, not in this PR
The
crisisplanning lens ruledpractices/shared/haptics/a safety surface that no detector reaches: nothing on the path imports fromfeatures/crisis/, so INFRA-531's import rule matches nothing, andpractices/is exempt from Phase 2.5 exceptdailyloop/. It needs a Protected Paths row plus aSAFETY_CANDIDATESentry β which must be achore(.claude)commit on_bare, since.claudeis gitignored ondevelopmentand cannot ride a feature branch. Note any gate arm there would be notice-only:eas.json'se2e-simprofile carriespractice_haptics:false, so no Maestro flow can render a cue.π€ Generated with Claude Code
https://claude.ai/code/session_01CkZagJp7dzojvrjY7uYwkh