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", () => {