Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,21 @@ export default function App() {
<p className="tagline">Understand your disk before you clean it.</p>
</header>

{/* 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. */}
<p className="sr-only" aria-live="polite" aria-atomic="true">
{scanning
? cancelling
? "Stopping the scan… nothing has been changed"
: liveProgress.phase || "Walking files"
: ""}
</p>

{!displayedReport && (
<section className="empty">
<p>Run a read-only scan. Nothing is deleted — ever — without your review.</p>
Expand Down
43 changes: 43 additions & 0 deletions app/src/App.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<p\s+className="sr-only"\s+aria-live="polite"[^>]*>([\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("<main") &&
regionIndex < jsx.indexOf("{!displayedReport && ("),
"the region mounts outside the conditional report branches — a region " +
"inside ScanningIndicator mounts already populated and is swapped out " +
"by the first preview, so the first phase would never be announced"
);
});

test("phase transitions announce politely, without the count ticks", () => {
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"
);
});
16 changes: 16 additions & 0 deletions app/src/components/ScanningIndicator.styles.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions app/src/components/ScanningIndicator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions app/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
4 changes: 4 additions & 0 deletions changelog.d/156-sr-announcements.added.md
Original file line number Diff line number Diff line change
@@ -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.
Loading