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.