Skip to content

fix: DEBUG-587 practice haptics β€” close the crisis-surface gate and re-anchor the breath on resume - #482

Merged
MP2EZ merged 1 commit into
developmentfrom
fix/DEBUG-587-practice-haptics-pause-resume-desync
Sep 10, 2026
Merged

fix: DEBUG-587 practice haptics β€” close the crisis-surface gate and re-anchor the breath on resume#482
MP2EZ merged 1 commit into
developmentfrom
fix/DEBUG-587-practice-haptics-pause-resume-desync

Conversation

@MP2EZ

@MP2EZ MP2EZ commented Sep 10, 2026

Copy link
Copy Markdown
Owner

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. activeRef had two writers with different meanings β€” the render body wrote raw isActive (no focus term), a passive effect wrote isActive && 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:

  • The post-988-call return path. 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.
  • Uncleared stagger timers. A paired announcement is armed on a 150ms timer and consults the gate at fire time, so a cue delivered just before the navigation still spoke over the crisis screen.

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 a useLayoutEffect β€” 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 cueScheduler on 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 drift phaseAtElapsed exists to eliminate.

So the visuals moved instead. BreathingCircle derives its resume position from phaseAtElapsed on the same pause-excluding clock, and announces the phase actually being resumed into rather than always "Breathe in". The ruling is recorded in the cueScheduler, BreathingCircle and phaseAtElapsed module headers, and pauseResumeSync.test.tsx goes red if anyone re-anchors the scheduler.

Also fixed β€” outside the ACs, and more severe than AC3

BreathingCircle announced every breath phase through AccessibilityInfo.announceForAccessibility with no focus awareness at all. A VoiceOver practitioner who tapped 988 kept hearing "Breathe in" / "Breathe out" over CrisisResources indefinitely. That path is not behind practice_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 blur from 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-haptics and announceForAccessibility boundaries, never on scheduler internals.

Worth recording: 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 β€” which is why this survived a suite that otherwise covers the module well.

Local: 545 passed across the haptics/practices/screen suites; full precommit green; test:accessibility 456 passed; test:integration 162 passed; check:breathing-worklets green.

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 crisis planning lens ruled practices/shared/haptics/ a safety surface that no detector reaches: nothing on the path imports from features/crisis/, so INFRA-531's import rule matches nothing, and practices/ is exempt from Phase 2.5 except dailyloop/. It needs a Protected Paths row plus a SAFETY_CANDIDATES entry β€” which must be a chore(.claude) commit on _bare, since .claude is gitignored on development and cannot ride a feature branch. Note any gate arm there would be notice-only: eas.json's e2e-sim profile carries practice_haptics:false, so no Maestro flow can render a cue.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01CkZagJp7dzojvrjY7uYwkh

…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
MP2EZ merged commit 10d74a4 into development Sep 10, 2026
11 checks passed
@MP2EZ
MP2EZ deleted the fix/DEBUG-587-practice-haptics-pause-resume-desync branch September 10, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant