[P3] Safety-stop and automatic-deco countdowns re-announce every simulated second; the safety-stop countdown is in two live regions at once #117

Open
opened 2026-10-31 19:47:41 +00:00 by pedro · 0 comments
Owner

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-6

Problem 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-status div (role="status" aria-live="polite", lines 1284-1312, text like "SAFETY STOP 2:59 Hold remaining") and the stopMessage paragraph (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-result role="status", line 1511-1514, "Decompression stop · X ft · m:ss remaining") has the same per-second churn from formatTime(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"> renders formatTime(Math.max(0, safetyEnd - dive.minutes)) in its <b> whenever safetyEnd is finite. (2) Lines 1515-1520: when stopMessage is true and safetyEnd is finite, <p className="stop-result" role="status"> renders Safety 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 returns m:ss from Math.round(minutes*60), so each simulated second changes the text. dive.minutes advances continuously while running. (4) Line 568 recomputes autoLeg from dive.minutes - automaticDeco.start, and line 1512 renders ${formatTime(autoLeg.remaining)} remaining inside role="status". The earlier reviewer's Playwright MutationObserver run matches this: at 1x over 10 s, .safety-status and .stop-result each produced 10 distinct texts; at 8x over 5 s, each produced about 40 mutations; the automatic deco .stop-result produced 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-status or the stopMessage paragraph 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.

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-6` Problem 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-status` div (role="status" aria-live="polite", lines 1284-1312, text like "SAFETY STOP 2:59 Hold remaining") and the `stopMessage` paragraph (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-result` role="status", line 1511-1514, "Decompression stop · X ft · m:ss remaining") has the same per-second churn from `formatTime(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">` renders `formatTime(Math.max(0, safetyEnd - dive.minutes))` in its `<b>` whenever safetyEnd is finite. (2) Lines 1515-1520: when `stopMessage` is true and safetyEnd is finite, `<p className="stop-result" role="status">` renders `Safety 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 returns `m:ss` from `Math.round(minutes*60)`, so each simulated second changes the text. dive.minutes advances continuously while running. (4) Line 568 recomputes autoLeg from `dive.minutes - automaticDeco.start`, and line 1512 renders `${formatTime(autoLeg.remaining)} remaining` inside role="status". The earlier reviewer's Playwright MutationObserver run matches this: at 1x over 10 s, `.safety-status` and `.stop-result` each produced 10 distinct texts; at 8x over 5 s, each produced about 40 mutations; the automatic deco `.stop-result` produced 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-status` or the `stopMessage` paragraph 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.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: pedro/DiveSim#117
No description provided.