Skip to content

Commit fa5db71

Browse files
Prithvi-Webclaude
andcommitted
fix(a11y): what the map's match bar says is said in a live region of its own, outside the bar - the bar and its message box are hidden while there is nothing to say, so tmSetQueryMessage showed the box, a live region, already filled, and the K03 sentence or a first parse error was announced only where an earlier message had left the box on screen (review F2.2)
sayAloud(liveId, text), beside sayWorking, says a box's words in an sr-only live region of their own: emptied at once and given the words a frame later, once the box is on screen with them; words the region already says are not said again (a query run once more, to the same message, is no news), and words taken away before their frame are never said. tmSetQueryMessage says its words in #tmQuerySaid, which stands in the map's frame right after the bar, held by nothing hidden but the Treemap view. #tmQueryErr is plain text now, so a reader that does announce a box shown filled does not say the words twice, and the highlight box names it as its description (aria-describedby), so the message about a query is read with the field. tests/queryMessageSaidAloud.test.ts (new, RED before): the region and where it stands, read from the markup's own nesting; the box no live region and the field's description; tmSetQueryMessage and sayAloud lifted from the page and run, frame by frame; and tmSetQueryMessage the one place the box's words are set. Mutants (W/bqr1/f2/MUTANTS-F2.txt, F2.2A..T), each RED for its assertion, restored by SHA-256, page rebuilt byte-identical. The same hidden-then-filled pattern stands in other live regions this finding did not name; they are listed for the coordinator, not changed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 31fe079 commit fa5db71

5 files changed

Lines changed: 222 additions & 4 deletions

File tree

‎public/index.html‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4350,7 +4350,7 @@ <h2><span data-icon="gauge"></span>Folder Budgets</h2>
43504350
<!-- The short placeholder fits the field at its floor; the page
43514351
swaps in the longest of TM_SEARCH_PLACEHOLDERS that fits once
43524352
the field is laid out (QA fourth pass E14c). -->
4353-
<input type="text" id="tmSearch" placeholder="Highlight…" aria-label="Highlight matching files in the treemap"
4353+
<input type="text" id="tmSearch" placeholder="Highlight…" aria-label="Highlight matching files in the treemap" aria-describedby="tmQueryErr"
43544354
role="combobox" aria-expanded="false" aria-controls="tmQueryHints" aria-autocomplete="list"
43554355
autocomplete="off" spellcheck="false">
43564356
<!-- FX wiring: same overlay-strip trick as #gsearch — the field
@@ -4457,8 +4457,16 @@ <h2><span data-icon="gauge"></span>Folder Budgets</h2>
44574457
other, so they get a way into the cart. Staging only: nothing is
44584458
deleted here, and the count is stated before anything moves. -->
44594459
<button class="pill" id="tmStageMatches" hidden title="Add every file this query matched to the Clean Up cart">Stage matches</button>
4460-
<div class="tm-queryerr" id="tmQueryErr" role="status" aria-live="polite" hidden></div>
4460+
<!-- Plain text: its words are said in #tmQuerySaid (review F2.2). -->
4461+
<div class="tm-queryerr" id="tmQueryErr" hidden></div>
44614462
</div>
4463+
<!-- What the match bar says, said aloud (review F2.2). The bar and its
4464+
message box are hidden while there is nothing to say, so they come
4465+
on screen with the words already in them, which a screen reader
4466+
seldom announces. This region stands outside the bar, so nothing
4467+
hides it while the map is up; tmSetQueryMessage says the words
4468+
here, a frame after the box shows them. -->
4469+
<div id="tmQuerySaid" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
44624470
</div>
44634471
<div class="tm-foot">
44644472
<div id="tmLegend" class="tm-legend"></div>
@@ -10114,6 +10122,25 @@ <h3 id="portableTitle"><span data-icon="disc"></span>Portable session</h3>
1011410122
});
1011510123
}
1011610124

10125+
/* ── Words said aloud from a box that is hidden while it has nothing to say (review F2.2) ──
10126+
Such a box goes from not displayed to displayed with its words already in
10127+
it, and a screen reader seldom announces a live region that appears filled
10128+
(sayWorking, above). So the box is plain text, and its words are said in an
10129+
sr-only live region of their own, outside whatever hides the box (`liveId`): emptied
10130+
now and given the words a frame later, once the box is on screen with them,
10131+
so they are a change a screen reader hears. Words the region already says
10132+
are not said again (a query run once more, to the same message, is no
10133+
news), and words taken away before their frame are never said. */
10134+
const aloudSaid = new Map(); // region id → the words it says, or is about to
10135+
function sayAloud(liveId, text) {
10136+
const live = $(liveId);
10137+
const words = text || '';
10138+
if (!live || aloudSaid.get(liveId) === words) return;
10139+
aloudSaid.set(liveId, words);
10140+
live.textContent = '';
10141+
if (words) requestAnimationFrame(() => { if (aloudSaid.get(liveId) === words) live.textContent = words; });
10142+
}
10143+
1011710144
/**
1011810145
* How much tree the instant open asks the index for.
1011910146
*
@@ -25738,6 +25765,11 @@ <h3 id="portableTitle"><span data-icon="disc"></span>Portable session</h3>
2573825765
box.classList.toggle('okmsg', !isError);
2573925766
input.classList.toggle('badq', Boolean(isError));
2574025767
input.setAttribute('aria-invalid', isError ? 'true' : 'false');
25768+
// The box and its bar come on screen with the words already in them, which a
25769+
// screen reader seldom announces: the words are said in #tmQuerySaid, which
25770+
// stands outside the bar (review F2.2). Guarded: a page reader may lift this
25771+
// function without it.
25772+
if (typeof sayAloud === 'function') sayAloud('tmQuerySaid', text);
2574125773
tmSyncQueryBar();
2574225774
}
2574325775

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,25 @@ function sayWorking(host, liveId) {
440440
});
441441
}
442442

443+
/* ── Words said aloud from a box that is hidden while it has nothing to say (review F2.2) ──
444+
Such a box goes from not displayed to displayed with its words already in
445+
it, and a screen reader seldom announces a live region that appears filled
446+
(sayWorking, above). So the box is plain text, and its words are said in an
447+
sr-only live region of their own, outside whatever hides the box (`liveId`): emptied
448+
now and given the words a frame later, once the box is on screen with them,
449+
so they are a change a screen reader hears. Words the region already says
450+
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. */
452+
const aloudSaid = new Map(); // region id → the words it says, or is about to
453+
function sayAloud(liveId, text) {
454+
const live = $(liveId);
455+
const words = text || '';
456+
if (!live || aloudSaid.get(liveId) === words) return;
457+
aloudSaid.set(liveId, words);
458+
live.textContent = '';
459+
if (words) requestAnimationFrame(() => { if (aloudSaid.get(liveId) === words) live.textContent = words; });
460+
}
461+
443462
/**
444463
* How much tree the instant open asks the index for.
445464
*

‎src/ui/app/105-query-grammar-in-the-highlight-box.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ function tmSetQueryMessage(text, isError) {
203203
box.classList.toggle('okmsg', !isError);
204204
input.classList.toggle('badq', Boolean(isError));
205205
input.setAttribute('aria-invalid', isError ? 'true' : 'false');
206+
// The box and its bar come on screen with the words already in them, which a
207+
// screen reader seldom announces: the words are said in #tmQuerySaid, which
208+
// stands outside the bar (review F2.2). Guarded: a page reader may lift this
209+
// function without it.
210+
if (typeof sayAloud === 'function') sayAloud('tmQuerySaid', text);
206211
tmSyncQueryBar();
207212
}
208213

‎src/ui/markup/015-view-treemap.html‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<!-- The short placeholder fits the field at its floor; the page
2323
swaps in the longest of TM_SEARCH_PLACEHOLDERS that fits once
2424
the field is laid out (QA fourth pass E14c). -->
25-
<input type="text" id="tmSearch" placeholder="Highlight…" aria-label="Highlight matching files in the treemap"
25+
<input type="text" id="tmSearch" placeholder="Highlight…" aria-label="Highlight matching files in the treemap" aria-describedby="tmQueryErr"
2626
role="combobox" aria-expanded="false" aria-controls="tmQueryHints" aria-autocomplete="list"
2727
autocomplete="off" spellcheck="false">
2828
<!-- FX wiring: same overlay-strip trick as #gsearch — the field
@@ -129,8 +129,16 @@
129129
other, so they get a way into the cart. Staging only: nothing is
130130
deleted here, and the count is stated before anything moves. -->
131131
<button class="pill" id="tmStageMatches" hidden title="Add every file this query matched to the Clean Up cart">Stage matches</button>
132-
<div class="tm-queryerr" id="tmQueryErr" role="status" aria-live="polite" hidden></div>
132+
<!-- Plain text: its words are said in #tmQuerySaid (review F2.2). -->
133+
<div class="tm-queryerr" id="tmQueryErr" hidden></div>
133134
</div>
135+
<!-- What the match bar says, said aloud (review F2.2). The bar and its
136+
message box are hidden while there is nothing to say, so they come
137+
on screen with the words already in them, which a screen reader
138+
seldom announces. This region stands outside the bar, so nothing
139+
hides it while the map is up; tmSetQueryMessage says the words
140+
here, a frame after the box shows them. -->
141+
<div id="tmQuerySaid" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
134142
</div>
135143
<div class="tm-foot">
136144
<div id="tmLegend" class="tm-legend"></div>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import { INDEX } from './fixtures/liftFrontend';
5+
6+
/**
7+
* What the map's match bar says, said aloud (review F2.2).
8+
*
9+
* The bar under the highlight box and the message box in it (#tmQueryErr) are hidden while there is nothing to
10+
* say, and `[hidden]` is `display: none`: tmSetQueryMessage put the words in the box and showed the box and the
11+
* bar in one task, so the box, a live region, appeared already filled — which a screen reader seldom announces
12+
* (sayWorking's own comment). The K03 sentence ("The search runs on a finished scan — it runs once the scan
13+
* finishes.") and a first parse error were then said only where an earlier message had left the box on screen.
14+
* The words are now said in #tmQuerySaid, an sr-only live region outside the bar, held by nothing hidden but the
15+
* Treemap view, a frame after the box shows them; the box itself is plain text, so where a reader does announce a box that appears filled the words
16+
* are not said twice, and the highlight box names it as its description.
17+
*/
18+
19+
/** A page function by its declaration, brace-matched. */
20+
function pageFn(decl: string): string {
21+
const start = INDEX.indexOf(decl);
22+
assert.notEqual(start, -1, `${decl} is in the page`);
23+
let depth = 0;
24+
for (let i = INDEX.indexOf('{', INDEX.indexOf(')', start)); i < INDEX.length; i++) {
25+
if (INDEX[i] === '{') depth++;
26+
else if (INDEX[i] === '}' && --depth === 0) return INDEX.slice(start, i + 1);
27+
}
28+
return assert.fail(`${decl} never closes`);
29+
}
30+
31+
/** The opening tag of the element with this id in the markup. */
32+
function tagOf(id: string): string {
33+
const at = INDEX.indexOf(` id="${id}"`);
34+
assert.notEqual(at, -1, `#${id} is in the markup`);
35+
const open = INDEX.lastIndexOf('<', at);
36+
return INDEX.slice(open, INDEX.indexOf('>', at) + 1);
37+
}
38+
39+
const VOID = new Set(['input', 'br', 'img', 'hr', 'meta', 'link', 'source', 'wbr', 'area', 'col', 'embed', 'track']);
40+
41+
/** The opening tags of the elements that hold the one with this id, outermost first, read from the markup. */
42+
function ancestorsOf(id: string): string[] {
43+
const at = INDEX.indexOf(` id="${id}"`);
44+
const start = INDEX.lastIndexOf('<body', at);
45+
assert.notEqual(start, -1, 'control: the markup has a body');
46+
const markup = INDEX.slice(start, INDEX.lastIndexOf('<', at)).replace(/<!--[\s\S]*?-->/g, '');
47+
const open: Array<{ name: string; tag: string }> = [];
48+
for (const m of markup.matchAll(/<(\/?)([a-z][a-z0-9-]*)\b[^>]*>/gi)) {
49+
const name = m[2].toLowerCase();
50+
if (m[1]) {
51+
assert.equal(open.pop()?.name, name, `the markup closes ${name} where it opened it`);
52+
} else if (!VOID.has(name) && !m[0].endsWith('/>')) {
53+
open.push({ name, tag: m[0] });
54+
}
55+
}
56+
return open.map((o) => o.tag);
57+
}
58+
59+
const SAID = '<div id="tmQuerySaid" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>';
60+
61+
test('the match bar\'s words have a live region of their own, empty in the page from the start, held by nothing that is hidden but the Treemap view itself — never in the bar, which is hidden while it has nothing to say (review F2.2)', () => {
62+
assert.equal(INDEX.split(SAID).length, 2, 'exactly one empty #tmQuerySaid region in the markup');
63+
const holders = ancestorsOf('tmQuerySaid');
64+
assert.ok(holders.some((t) => / id="treemapWrap"/.test(t)), `control: it stands in the map's frame, beside the bar: ${holders.join(' ')}`);
65+
assert.deepEqual(holders.filter((t) => /\shidden(?:[\s>=])/.test(t)).map((t) => / id="([^"]+)"/.exec(t)?.[1] ?? t), ['view-treemap'],
66+
'nothing that holds it is hidden apart from the Treemap view, so it is on screen whenever the map is');
67+
assert.ok(ancestorsOf('tmQueryErr').some((t) => / id="tmQueryBar"/.test(t)), 'control: the message box is in the bar, which comes and goes');
68+
});
69+
70+
test('the message box is plain text, so its words are said once, and the highlight box names it as its description (review F2.2)', () => {
71+
const box = tagOf('tmQueryErr');
72+
assert.doesNotMatch(box, /\srole="|\saria-live="/, `the box is no live region of its own: ${box}`);
73+
assert.match(tagOf('tmSearch'), /\saria-describedby="tmQueryErr"/, 'the highlight box is described by the message about its query');
74+
});
75+
76+
/* ───────────────────────────── the page's own code, run ───────────────────────────── */
77+
78+
/** An element as far as tmSetQueryMessage and sayAloud reach; a region's text is recorded as it is set. */
79+
class El {
80+
hidden = true;
81+
readonly said: string[] = [];
82+
private text = '';
83+
private readonly attrs = new Map<string, string>();
84+
private readonly classes = new Set<string>();
85+
readonly classList = {
86+
toggle: (c: string, on?: boolean) => { if (on ?? !this.classes.has(c)) this.classes.add(c); else this.classes.delete(c); },
87+
contains: (c: string) => this.classes.has(c),
88+
};
89+
get textContent(): string { return this.text; }
90+
set textContent(v: string) { this.text = v; this.said.push(v); }
91+
setAttribute(k: string, v: string): void { this.attrs.set(k, v); }
92+
getAttribute(k: string): string | null { return this.attrs.get(k) ?? null; }
93+
}
94+
95+
/** tmSetQueryMessage and sayAloud lifted from the page, over Els, with frames run by hand. */
96+
function matchBar() {
97+
const els = new Map<string, El>();
98+
const $ = (id: string) => { let e = els.get(id); if (!e) { e = new El(); els.set(id, e); } return e; };
99+
const frames: Array<() => void> = [];
100+
let synced = 0;
101+
const lifted = ['function tmSetQueryMessage(', 'function sayAloud('].map(pageFn).join('\n');
102+
const aloud = /\nconst (\w+) = new Map\(\);[^\n]*\nfunction sayAloud\(/.exec(INDEX);
103+
assert.ok(aloud, 'control: sayAloud keeps what each region says in a map declared right above it');
104+
const set = new Function('$', 'requestAnimationFrame', 'tmSyncQueryBar',
105+
`const ${aloud[1]} = new Map();\n${lifted}\nreturn tmSetQueryMessage;`)(
106+
$, (fn: () => void) => frames.push(fn), () => { synced++; }) as (text: string, isError: boolean) => void;
107+
const frame = () => { for (const fn of frames.splice(0)) fn(); };
108+
return { $, set, frame, synced: () => synced };
109+
}
110+
111+
const K03 = 'The search runs on a finished scan — it runs once the scan finishes.';
112+
const PARSE = 'Unknown field "colour". Valid fields: name, size, ext (at character 1)';
113+
114+
test('the bar\'s words are said in its region a frame after the box shows them, words it already says are not said again, and words taken away before their frame are never said (review F2.2)', () => {
115+
assert.ok(INDEX.includes(`'${K03}'`), 'control: these are the words the bar says while the saved index\'s tree is up and its scan is to come (K03)');
116+
const b = matchBar();
117+
b.set(K03, false);
118+
assert.deepEqual([b.$('tmQueryErr').textContent, b.$('tmQueryErr').hidden, b.synced()], [K03, false, 1], 'control: the box shows the words, and the bar is told');
119+
assert.equal(b.$('tmQuerySaid').textContent, '', 'nothing is said before the frame: the box and its bar come on screen in this task');
120+
b.frame();
121+
assert.equal(b.$('tmQuerySaid').textContent, K03, 'a frame later the region says the words, in its text');
122+
123+
b.$('tmQuerySaid').said.length = 0;
124+
b.set(K03, false);
125+
b.frame();
126+
assert.deepEqual(b.$('tmQuerySaid').said, [], 'the same words again — a query run once more, to the same message — are no news, and are not said again');
127+
128+
b.set(PARSE, true);
129+
assert.equal(b.$('tmQuerySaid').textContent, '', 'new words: the region is emptied at once');
130+
b.frame();
131+
assert.equal(b.$('tmQuerySaid').textContent, PARSE, 'and says them a frame later');
132+
133+
b.$('tmQuerySaid').said.length = 0;
134+
b.set('', false);
135+
b.frame();
136+
b.set(PARSE, true);
137+
b.frame();
138+
assert.deepEqual(b.$('tmQuerySaid').said, ['', '', PARSE], 'once the bar has fallen silent, the same words as before are news again: emptied with the bar, then said');
139+
140+
b.set(K03, false);
141+
b.set('', false);
142+
b.frame();
143+
assert.deepEqual([b.$('tmQueryErr').hidden, b.$('tmQuerySaid').textContent], [true, ''], 'words taken away before their frame are never said, and the region is left empty');
144+
});
145+
146+
test('tmSetQueryMessage is the one place the box\'s words are set, so every word the bar shows is said aloud (review F2.2)', () => {
147+
assert.ok(pageFn('function tmSetQueryMessage(').includes("const box = $('tmQueryErr');"), 'control: tmSetQueryMessage holds the box');
148+
const uses = [...INDEX.matchAll(/\$\('tmQueryErr'\)(.{0,12})/g)].map((m) => m[0]);
149+
assert.equal(uses.filter((u) => u.startsWith("$('tmQueryErr');")).length, 1, 'only tmSetQueryMessage holds the box');
150+
assert.deepEqual(uses.filter((u) => !u.startsWith("$('tmQueryErr');") && !/^\$\('tmQueryErr'\)\.hidden(?!\s*=(?!=))/.test(u)), [],
151+
'every other use of the box reads whether it is hidden');
152+
assert.deepEqual([...INDEX.matchAll(/(?:getElementById\(\s*'tmQueryErr'|querySelector(?:All)?\(\s*'#tmQueryErr)[^\n]{0,40}/g)].map((m) => m[0]), [],
153+
'and nothing reaches the box another way');
154+
});

0 commit comments

Comments
 (0)