[P3] Safety-stop and automatic-deco countdowns re-announce every simulated second; the safety-stop countdown is in two live regions at once #117
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Priority: P3
Type: bug
Source: full codebase review requested 2026-10-31 (independently reproduced against
2cac7cf)Location:
src/App.tsx:1283-1313,src/App.tsx:1511-1514,src/App.tsx:1515-1524,src/shared/time.ts:2-6Problem and evidence
During a timed safety stop,
formatTime(Math.max(0, safetyEnd - dive.minutes))is rendered inside two separate live regions at the same time: the.safety-statusdiv (role="status" aria-live="polite", lines 1284-1312, text like "SAFETY STOP 2:59 Hold remaining") and thestopMessageparagraph (role="status", lines 1516-1523, "Safety stop in progress · 2:59 hold remaining."). formatTime rounds to whole seconds and outputs m:ss, so the text changes once per simulated second. That is once per real second at 1x and about 8 times per real second at the default 8x. role=status implies aria-atomic=true, so screen readers that queue polite updates (NVDA, JAWS) re-read both whole regions on every tick, giving two nearly identical announcements per tick. The automatic-deco region (.stop-resultrole="status", line 1511-1514, "Decompression stop · X ft · m:ss remaining") has the same per-second churn fromformatTime(autoLeg.remaining), but it is only one region.Line-by-line trace at HEAD
2cac7cf: (1) Line 1284<div className="safety-status" role="status" aria-live="polite">rendersformatTime(Math.max(0, safetyEnd - dive.minutes))in its<b>whenever safetyEnd is finite. (2) Lines 1515-1520: whenstopMessageis true and safetyEnd is finite,<p className="stop-result" role="status">rendersSafety stop in progress · ${formatTime(Math.max(0, safetyEnd - dive.minutes))} hold remaining.Both regions are mounted together during the hold. (3) src/shared/time.ts formatTime returnsm:ssfromMath.round(minutes*60), so each simulated second changes the text. dive.minutes advances continuously while running. (4) Line 568 recomputes autoLeg fromdive.minutes - automaticDeco.start, and line 1512 renders${formatTime(autoLeg.remaining)} remaininginside role="status". The earlier reviewer's Playwright MutationObserver run matches this: at 1x over 10 s,.safety-statusand.stop-resulteach produced 10 distinct texts; at 8x over 5 s, each produced about 40 mutations; the automatic deco.stop-resultproduced 10 changes in 10 s at 1x. I did not re-run the browser test because the code path is unconditional and fully determined by the cited lines.Suggested correction
Keep live regions for phase transitions only: "Traveling to safety stop", "Safety stop started, 3:00 hold", "Safety stop complete", and "Decompression stop at X ft, N min" when a new leg starts. Move the m:ss countdown digits out of the live region: render them in a sibling element without role=status, or mark them aria-hidden and expose them in a non-live element or role="timer" (which has implicit aria-live=off). Remove the duplicate: either
.safety-statusor thestopMessageparagraph should be the single announcing region for safety-stop phase changes. The<progress>already gives on-demand access to the countdown.Acceptance criteria
With a MutationObserver on all [role=status]/[aria-live] elements, during a 3:00 simulated safety stop at 1x and at 8x, the live-region text changes only when the phase changes (arrival/start, completion, surfacing), not every second. Only one live region announces each safety-stop phase change. During an automatic deco stop the live region changes only when a leg begins or ends. The visible countdown still updates every second. Regression test (Vitest + Testing Library or Playwright): advance the sim by several seconds during a hold and assert the textContent of every live region stays the same while the visible countdown element changes. pnpm verify passes.
Scope: maintenance of the educational simulator. Preserve historical R1 acceptance and operational-validation limits.