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.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..4bfd1fe 100644
--- a/app/src/components/ScanningIndicator.test.mjs
+++ b/app/src/components/ScanningIndicator.test.mjs
@@ -33,6 +33,14 @@ test("the live counters still render from the same props", () => {
);
});
+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", () => {
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.