android: what a screen reader says — names, roles, and the sheets out of the tree when they are down

Every control on the phone has a name now, in the app's own words, and the
desktop's word where it already has one; the nav is a tab set whose badge
count is part of the tab's name — "You, 3 transfers" rather than a stray 3
read out before it. The two sheets are dialogs with somewhere sensible for
the focus to land, and the half sheet's grab handle is finally a real
Close — the first dismissal a screen reader could reach, because the scrim
never was one. A sheet that is down is inert rather than sitting
off-screen in the accessibility tree with the hidden mini bar and the nav
under it, and one live region is mounted for the life of the app so that
an undo offer is actually announced; switches say on or off, chips say
pressed, and the waveform is a slider where it can seek. Nothing visible
changed. The walk counts the controls with no name, with a junk name, in a
hidden layer, or with unsaid state — all four held at zero on ten screens,
and the nameless count on a room and on both prompts besides, which is
where the six fields named here actually live.
This commit is contained in:
KeelrLeek committed 2026-09-14 00:28:28 -05:00
1 parent 9df162360e
commit 4fc6e9c630
22 files changed
+355 -83

No files matched your search

+119 -19
View File
@@ -68,6 +68,60 @@ window.__settle = function () {
/* what the page shows, read back after every still */
const PROBE = `(function () {
var e = document.querySelector('.screen');
/* ── what a screen reader would be handed ────────────────────────────
'exposed' is the tree TalkBack actually walks: nothing under
aria-hidden or inert, nothing display:none or zero-boxed. 'aname' is
the name it would announce — aria-label, aria-labelledby, then the
visible text with the aria-hidden subtrees left out (an Icon is
aria-hidden, so an icon-only control computes to nothing at all). A
PLACEHOLDER is deliberately not a name here: it goes away the moment
the field has text in it. Every backslash in this block is doubled —
it lives inside a template literal. */
var A11Y_SEL = 'button, [role=button], a, input, select, textarea, [role=tab], [role=switch], [role=slider], [role=checkbox]';
function exposed(el) {
if (el.closest('[aria-hidden="true"], [inert]')) return false;
if (!el.getClientRects().length) return false;
var st = getComputedStyle(el);
return st.visibility !== 'hidden' && st.display !== 'none';
}
function vistext(el) {
var out = '';
for (var i = 0; i < el.childNodes.length; i++) {
var n = el.childNodes[i];
if (n.nodeType === 3) { out += n.nodeValue; continue; }
if (n.nodeType !== 1) continue;
if (n.getAttribute('aria-hidden') === 'true') continue;
if (n.tagName === 'IMG') { out += ' ' + (n.getAttribute('alt') || ''); continue; }
out += ' ' + vistext(n);
}
return out;
}
function aname(el) {
var l = el.getAttribute('aria-label');
if (l && l.trim()) return l.trim();
var by = el.getAttribute('aria-labelledby');
if (by) {
var s = '';
by.split(' ').forEach(function (id) { var n = document.getElementById(id); if (n) s += ' ' + vistext(n); });
if (s.trim()) return s.replace(/\\s+/g, ' ').trim();
}
var t = el.tagName;
if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') {
if (el.id) { var lab = document.querySelector('label[for="' + el.id + '"]'); if (lab && lab.textContent.trim()) return lab.textContent.replace(/\\s+/g, ' ').trim(); }
return (el.getAttribute('title') || '').trim();
}
return vistext(el).replace(/\\s+/g, ' ').trim() || (el.getAttribute('title') || '').trim();
}
function whatis(el) { return (el.tagName.toLowerCase() + '.' + String(el.className || '').split(' ').slice(0, 2).join('.')).slice(0, 36); }
function junk(el, nm) {
if (/^[0-9 .,:;+·—–-]+$/.test(nm)) return 'digits';
if (/\\.(png|jpe?g|webp|gif|svg|mp3|flac|m4a|ogg|wav)$/i.test(nm)) return 'file';
if (/^[⋯…×✕✓→←]$/.test(nm)) return 'glyph';
if (nm.length > 200 && !el.hasAttribute('aria-expanded')) return 'essay';
return '';
}
var a11yAll = [].filter.call(document.querySelectorAll(A11Y_SEL), exposed);
var a11yNone = a11yAll.filter(function (el) { return !aname(el); });
/* geometry, for the orientation steps: box() is the rect or null, and
inside() asks whether every pixel of it is within the viewport —
which, sideways, is the whole question */
@@ -188,6 +242,52 @@ const PROBE = `(function () {
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
/* the number this round exists to hold at zero, and who broke it */
a11yAnon: a11yNone.length,
a11yWho: a11yNone.slice(0, 3).map(whatis).join(' | '),
a11yBad: a11yAll.map(function (el) { var nm = aname(el); if (!nm) return ''; var j = junk(el, nm); return j ? j + ':' + whatis(el) + ':' + nm.slice(0, 22) : ''; }).filter(Boolean).slice(0, 3).join(' | '),
/* a layer that is off-screen but still in the tree: the closed
sheets, the hidden mini bar, the nav under a sheet */
a11yGhost: (function () {
var n = 0;
[].forEach.call(document.querySelectorAll('.sheet:not(.up), .halfsheet:not(.up), .mini.hidden, .nav.gone'), function (h) {
if (h.matches(A11Y_SEL) && exposed(h)) n++;
n += [].filter.call(h.querySelectorAll(A11Y_SEL), exposed).length;
});
return n;
})(),
/* a control that LOOKS chosen and does not say so */
a11yState: (function () {
var bad = [];
[].forEach.call(document.querySelectorAll('.row .sw'), function (sw) {
var b = sw.closest('button, [role=button]');
if (!b || !exposed(b)) return;
var want = sw.classList.contains('on') ? 'true' : 'false';
if (b.getAttribute('role') !== 'switch' || b.getAttribute('aria-checked') !== want) bad.push('sw:' + aname(b).slice(0, 18));
});
[].forEach.call(document.querySelectorAll('.segtabs button, .np-faces .fbtn, .nav button'), function (b) {
if (!exposed(b)) return;
var want = b.classList.contains('on') ? 'true' : 'false';
if (b.getAttribute('role') !== 'tab' || b.getAttribute('aria-selected') !== want) bad.push('tab:' + aname(b).slice(0, 18));
});
[].forEach.call(document.querySelectorAll('button.chip'), function (b) {
if (!exposed(b) || b.classList.contains('tchip')) return;
if (!b.hasAttribute('aria-pressed')) { if (b.classList.contains('on')) bad.push('chip:' + aname(b).slice(0, 18)); return; }
if (b.getAttribute('aria-pressed') !== (b.classList.contains('on') ? 'true' : 'false')) bad.push('chip:' + aname(b).slice(0, 18));
});
return bad.slice(0, 3).join(' | ');
})(),
/* the layer on top: its role, whether it is modal, its name, and
whether the focus went with it */
dlg: (function () {
var d = document.querySelector('.viewer') || document.querySelector('.syncroom')
|| document.querySelector('.halfsheet.up') || document.querySelector('.sheet.up');
if (!d) return '';
var nm = (d.getAttribute('aria-label') || '').trim();
return (d.getAttribute('role') || '-') + '/' + (d.getAttribute('aria-modal') || '-') + '/' + nm.slice(0, 34) + '/' + (d.contains(document.activeElement) ? 'focus' : 'nofocus');
})(),
navTabs: [].map.call(document.querySelectorAll('.nav button'), function (b) { return aname(b) + (b.getAttribute('aria-selected') === 'true' ? '*' : ''); }).join('|'),
announce: ((document.querySelector('[data-live]') || {}).textContent || '').trim(),
vp: innerWidth + 'x' + innerHeight,
/* the sheet's controls: any one of them off the viewport is the landscape bug */
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
@@ -617,14 +717,14 @@ const MOCK_EXPECT = {
"buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast,
"room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText),
"contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast,
taste: (i) => i.likes === "Daft Punk|french house|Warp Records" && i.hates === "loudness war" && i.similar === 4,
taste: (i) => i.likes === "Daft Punk|french house|Warp Records" && i.hates === "loudness war" && i.similar === 4 && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
"taste-declared": (i) => /Boards of Canada/.test(i.likes) && i.similar === 5,
"taste-hated": (i) => i.hates === "loudness war|loudness war 2",
"taste-dropped": (i) => !/Boards of Canada/.test(i.likes) && i.similar === 4 && !i.toast,
"taste-peer": (i) => i.half.includes("up") && /hifi_hank/.test(i.halfText),
"home-more": (i) => i.half.includes("up"),
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit",
"you-norm-off": (i) => i.norm === "off",
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit" && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
"you-norm-off": (i) => i.norm === "off" && i.a11yState === "",
"you-norm-on": (i) => i.norm === "on",
"you-lossy": (i) => i.srcMax === "Lossy only",
"you-keep": (i) => i.keep === "1 week",
@@ -665,15 +765,15 @@ const MOCK_EXPECT = {
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
home: (i) => /^(Good |Still)/.test(i.title),
home: (i) => /^(Good |Still)/.test(i.title) && /^Home\*\|Search\|Library\|Community(, \d+ unread)?\|You(, \d+ transfers?)?$/.test(i.navTabs) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
"search-dym": (i) => i.dym === "shpongle",
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
search: (i) => i.title.startsWith("Search"),
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10 && /hours/.test(i.countline),
album: (i) => i.screen.includes("tr-push") && !i.mini,
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10 && /hours/.test(i.countline) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
album: (i) => i.screen.includes("tr-push") && !i.mini && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => /\+0\.5 s/.test(i.sheetTitle),
@@ -693,13 +793,13 @@ const MOCK_EXPECT = {
"sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off",
"lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast,
"lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast,
viewer: (i) => i.viewer,
viewer: (i) => i.viewer && /^button\/-\/Close artwork viewer/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"album-playing": (i) => i.mini,
"track-sheet": (i) => i.half.includes("up"),
sheet: (i) => i.sheet.includes("up"),
"track-sheet": (i) => i.half.includes("up") && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
"after-back": (i) => !i.sheet.includes("up"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
@@ -709,7 +809,7 @@ const MOCK_EXPECT = {
record leads), the news, the back catalogue, recommendations,
complete the set, following — the desktop's page */
"home-again": (i) => /^(Good |Still)/.test(i.title) && /^Coming/.test(i.feat) && i.news >= 1 && /New from your artists/.test(i.secs) && /From the back catalogue/.test(i.secs) && /Recommended for you/.test(i.secs) && /Following/.test(i.secs),
room: (i) => i.screen.includes("tr-push") && i.msgs >= 2,
room: (i) => i.screen.includes("tr-push") && i.msgs >= 2 && !i.a11yAnon && !i.a11yBad,
"rooms-filtered": (i) => i.rooms === 1,
"rooms-more": (i) => i.rooms >= 95,
/* the artist with a picture shows it on the hero and its card; the
@@ -746,12 +846,12 @@ const MOCK_EXPECT = {
browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead),
"browse-dir": (i) => i.brwFiles >= 8,
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10 && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
"cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14,
"cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges) && /\|You, \d+ transfers?$/.test(i.navTabs),
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3,
@@ -763,7 +863,7 @@ const MOCK_EXPECT = {
"playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
"playlist-filter": (i) => i.plrows === 1,
"playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice),
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice) && /Removed/.test(i.announce) && /Undo/.test(i.announce),
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"),
"playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),
@@ -780,7 +880,7 @@ const MOCK_EXPECT = {
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
"playlist-renamed": (i) => i.title.startsWith("Late night drive II"),
"playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames),
"playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal),
"playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal) && !i.a11yAnon,
"playlist-new": (i) => i.screen.includes("tr-push") && /^New Playlist/.test(i.title) && i.plrows === 0,
"playlist-pick": (i) => i.half.includes("up") && /New playlist/.test(i.halfText) && /Late night drive II/.test(i.halfText),
"playlist-added": (i) => /Added \d+ tracks to New Playlist/.test(i.notice),
@@ -788,7 +888,7 @@ const MOCK_EXPECT = {
"playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames),
"import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText) && /Folder of audio files/.test(i.halfText),
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "",
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "" && !i.a11yAnon,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn),
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2,
@@ -951,7 +1051,7 @@ const PHONE_STEPS = [
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
];
const PHONE_EXPECT = {
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img"),
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img") && !i.a11yAnon && !i.a11yGhost,
"album-playing": (i) => i.mini && !i.toast,
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
@@ -966,7 +1066,7 @@ const PHONE_EXPECT = {
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"),
sheet: (i) => i.sheet.includes("up"),
sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yGhost,
"after-back": (i) => !i.sheet.includes("up"),
"after-reload": (i) => i.mini && !!i.playing && i.amb.includes("on:img") && !i.toast,
search: (i) => i.title.startsWith("Search"),
@@ -974,7 +1074,7 @@ const PHONE_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
"artist-cold": (i) => i.screen.includes("has-hero"),
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax,
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax && !i.a11yAnon && !i.a11yGhost && !i.a11yState,
"you-norm-off": (i) => i.norm === "off" && !i.toast,
"you-norm-on": (i) => i.norm === "on" && !i.toast,
"you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast,