Skip to content

Commit ff1edf4

Browse files
Prithvi-Webclaude
andcommitted
fix(a11y): the boxes hidden while they have nothing to say — the preview banner, the map's footnote, the delete dialog's open-file warning, the start screen's line, the plain-words popover, Disk City's line, the index badge, refused folders, drives mounted inside, the calendar's and the journal's notes — say their words in live regions of their own, and Disk City's hover card and the Clean Up refusal are no status (BQS, the class of review F2.1/F2.2)
Each of these carried role="status" or aria-live and `hidden` in the markup, and its writer filled it and showed it in one task: it appeared already filled, which a screen reader seldom announces. Each box is plain text now, and sayAloud (040) says its words in an sr-only live region of its own, beside it, a frame after the box shows them, and empties it when the box goes: - #tmPreviewBanner -> #tmPreviewSaid: the message as it is rendered (innerText), so the sentence the stylesheet hides beside the open cart is not said, and the Exit preview button is no part of it. - #tmAltNote -> #tmAltNoteSaid: the note a layout settles on, never "still laying out — N shapes so far", a new count every frame. - #confirmOpenHandles -> #confirmOpenHandlesSaid, inside the modal dialog: "Checking…", then the warning that turns OK into "Move to Trash anyway", the "Still in use" dialog put back included. - #startLast -> #startLastSaid: the launch lines and the rules' refusal; not a Stop's or a failure's line, which #stopAnnounce (review F1.14) and the toast say already, so none is heard twice. - #nlPending and #nlReason -> #nlSaid: "Translating…", then why; a translation, which takes focus to the query, and a close leave nothing said. - #cityLod -> #cityLodSaid: its news only — the city loading, a load that failed, the saved index's tree, the server's cap — never the counts a zoom changes on every frame; the line is now the city canvas's description (aria-describedby). - #indexBadge -> #indexBadgeSaid; #accessRow -> #accessSaid, its count and its hint (every paint of the row runs inside keepAccessFocus, which says it); #mountsNote -> #mountsNoteSaid; #calDegraded -> #calDegradedSaid; #journalPortable -> #journalPortableSaid. Not live: #cityCard is a hover card the pointer moves, role="tooltip" as the map's #tooltip is; #cleanPromoteWhy's words are toasted in the same task, as every refusal of the Clean Up sheet is. sayAloud says a box shown on lines of its own (innerText, a block a line) as sentences: the region is one sr-only line, where a line break is only a space. Single-line words are said as before. tests/workingSaidAloud.test.ts (RED before): no live region in the page ships hidden, and no script shows or hides one, but #tmEmpty, named with why (its writer, tmSetEmpty, is in 050, another batch's). tests/hiddenBoxesSaidAloud.test.ts (new, RED before): each box plain and its region after it and beside it; every function that writes a box says it, or runs only inside the one that does; the two boxes that are no status; and each box's writer lifted from the page and run, frame by frame. tests/coreScanFlow.test.ts: the start screen's line said, and a Stop's and a failure's not, through the page's own boot, Stop and failure. frontendContract, fxWiring, mountsNoteUi and firstRunViews pinned the old live roles; they pin the regions. Mutants (W/bqs/mut/MUTANTS.txt, W1..R1, 62), each RED for its assertion, restored by SHA-256, page rebuilt byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fa5db71 commit ff1edf4

25 files changed

Lines changed: 1099 additions & 64 deletions

‎public/index.html‎

Lines changed: 201 additions & 27 deletions
Large diffs are not rendered by default.

‎src/ui/app/040-scanning-sse.js‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,16 @@ function sayWorking(host, liveId) {
448448
now and given the words a frame later, once the box is on screen with them,
449449
so they are a change a screen reader hears. Words the region already says
450450
are not said again (a query run once more, to the same message, is no
451-
news), and words taken away before their frame are never said. */
451+
news), and words taken away before their frame are never said.
452+
A box shown on lines of its own — what innerText reads of one, a block a
453+
line — is said as sentences: the region is on one line (sr-only), where a
454+
line break is only a space, so each line but the last ends with a stop where
455+
it has none, and a screen reader pauses there (BQS). */
452456
const aloudSaid = new Map(); // region id → the words it says, or is about to
453457
function sayAloud(liveId, text) {
454458
const live = $(liveId);
455-
const words = text || '';
459+
const lines = String(text || '').split('\n').map((l) => l.trim()).filter(Boolean);
460+
const words = lines.map((l, i) => (i < lines.length - 1 && !/[.!?…:]$/.test(l) ? `${l}.` : l)).join(' ');
456461
if (!live || aloudSaid.get(liveId) === words) return;
457462
aloudSaid.set(liveId, words);
458463
live.textContent = '';

‎src/ui/app/045-persistent-live-index.js‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
/** Paint the index indicator for the folder currently on screen. */
1313
function renderIndexBadge(info) {
1414
const el = $('indexBadge');
15-
if (!info || !info.indexed || !info.root) { el.hidden = true; return; }
15+
// The badge comes on screen with its words already in it, which a screen
16+
// reader seldom announces: they are said in #indexBadgeSaid, a frame after it
17+
// shows them, and taken back as it goes (BQS). Guarded: a page reader may
18+
// lift this function without sayAloud.
19+
const say = (words) => { if (typeof sayAloud === 'function') sayAloud('indexBadgeSaid', words); };
20+
if (!info || !info.indexed || !info.root) { el.hidden = true; say(''); return; }
1621
const stale = info.root.state !== 'ready' || !info.root.live;
1722
// A stale root comes with the server's reason, and there is more than one:
1823
// on macOS another folder's watch starting or stopping can cost this one a
@@ -52,6 +57,7 @@ function renderIndexBadge(info) {
5257
? 'TreeMap keeps a saved index of this folder so it opens instantly. It may have missed a change at some point, so something may be different now.'
5358
: 'This folder opens instantly from a saved index that updates itself as files change.';
5459
el.hidden = false;
60+
say(el.textContent);
5561
}
5662

5763
/** Bumped by each background build; a build whose number has passed stops following its job. */
@@ -1459,6 +1465,13 @@ function keepAccessFocus(paint) {
14591465
const was = document.activeElement;
14601466
const inRow = !!was && (was === row || row.contains(was));
14611467
paint();
1468+
// Every paint of the row runs in here, and the row comes on screen with its
1469+
// words already in it, which a screen reader seldom announces: its count and
1470+
// its hint, a line each, are said in #accessSaid a frame later, and taken
1471+
// back as it goes (BQS). Guarded: a page reader may lift this without sayAloud.
1472+
if (typeof sayAloud === 'function') {
1473+
sayAloud('accessSaid', row.hidden ? '' : [$('accessText').textContent, $('accessHint').textContent].filter(Boolean).join('\n'));
1474+
}
14621475
if (!inRow || (!row.hidden && !was.hidden)) return;
14631476
const stays = ['accessMissingBtn', 'accessOpenBtn', 'accessListBtn'].map((id) => $(id)).find((b) => !row.hidden && !b.hidden);
14641477
(stays || (row.hidden ? $('scanBtn') : row)).focus();
@@ -1794,6 +1807,11 @@ function renderMountsNote(b) {
17941807
const row = $('mountsNote');
17951808
if (!row) return;
17961809
const note = mountsNoteText(b);
1810+
// The note comes on screen with its words already in it, which a screen
1811+
// reader seldom announces: they are said in #mountsNoteSaid a frame later,
1812+
// and taken back as it goes (BQS). Guarded: a page reader may lift this
1813+
// function without sayAloud.
1814+
if (typeof sayAloud === 'function') sayAloud('mountsNoteSaid', note ? note.text : '');
17971815
if (!note) {
17981816
row.hidden = true;
17991817
return;

‎src/ui/app/075-simulated-after.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,9 @@ function syncCartPreviewChrome() {
429429
if (!banner) return;
430430
banner.hidden = !tmPreview.on;
431431
if (!tmPreview.on) {
432+
// The preview over, nothing of the banner's is left said (BQS). Guarded,
433+
// as below.
434+
if (typeof sayAloud === 'function') sayAloud('tmPreviewSaid', '');
432435
wrap.style.removeProperty('--tm-preview-pad');
433436
// The right pad goes too (J52): the canvas takes its width back, and the
434437
// view drops the clearance, so the next preview measures its own.
@@ -457,6 +460,12 @@ function syncCartPreviewChrome() {
457460
// The open cart's footer says this sentence beside the banner, so while the
458461
// cart is open the stylesheet takes it out of the banner (H52).
459462
` <span class="tm-pv-cart-said">${cartSpaceSaid()}</span>` + outside;
463+
// Said aloud in #tmPreviewSaid (BQS): the banner comes on screen with these
464+
// words already in it, which a screen reader seldom announces. Its message
465+
// as it is rendered — innerText, so the sentence the stylesheet hides beside
466+
// the open cart is not said, and the Exit preview button is no part of it.
467+
// Guarded: a page reader may lift this function without sayAloud.
468+
if (typeof sayAloud === 'function') sayAloud('tmPreviewSaid', $('tmPreviewText').innerText);
460469
// The banner floats over the map's top edge, and covered the labels of the
461470
// top-left tiles ("Videos", "holiday-4k.mov" — QA D64). The wrap is padded
462471
// by the banner's measured height instead — it wraps to two lines beside

‎src/ui/app/090-alternate-renderers-the-drawing.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ function renderAltNote() {
381381
const note = state.treemap.altNote;
382382
host.textContent = note || '';
383383
host.hidden = !note;
384+
// Said aloud in #tmAltNoteSaid (BQS): the note comes on screen with its
385+
// words already in it, which a screen reader seldom announces. Not the
386+
// words of a layout still being refined — a new count every frame, written
387+
// with the shapes drawn so far (altRefineSchedule) — but the note it
388+
// settles on. Guarded: a page reader may lift this function without sayAloud.
389+
if (typeof sayAloud === 'function' && note !== altRefiningNote(state.treemap.cells.length)) sayAloud('tmAltNoteSaid', note);
384390
}
385391

386392
/* ── Drawing ──────────────────────────────────────────────────────────── */

‎src/ui/app/135-calendar-heatmap.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ async function loadCalendar() {
4848
? 'The calendar is counted from a finished scan — it fills in once the scan finishes.'
4949
: 'Opened from the saved index — scan this folder to see its files by the day they last changed or were created.')}</p></div>`;
5050
$('calDegraded').hidden = true; $('calLegend').hidden = true;
51+
if (typeof sayAloud === 'function') sayAloud('calDegradedSaid', ''); // the note gone, nothing of it is left said (BQS)
5152
return;
5253
}
5354
if (!scanId) {
@@ -56,6 +57,7 @@ async function loadCalendar() {
5657
`<button class="btn btn-primary" data-cal-browse>${icon('folder', 15)}Choose a folder</button></div>`;
5758
body.querySelector('[data-cal-browse]').addEventListener('click', () => openBrowse(null));
5859
$('calDegraded').hidden = true; $('calLegend').hidden = true;
60+
if (typeof sayAloud === 'function') sayAloud('calDegradedSaid', ''); // the note gone, nothing of it is left said (BQS)
5961
return;
6062
}
6163
const seq = ++calSeq;
@@ -74,6 +76,7 @@ async function loadCalendar() {
7476
calData = null;
7577
body.innerHTML = `<div class="muted">Could not build the calendar: ${escapeHtml(e.message)}</div>`;
7678
$('calDegraded').hidden = true; $('calLegend').hidden = true;
79+
if (typeof sayAloud === 'function') sayAloud('calDegradedSaid', ''); // the note gone, nothing of it is left said (BQS)
7780
}
7881
}
7982

@@ -83,6 +86,11 @@ function renderCalendar() {
8386
const deg = calData.degraded || [];
8487
$('calDegraded').hidden = deg.length === 0;
8588
$('calDegraded').textContent = deg.map((d) => d.reason).join(' ');
89+
// The note comes on screen with its words already in it, which a screen
90+
// reader seldom announces: they are said in #calDegradedSaid a frame later,
91+
// and taken back as it goes (BQS). Guarded: a page reader may lift this
92+
// function without sayAloud.
93+
if (typeof sayAloud === 'function') sayAloud('calDegradedSaid', $('calDegraded').textContent);
8694
if (!days.length) {
8795
body.innerHTML = `<div class="muted">${calChannel === 'created' && deg.length
8896
? 'No creation days could be counted — see the note below.'
@@ -290,6 +298,10 @@ async function loadJournal() {
290298
const note = portable && portable.portable && (portable.degraded || []).find((d) => d.key === 'journal');
291299
$('journalPortable').hidden = !note;
292300
if (note) $('journalPortable').textContent = note.reason;
301+
// The note comes on screen with its words already in it, which a screen
302+
// reader seldom announces: they are said in #journalPortableSaid a frame
303+
// later, and taken back as it goes (BQS). Guarded, as the calendar's.
304+
if (typeof sayAloud === 'function') sayAloud('journalPortableSaid', note ? note.reason : '');
293305
renderJournal(data.entries || []);
294306
} catch (e) {
295307
if (seq !== journalSeq || state.view !== 'history') return;

‎src/ui/app/145-disk-city-the-isometric-view.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,11 +1412,17 @@ function renderCityChrome() {
14121412
const lod = $('cityLod');
14131413
if (lod) {
14141414
const parts = [];
1415+
// What of the line is news, said aloud in #cityLodSaid (BQS): a load that
1416+
// failed, the city loading, the saved index's tree, the server's cap.
1417+
// Never the counts of what is drawn, which change on every frame of a zoom
1418+
// and are read with the city (its aria-describedby); this runs each frame.
1419+
const news = [];
14151420
// A load that failed is said until the next load starts (loadCity).
14161421
// Written once, the next redraw — a resize, the Height or Colour control
14171422
// — wiped it, and a blank plate read as an empty folder (C03 review F3.4).
14181423
const failed = c.loadFailed;
14191424
if (failed) parts.push(`${baseName(failed.path)} did not load: ${failed.message}`);
1425+
if (failed) news.push(parts[parts.length - 1]);
14201426
if (!from) {
14211427
// Loading is true while a load is out or a scan runs whose landing
14221428
// draws the city. A saved index's tree with no scan behind it and none
@@ -1428,6 +1434,7 @@ function renderCityChrome() {
14281434
// No scan behind the tree (treeScanId) and none coming (treeScanComing).
14291435
const fromIndex = !!state.root && !treeScanId() && !treeScanComing();
14301436
if (!failed) parts.push(fromIndex ? 'Opened from the saved index — scan this folder to see its city.' : 'loading this folder…');
1437+
if (!failed) news.push(parts[parts.length - 1]);
14311438
} else {
14321439
// §6.1 forbids a silent threshold. A map drawing 4,120 of 251,000 things
14331440
// and saying nothing is lying by omission about what is on screen.
@@ -1454,12 +1461,16 @@ function renderCityChrome() {
14541461
}
14551462
if (capped) {
14561463
parts.push(`the map holds at most ${formatCount(TM_SERVER_NODE_CAP)} items, and this folder reached that — drill in for the rest`);
1464+
news.push(parts[parts.length - 1]);
14571465
}
14581466
if (c.unknownHeight) parts.push(`${formatCount(c.unknownHeight)} with no ${c.height === 'files' ? 'count' : 'date'} yet, drawn flat`);
14591467
if (c.unresolved) parts.push(`${formatCount(c.unresolved)} could not be depth-sorted and are drawn last`);
14601468
}
14611469
lod.textContent = parts.join(' · ');
14621470
lod.hidden = false;
1471+
// The line comes on screen with its words already in it, which a screen
1472+
// reader seldom announces. Guarded: a page reader may lift this without sayAloud.
1473+
if (typeof sayAloud === 'function') sayAloud('cityLodSaid', news.join(' · '));
14631474
}
14641475

14651476
const legend = $('cityLegend');

‎src/ui/app/155-open-file-guard.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ function resetOpenHandleWarning() {
1717
confirmIgnoreOpenHandles = false;
1818
const host = $('confirmOpenHandles');
1919
if (host) { host.hidden = true; host.innerHTML = ''; }
20+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', ''); // and nothing of it is left said (BQS)
2021
setConfirmButton('Move to Trash');
2122
}
2223

@@ -70,6 +71,11 @@ async function checkOpenHandlesFor(paths) {
7071
host.hidden = false;
7172
host.className = 'checking';
7273
host.textContent = 'Checking whether anything has these files open…';
74+
// Said aloud in #confirmOpenHandlesSaid (BQS): the panel comes on screen with
75+
// its words already in it, which a screen reader seldom announces. Every
76+
// write of the panel says what it shows, as rendered, or empties the region
77+
// as it hides. Guarded: a page reader may lift these without sayAloud.
78+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', host.innerText);
7379

7480
const chunks = Math.min(Math.ceil(paths.length / TRASH_CHUNK), OPEN_HANDLE_PREFLIGHT_CHUNKS);
7581
const conflicts = [];
@@ -128,6 +134,7 @@ async function checkOpenHandlesFor(paths) {
128134
// stand between the user and a delete they asked for. The server still
129135
// guards the delete itself.
130136
host.hidden = true;
137+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', '');
131138
return;
132139
}
133140
if (unavailable) {
@@ -192,6 +199,7 @@ function renderOpenHandleWarning(report, pathCount, nodeOf = () => null) {
192199
host.className = 'checking';
193200
host.hidden = false;
194201
host.textContent = report.reason || 'TreeMap couldn’t check whether these files are in use.';
202+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', host.innerText);
195203
return;
196204
}
197205
// "Checked, found nothing" and "checked as much as it could reach, found
@@ -202,9 +210,11 @@ function renderOpenHandleWarning(report, pathCount, nodeOf = () => null) {
202210
host.className = 'checking';
203211
host.hidden = false;
204212
host.textContent = partial;
213+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', host.innerText);
205214
return;
206215
}
207216
host.hidden = true;
217+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', '');
208218
return;
209219
}
210220

@@ -264,6 +274,8 @@ function renderOpenHandleWarning(report, pathCount, nodeOf = () => null) {
264274
// A truncated probe that found something is still truncated, and this is
265275
// the case it matters most in — the list below may be part of a longer one.
266276
(partial ? `<div class="oh-file" style="margin-top:6px;">${escapeHtml(partial)}</div>` : '');
277+
// As it is shown: one line a block, the programs a line each.
278+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', host.innerText);
267279

268280
// The button now says what it actually does — a move to the Trash, never
269281
// "Delete" (H07). The user has read the warning; taking the choice away
@@ -312,6 +324,7 @@ function confirmFooterReset() {
312324
confirmIgnoreOpenHandles = false;
313325
const host = $('confirmOpenHandles');
314326
if (host) { host.hidden = true; host.innerHTML = ''; }
327+
if (typeof sayAloud === 'function') sayAloud('confirmOpenHandlesSaid', ''); // and nothing of it is left said (BQS)
315328
const cancel = $('confirmCancel');
316329
if (cancel) cancel.hidden = false;
317330
setConfirmButton('Move to Trash');

‎src/ui/app/160-folder-budgets.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ function nlClose(opts = {}) {
385385
// outside click that lands on nothing focusable) without a blur firing.
386386
fxOrbHide('nlListen');
387387
$('nlPending').hidden = true;
388+
if (typeof sayAloud === 'function') sayAloud('nlSaid', ''); // and nothing of it is left said (BQS)
388389
$('tmNlBtn').setAttribute('aria-expanded', 'false');
389390
// A close that hands off elsewhere (Run → the highlight box; a view
390391
// unmount) skips the button focus, or the caret would bounce twice and a
@@ -400,6 +401,11 @@ async function nlTranslate() {
400401
// the finally below and nlClose (a close mid-flight) both drop that.
401402
fxOrbHide('nlListen');
402403
$('nlPending').hidden = false;
404+
// Said aloud in #nlSaid (BQS): "Translating…" and the reason come on screen
405+
// with their words already in them, which a screen reader seldom announces.
406+
// The box's own words, read before the orb mounts into it. Guarded: a page
407+
// reader may lift this function without sayAloud.
408+
if (typeof sayAloud === 'function') sayAloud('nlSaid', $('nlPending').textContent);
403409
fxOrbShow('nl', $('nlPending').querySelector('.fx-orb-well'), 'weaving');
404410
try {
405411
const res = await api('/api/nl-query', {
@@ -415,17 +421,21 @@ async function nlTranslate() {
415421
if (res.matched && res.matched.length) bits.push(`understood: ${res.matched.map((m) => m.phrase).join(', ')}`);
416422
if (res.unmatched && res.unmatched.length) bits.push(`ignored: ${res.unmatched.join(', ')}`);
417423
$('nlDetail').textContent = bits.join(' · ');
424+
// Focus goes to the query, which is read there: "Translating…" is taken back.
425+
if (typeof sayAloud === 'function') sayAloud('nlSaid', '');
418426
$('nlResult').focus();
419427
$('nlResult').select();
420428
} else {
421429
$('nlOut').hidden = true;
422430
$('nlReason').hidden = false;
423431
$('nlReason').textContent = res.reason || 'That could not be translated.';
432+
if (typeof sayAloud === 'function') sayAloud('nlSaid', $('nlReason').textContent);
424433
}
425434
} catch (e) {
426435
$('nlOut').hidden = true;
427436
$('nlReason').hidden = false;
428437
$('nlReason').textContent = 'Translation failed: ' + e.message;
438+
if (typeof sayAloud === 'function') sayAloud('nlSaid', $('nlReason').textContent);
429439
} finally {
430440
fxOrbHide('nl');
431441
$('nlPending').hidden = true;

‎src/ui/app/240-keyboard-shortcuts.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,13 @@ function renderStartChoices() {
471471
const words = startLastLine(last, lastVerdict && !lastVerdict.allowed ? lastVerdict : null, unreadable);
472472
line.textContent = words;
473473
line.hidden = !words;
474+
// Said aloud in #startLastSaid (BQS): the line comes on screen with its words
475+
// already in it, which a screen reader seldom announces. Not the line of a
476+
// Stop or a failure — stopping, stopped, unshown; failed; unstopped — which
477+
// #stopAnnounce (review F1.14) and the toast say already: said here as well,
478+
// each would be heard twice. Guarded: a page reader may lift this without sayAloud.
479+
const saidElsewhere = !!last && ['stopping', 'stopped', 'unshown', 'failed', 'unstopped'].includes(last.outcome);
480+
if (typeof sayAloud === 'function') sayAloud('startLastSaid', saidElsewhere ? '' : words);
474481
// One "Scan … again" carries the id focus lands by: the last folder's, never the history's beside it (J03).
475482
const html = list.map((c, i) => `<button class="btn start-choice${c.again ? ` start-again${retry && !c.history ? ' start-retry' : ''}` : ''}"` +
476483
`${c.again && !c.history ? ' id="startAgainBtn"' : ''} data-start-ix="${i}" title="${escapeHtml(c.drive ? `${c.path} — the whole drive` : c.path)}">` +

0 commit comments

Comments
 (0)