From 8c3843f10fe704c3f416473e18ac46448bad1cbf Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Sun, 13 Sep 2026 13:23:21 +0200 Subject: [PATCH 1/2] feat(app): announce scan phase changes to screen readers politely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan panel re-renders its file and byte counters on every progress event (~150ms), so any live region wrapping them would chatter constantly. The phase label only changes a handful of times per scan — walking files, impact graph, classifying, duplicates, findings — so a visually-hidden aria-live=polite region carrying just that text announces each transition and stays quiet between them. Cancelling also swaps the announcement to the stopping copy so the state change is heard, matching the visible status text. Adds the shared .sr-only clip helper — keeps content in the accessibility tree but off screen — and string-match tests asserting the live region exists, carries the phase rather than the counters, and announces the cancelling state. --- app/src/components/ScanningIndicator.jsx | 10 +++++++ .../ScanningIndicator.styles.test.mjs | 16 ++++++++++ app/src/components/ScanningIndicator.test.mjs | 29 +++++++++++++++++++ app/src/styles.css | 16 ++++++++++ changelog.d/156-sr-announcements.added.md | 4 +++ 5 files changed, 75 insertions(+) create mode 100644 changelog.d/156-sr-announcements.added.md diff --git a/app/src/components/ScanningIndicator.jsx b/app/src/components/ScanningIndicator.jsx index 67fa666..fc3d2b1 100644 --- a/app/src/components/ScanningIndicator.jsx +++ b/app/src/components/ScanningIndicator.jsx @@ -35,6 +35,16 @@ export default function ScanningIndicator({ filesSeen, bytesSeen, phase, onCance {filesSeen.toLocaleString()} files found {bytesSeen > 0 && <> · {humanBytes(bytesSeen)} so far}

+ {/* Polite announcements for the milestones only. This text + changes a handful of times per scan — once per phase, and + once more when a cancel registers — so a live region + carrying just it announces each transition while the + 150ms file/byte ticks above stay silent. */} +

+ {cancelling + ? "Stopping the scan… nothing has been changed" + : phase || "Walking files"} +

diff --git a/app/src/components/ScanningIndicator.styles.test.mjs b/app/src/components/ScanningIndicator.styles.test.mjs index 33e59a6..12da00b 100644 --- a/app/src/components/ScanningIndicator.styles.test.mjs +++ b/app/src/components/ScanningIndicator.styles.test.mjs @@ -74,6 +74,22 @@ test("cancelling calms the panel instead of snapping it away", () => { ); }); +test(".sr-only hides the live region visually, not from the a11y tree", () => { + const block = /\.sr-only\s*\{([^}]*)\}/s.exec(css); + assert.ok(block, "styles.css must define the .sr-only helper the live region uses"); + const body = block[1]; + assert.match(body, /position\s*:\s*absolute/); + assert.match(body, /width\s*:\s*1px/); + assert.match(body, /height\s*:\s*1px/); + assert.match(body, /overflow\s*:\s*hidden/); + assert.match(body, /clip\s*:\s*rect\(\s*0/); + assert.doesNotMatch( + body, + /display\s*:\s*none|visibility\s*:\s*hidden/, + "display:none or visibility:hidden would remove it from the a11y tree entirely" + ); +}); + test("the scan panel enters with transform and opacity only", () => { assert.match( css, diff --git a/app/src/components/ScanningIndicator.test.mjs b/app/src/components/ScanningIndicator.test.mjs index 5cf4473..52bfd35 100644 --- a/app/src/components/ScanningIndicator.test.mjs +++ b/app/src/components/ScanningIndicator.test.mjs @@ -33,6 +33,35 @@ test("the live counters still render from the same props", () => { ); }); +test("phase transitions announce politely, without the count ticks", () => { + const liveRegion = /]*>([\s\S]*?)<\/p>/.exec( + scan + ); + assert.ok( + liveRegion, + "a visually-hidden polite live region must carry the scan phase" + ); + assert.match(liveRegion[1], /phase\b/, "the region announces the scan phase"); + assert.doesNotMatch( + liveRegion[1], + /filesSeen|bytesSeen/, + "the ~150ms file/byte ticks must stay out of the announced text" + ); + assert.match( + liveRegion[0], + /aria-atomic="true"/, + "announce the whole phrase, not a diff of it" + ); +}); + +test("cancelling is announced as a status change, not silence", () => { + assert.match( + scan, + /aria-live="polite"[\s\S]*?cancelling\s*\?\s*"Stopping the scan/, + "pressing Cancel should announce that the scan is stopping" + ); +}); + test("cancelling gets its own calm state", () => { assert.match( scan, diff --git a/app/src/styles.css b/app/src/styles.css index 86b130c..a89cf36 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -20,6 +20,22 @@ body { margin: 0; } button { padding: 0.6rem 1.4rem; font-size: 1rem; cursor: pointer; } .error { color: #c0392b; } +/* Visually hidden but still in the accessibility tree — for text that + only exists to be announced, like the scan phase live region. The + classic 1px clip pattern: display:none would silence it, and a bare + off-screen position would leave a scrollable stray pixel. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .summary { line-height: 1.5; } .scanned-folder { font-family: ui-monospace, monospace; font-size: 0.85rem; opacity: 0.7; } diff --git a/changelog.d/156-sr-announcements.added.md b/changelog.d/156-sr-announcements.added.md new file mode 100644 index 0000000..fd944ba --- /dev/null +++ b/changelog.d/156-sr-announcements.added.md @@ -0,0 +1,4 @@ +- The desktop app's scan panel now announces each new scan stage to screen + readers through a polite, visually-hidden live region — including when a + scan is stopping — while the rapidly updating file and byte counters stay + silent instead of chattering on every progress tick. From 94914e06c51cdfe77b662b77e94e90d6eff3dbc8 Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Mon, 14 Sep 2026 14:13:56 +0200 Subject: [PATCH 2/2] fix(app): mount one persistent live region for scan phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The region inside ScanningIndicator mounted already populated, and polite regions announce content changes rather than initial content — and the first preview swaps the whole panel between the report branches, so the first phase (and the long walking phase) could pass unannounced. The region now lives in App.jsx, mounted empty inside
ahead of the conditional branches, and follows liveProgress.phase plus the cancelling state; the 150ms counters stay out of it. --- app/src/App.jsx | 15 +++++++ app/src/App.test.mjs | 43 +++++++++++++++++++ app/src/components/ScanningIndicator.jsx | 10 ----- app/src/components/ScanningIndicator.test.mjs | 33 +++----------- 4 files changed, 64 insertions(+), 37 deletions(-) diff --git a/app/src/App.jsx b/app/src/App.jsx index 3cef1e3..7e52f01 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -584,6 +584,21 @@ export default function App() {

Understand your disk before you clean it.

+ {/* One persistent live region for scan milestones. It mounts empty + and sits outside the report branches: ScanningIndicator only + appears once a scan starts (already populated — polite regions + announce changes, not initial content) and is replaced when the + first preview swaps the view, so a region inside it could miss + the first phase entirely. Only the phase is announced — the + ~150ms file/byte counters stay out of it. */} +

+ {scanning + ? cancelling + ? "Stopping the scan… nothing has been changed" + : liveProgress.phase || "Walking files" + : ""} +

+ {!displayedReport && (

Run a read-only scan. Nothing is deleted — ever — without your review.

diff --git a/app/src/App.test.mjs b/app/src/App.test.mjs index d140d8b..e431e91 100644 --- a/app/src/App.test.mjs +++ b/app/src/App.test.mjs @@ -74,3 +74,46 @@ test("capped lists retain the findings and duplicate-path styling hooks", () => assert.match(jsx, /className="findings"/); assert.match(jsx, /className="dup-paths"/); }); + +const liveRegion = /]*>([\s\S]*?)<\/p>/.exec( + jsx +); + +test("one live region is mounted persistently, before the report branches", () => { + assert.ok(liveRegion, "a visually-hidden polite live region must exist"); + const regionIndex = jsx.indexOf('aria-live="polite"'); + assert.ok( + regionIndex > jsx.indexOf(" { + assert.match(liveRegion[1], /liveProgress\.phase\b/, "the region announces the scan phase"); + assert.doesNotMatch( + liveRegion[1], + /files_seen|bytes_seen|filesSeen|bytesSeen/, + "the ~150ms file/byte ticks must stay out of the announced text" + ); + assert.match( + liveRegion[0], + /aria-atomic="true"/, + "announce the whole phrase, not a diff of it" + ); +}); + +test("the region is empty while idle and announces cancelling", () => { + assert.match( + liveRegion[1], + /scanning\s*\?[\s\S]*:\s*""/, + "nothing to announce when no scan is running" + ); + assert.match( + liveRegion[1], + /cancelling\s*\?\s*"Stopping the scan/, + "pressing Cancel should announce that the scan is stopping" + ); +}); diff --git a/app/src/components/ScanningIndicator.jsx b/app/src/components/ScanningIndicator.jsx index fc3d2b1..67fa666 100644 --- a/app/src/components/ScanningIndicator.jsx +++ b/app/src/components/ScanningIndicator.jsx @@ -35,16 +35,6 @@ export default function ScanningIndicator({ filesSeen, bytesSeen, phase, onCance {filesSeen.toLocaleString()} files found {bytesSeen > 0 && <> · {humanBytes(bytesSeen)} so far}

- {/* Polite announcements for the milestones only. This text - changes a handful of times per scan — once per phase, and - once more when a cancel registers — so a live region - carrying just it announces each transition while the - 150ms file/byte ticks above stay silent. */} -

- {cancelling - ? "Stopping the scan… nothing has been changed" - : phase || "Walking files"} -

diff --git a/app/src/components/ScanningIndicator.test.mjs b/app/src/components/ScanningIndicator.test.mjs index 52bfd35..4bfd1fe 100644 --- a/app/src/components/ScanningIndicator.test.mjs +++ b/app/src/components/ScanningIndicator.test.mjs @@ -33,33 +33,12 @@ test("the live counters still render from the same props", () => { ); }); -test("phase transitions announce politely, without the count ticks", () => { - const liveRegion = /]*>([\s\S]*?)<\/p>/.exec( - scan - ); - assert.ok( - liveRegion, - "a visually-hidden polite live region must carry the scan phase" - ); - assert.match(liveRegion[1], /phase\b/, "the region announces the scan phase"); - assert.doesNotMatch( - liveRegion[1], - /filesSeen|bytesSeen/, - "the ~150ms file/byte ticks must stay out of the announced text" - ); - assert.match( - liveRegion[0], - /aria-atomic="true"/, - "announce the whole phrase, not a diff of it" - ); -}); - -test("cancelling is announced as a status change, not silence", () => { - assert.match( - scan, - /aria-live="polite"[\s\S]*?cancelling\s*\?\s*"Stopping the scan/, - "pressing Cancel should announce that the scan is stopping" - ); +test("the panel owns no live region — that lives in App", () => { + // Polite regions announce content changes, not initial content, and this + // component mounts already populated (and is swapped out when the first + // preview arrives). The persistent, initially-empty region therefore + // lives in App.jsx, outside the report branches — see App.test.mjs. + assert.doesNotMatch(scan, /aria-live/, "the live region moved to App.jsx"); }); test("cancelling gets its own calm state", () => {