The dashboard now says out loud what a filter just did

Cross-filtering is the one interaction whose whole point is that acting on ONE
control changes a number somewhere ELSE. Pick "Islam" in the left rail and 2,107
becomes 693 across the KPI strip, the map, the table and the charts. A sighted
user watches that happen. A screen-reader user heard nothing at all: this
element carried exactly one role="status" (the table's selection pill) and zero
aria-live regions. The filter worked; the feedback did not exist.

ONE polite live region in the shell, not one per panel. Six surfaces change on
every brush, and polite announcements QUEUE — six regions would make the user
wait through five repeats before reaching the next control. It is always
rendered and starts empty, because a live region inserted already holding its
text is routinely not announced; it is visually hidden rather than display:none,
which would take it out of the accessibility tree along with the pixels. It is
debounced at 700ms for the same reason the selection camera is debounced: typing
re-derives the row set per keystroke, and an un-debounced region would queue one
announcement per character. It stays silent on arrival and only ever reports a
change. It names the count, the total, the active facets and the search term,
formatted by the shell's own formatter so it can never read as a different
number from the KPI strip beside it.

ONE RESIZE HANDLE ANNOUNCED ITSELF IN ENGLISH in all 11 locales. Four of the five
read their title and aria-label through T(); the map/table one held raw string
literals. It survived review because each half looks correct on its own — the
ternary is right, the strings are right, only the missing T( is wrong. Both now
resolve through the catalogue. (The keys themselves are on
sr/dashboard-a11y-strings: aria.json held eleven keys and not one was a resize
key, so ALL five handles have been falling back to English. The T() call sites
were right the whole time.)

THE CHIP ✕ IS NOW BIG ENOUGH TO HIT. The live strip's remove button was a
~13x16px glyph — the smallest control in the product, and the only way out of a
narrowed board, where a miss costs the reader the whole view. The row height
cannot grow: three comment blocks above it record a measured layout-shift bug and
the Driver's "a lot more subtle" instruction that pinned it to 16px. So the HIT
AREA grows and the BOX does not, via an absolutely-positioned 24x24 ::after that
contributes nothing to the flex row's intrinsic size. Nothing moves by a pixel.
2.5.8 is a TARGET SIZE criterion, not a touch one, so it is unconditional. A
focus-visible ring too, since the chips live in an overflow-x strip where being
scrolled into view is not the same as being visible.

The kit's own DashboardFilterChips were spans with click delegation — not
focusable, no click from Enter or Space. They are buttons now, with an aria-label
naming the ACTION rather than the filter ("Religion: Islam" is a good name and a
useless action), type="button" so a partner's form is not submitted by a chip, a
focus ring and a 24px floor. The reset-all chip deliberately gets NO aria-label:
its visible words already are the action, and overriding them breaks voice
control.

Recording what this does NOT fix, because the measurement that prompted it named
the wrong file. DashboardFilterChips is exported from the kit and mounted by
NOBODY — round 1 of BUG-DUPLICATE-FILTER-BADGES removed it, and the strip on
screen belongs to SearchFilterHeader, whose remove button was already a real
<button type="button"> with a localized aria-label. So the keyboard half was a
latent defect closed before the next dashboard mounts it, not a live one. The
live defect in that same control was its size, above.

check:dashboard-filter-a11y guards all of it, on the kit component AND the strip
that actually ships. It walks the parsed template AST rather than grepping,
because grep cannot tell a bound :aria-label from a literal one, cannot tell a
button from a span named like one, and cannot see a live region hiding inside a
v-show'd cell. It also reads the built bundle, since source-correct plus
bundle-stale is a real repeat failure here. Fourteen mutants were run against it;
every one fails, naming the file and the reason. No browser — that is rega's.

STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid (the
composite bakes the shell in). Part D of the gate fails until you do, by design.
This commit is contained in:
Doxa Maps 2026-09-26 12:49:31 +00:00
parent f3fcce9386
commit c683d49716
5 changed files with 454 additions and 11 deletions

View file

@ -464,8 +464,30 @@ function onClearAll() { emit('clear-all') }
padding: 0 8px; font-size: 0.75rem; line-height: 1; white-space: nowrap;
height: var(--dx-sel-row-h, 16px); box-sizing: border-box;
}
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.05em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center; }
.sfh__chip-x { background: none; border: none; cursor: pointer; font-size: 1.05em; line-height: 1; color: inherit; padding: 0; display: inline-flex; align-items: center;
/* ★ A 24px TARGET THAT COSTS ZERO LAYOUT (WCAG 2.5.8).
MEASURED: the chip's box is pinned to --dx-sel-row-h (16px) and this button carries padding:0,
so the only way to remove a filter was a roughly 13x16px glyph. That is the smallest control in
the product, and it is the ONLY way out of a narrowed board — the one place where a miss costs
the user the whole view.
THE ROW HEIGHT IS NOT NEGOTIABLE and must not be touched here: the three comment blocks above
record a measured layout-shift bug (18px vs a 24.25px chipped row walking the map 180 -> 182.25px)
and the Driver's "a lot more subtle" instruction that set 16px. Growing the button would reopen
both.
So the HIT AREA grows and the BOX does not. An absolutely-positioned ::after centred on the
button claims 24x24 of pointer/touch surface while contributing nothing to the flex row's
intrinsic size — the chip stays 16px tall, the strip's fixed-height invariant holds, and
nothing moves by a pixel. position:relative scopes the overlay to this button.
2.5.8 is a TARGET SIZE criterion, not a touch criterion, so this is unconditional — never
behind @media (pointer: coarse). The overlay is transparent and inert to the eye. */
position: relative; }
.sfh__chip-x::after { content: ''; position: absolute; left: 50%; top: 50%;
width: 24px; height: 24px; transform: translate(-50%, -50%); }
.sfh__chip-x:hover { opacity: 0.7; }
/* The chips sit inside an overflow-x strip, so a focused ✕ must be visibly ringed rather than
merely scrolled into view. :focus-visible, not :focus — a mouse click focuses the button too, and
a ring that outlives every click reads as a state the chip does not have. */
.sfh__chip-x:focus-visible { outline: 2px solid var(--brand, #1a237e); outline-offset: 1px; border-radius: 2px; }
/* ★ THE LAST CHIP'S NEIGHBOUR, NOT A PINNED CONTROL
(card BUILD-CLEAR-ALL-SITS-BESIDE-THE-SELECTIONS-NOT-FAR-RIGHT — "right next to the selected
stuff", and "fancy Nancy", i.e. it should look like a light touch, not a button).

View file

@ -44,6 +44,7 @@
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
"check:dashboard-filter-a11y": "node plugin-designs/research-dashboard/scripts/check-dashboard-filter-a11y.mjs",
"check:dashboard-geocoder-provider": "node plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs",
"check:deck-click": "node internal/check-deck-click.mjs",
"check:dep-names": "node internal/check-dependency-names.mjs",
@ -279,6 +280,7 @@
"check:composites-mount": "every capability-4/5 composite's OWN setup() and mounted hooks run without throwing, in en and ar, headless (no browser): vite loads each composite as its bundle entry defines it (configureApp, and ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer. Exists because 08f89f55 shipped two composites calling getRuntimeLocale/detectLocale/RTL_LOCALES with no import: the build was green, setup threw a ReferenceError in every locale, and Big Picture and Simple Research rendered nothing. Proves setup runs; cannot prove the children render.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
"check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a <button type=\"button\"> with an aria-label naming the ACTION (a filter that can be set by keyboard must be clearable by keyboard), the shell carries exactly ONE always-rendered polite role=\"status\" region so a brush that changes a number far from the control is announced once rather than six times or not at all, and no resize handle's accessible name is a raw English literal. AST over the parsed templates plus the built bundle — no browser (rega's alone).",
"check:dashboard-geocoder-provider": "the research dashboard leaf at ?provider=maplibre makes ZERO requests to api.mapbox.com, and its search bars still suggest (built tree; browser gate). It was kept out of gates:post while the generated leaf hard-linked the Mapbox engine; the leaf now loads only the engine it is asked for.",
"check:dep-names": "every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",

View file

@ -22,7 +22,7 @@
* config.searchKeys drive which axes show + what the text search matches. The geocoder-based
* DashboardSearchHeader (DOXA-LIB-3) remains available in the kit but is not mounted here.
*/
import { computed, watch, inject, ref, unref, onMounted } from 'vue'
import { computed, watch, inject, ref, unref, onMounted, onBeforeUnmount } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
@ -133,7 +133,79 @@ const pageScroll = computed(() => unref(hostConfig)?.layout === 'page-scroll')
// The assembler: creates+provides the bus/data/config, computes the linked surface.
// Passed the MERGED config so a host-set `provider` reaches DashboardMapPanel via
// inject('dashboardConfig'). With no host config this is byte-for-byte the previous behaviour.
const { bus, data, kpis, exporter, responsive } = useDashboardShell(mergedConfig.value)
const { bus, data, kpis, exporter, responsive, filteredRows } = useDashboardShell(mergedConfig.value)
// ── ★ ANNOUNCE THE RESULT OF A FILTER (the dashboard's largest screen-reader gap) ─────────────
// THE DEFECT, measured 2026-09-26: this element contained exactly ONE role="status" (the table's
// selection pill) and ZERO aria-live regions. That is the specific way a cross-filtering dashboard
// fails a screen-reader user, and it is invisible to everyone else: the whole POINT of
// brushing-and-linking is that acting on ONE control changes a number somewhere ELSE. A sighted
// user picks "Islam" in the left rail and watches 2,107 become 693 in the KPI strip, the map and
// the table. A screen-reader user presses the same control, hears the control's own state change,
// and is told NOTHING about the 1,414 rows that just left the board. The filter worked; the
// feedback never existed.
//
// WHY IT LIVES IN THE SHELL AND NOT IN A PANEL. Six surfaces change on every brush (KPI strip,
// map, table, charts, chips, breadcrumb). A live region per panel would announce the same event
// up to six times, which is worse than silence — screen readers queue polite announcements, so the
// user would wait through five repetitions before reaching the next control. The shell is the one
// place that is above all six and mounted exactly once.
//
// POLITE, not assertive: this is a result, not an alert. `aria-atomic` so the whole sentence is
// re-read rather than the changed word alone ("693" announced by itself is not information).
//
// ★ THE NODE MUST EXIST BEFORE IT HAS TEXT. A live region that is INSERTED already containing its
// message is frequently not announced at all — the accessibility tree has to be observing the node
// when the text changes. So the element is always rendered and starts EMPTY; it is never v-if'd,
// never v-show'd, and never unmounted. (This is also why it is not inside any of the v-show'd
// region cells above.)
const liveMessage = ref('')
let liveTimer = null
let liveArmed = false
// Debounced, for the same reason useSelectionCamera debounces the camera: typing in the search box
// re-derives filteredRows on every keystroke, and an un-debounced polite region would queue one
// announcement PER CHARACTER and then read them all back in order. 700ms is past a normal typing
// cadence, so the user hears the settled answer once.
const LIVE_DEBOUNCE_MS = 700
function announce() {
const shown = filteredRows.value.length
const all = (data.rows.value || []).length
// fmt() is the shell's own formatter. NUMERALS ARE HELD — this is deliberately the same
// en-US grouping every visible surface already prints, so the announcement and the KPI strip
// can never read as two different numbers.
const unit = T('dashboard.groups', 'groups')
let msg = T('dashboard.results_live', '{count} of {total} {unit} shown')
.replace('{count}', fmt(shown)).replace('{total}', fmt(all)).replace('{unit}', unit)
// The active filters, named. "693 of 2,107 groups shown" on its own does not say WHY, and the
// chips that carry that answer are exactly what a screen-reader user cannot glance at.
const active = Object.keys(bus.filterSet)
if (active.length) {
const names = active.map((k) => mergedConfig.value.facets?.[k]?.label || k).join(', ')
msg += ' · ' + T('dashboard.filters_live', 'Filters: {list}').replace('{list}', names)
}
const q = (bus.searchTerm?.value || '').trim()
if (q) msg += ' · ' + T('dashboard.search_live', 'Search: {term}').replace('{term}', q)
liveMessage.value = msg
}
watch(
// Every input that can move the count, in one key. filteredRows is the truth (it already folds
// facets AND the tiered search), and the filter/search keys are included so that a change which
// happens to leave the count identical — swapping one 693-row facet for another — still speaks.
() => [filteredRows.value.length, JSON.stringify(bus.filterSet), bus.searchTerm?.value || ''].join('|'),
() => {
// ★ DO NOT SPEAK ON ARRIVAL. The first settled value is the page loading, not the user doing
// something, and an element that announces itself the moment it mounts is noise a screen-reader
// user cannot prevent — on an embed with several elements it is noise several times over. The
// region arms itself after the first data-driven settle, so it only ever reports a CHANGE.
if (!liveArmed) { liveArmed = true; return }
clearTimeout(liveTimer)
liveTimer = setTimeout(announce, LIVE_DEBOUNCE_MS)
}
)
// A pending announcement must not outlive the element. Unmounting a dashboard mid-debounce (the
// SPA swaps tabs, a composite is torn down) would otherwise fire announce() against a dead
// instance — the same unmount-race class the map panel already guards.
onBeforeUnmount(() => clearTimeout(liveTimer))
// ── OUTWARD FILTER SIGNAL ────────────────────────────────────────────────────────────────
// WHY THIS EXISTS (measured 2026-09-22). This component was a SEALED BOX: zero defineProps,
@ -652,6 +724,13 @@ useShadowStyles(`
.rd-shell.rd-narrow .rd-seg{ display:flex; }
.rd-seg button{ flex:1; border:0; background:var(--dx-elevated,#fff); border-top:1px solid var(--rd-line,#d1d4cd); font-size:12px; font-weight:700; color:var(--dx-muted,#6d766e); cursor:pointer; }
.rd-seg button.on{ color:var(--rd-ink, #3b463d); box-shadow:inset 0 -3px 0 var(--rd-accent,#e67e22); }
/* VISUALLY HIDDEN, NOT display:none. display:none (and visibility:hidden, and width/height:0 with
overflow visible) removes the node from the ACCESSIBILITY tree too, which would silence the one
region that exists to be heard. The clip-path + 1px box is the standard visually-hidden recipe:
the node keeps a real box, stays in the a11y tree, and paints nothing. white-space:nowrap stops
a long sentence from forcing a scrollbar on the shell while it is clipped. */
.rd-sr-live{ position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0;
overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
.rd-exportbar{ position:absolute; left:0; right:0; bottom:0; display:flex; align-items:center; gap:10px; padding:8px 14px;
background:var(--rd-ink, #3b463d); color:#fff; z-index:50; box-shadow:0 -2px 10px #0003; animation:rd-slideup .2s ease; }
.rd-exportbar .sp{ flex:1; } .rd-exportbar b{ font-size:13px; }
@ -752,11 +831,18 @@ onMounted(() => {
NEW .rd-rz-shell handle's job below. In page-scroll mode it still drives --rd-map-h (the
map's SHARE of --rd-shell-h, not the total — see the CSS note above .rd-shell.rd-page-scroll),
because --table-h is force-set to `auto` and no longer drives any layout — see the
PAGE-SCROLL AXIS SWITCH note in useDashboardLayout.js. -->
PAGE-SCROLL AXIS SWITCH note in useDashboardLayout.js.
★ THE ONE HANDLE WHOSE NAME NEVER REACHED THE CATALOGUE. Its four siblings above and
below read their title and aria-label through T(); these two were RAW ENGLISH literals,
so this separator announced itself in English in all 11 locales while the handles on
either side of it translated. It survived review because each half looks correct in
isolation — the ternary is right, the strings are right, and only the MISSING T( is
wrong. Two keys per handle, the same shape as the siblings: the *Hint is the tooltip,
the plain key is the accessible name. -->
<div v-show="!isNarrow && !tableCollapsed" class="rd-rz rd-rz-table" data-resize="table"
role="separator" aria-orientation="horizontal" tabindex="0"
:title="pageScroll ? 'Drag or use arrow keys to resize the map/table split' : 'Drag or use arrow keys to resize the table'"
:aria-label="pageScroll ? 'Resize map/table split' : 'Resize table height'"
:title="pageScroll ? T('aria.resizeMapTableSplitHint', 'Drag or use arrow keys to resize the map/table split') : T('aria.resizeTableHint', 'Drag or use arrow keys to resize the table')"
:aria-label="pageScroll ? T('aria.resizeMapTableSplit', 'Resize map/table split') : T('aria.resizeTable', 'Resize table height')"
:aria-valuenow="pageScroll ? mapH : (parseFloat(tableH) || 34)"
:aria-valuemin="pageScroll ? 220 : 12" :aria-valuemax="pageScroll ? 1400 : 70"
@pointerdown="startTableResize" @keydown="onResizeKeydown('table', $event)"></div>
@ -773,6 +859,13 @@ onMounted(() => {
:aria-label="T('aria.resizeDashboardHeight', 'Resize dashboard height')"
:aria-valuenow="shellH" aria-valuemin="400" aria-valuemax="2600"
@pointerdown="startShellResize" @keydown="onResizeKeydown('shell', $event)"></div>
<!-- ★ THE POLITE LIVE REGION (see the announce() block in the script). ALWAYS RENDERED and
starting EMPTY — no v-if, no v-show, never inside a collapsible cell: a live region that
is inserted already carrying its message is routinely not announced, because the
accessibility tree has to be watching the node when the text changes. It is visually
hidden rather than display:none, because display:none removes it from the accessibility
tree as well and would silence it for the very users it exists for. -->
<div class="rd-sr-live" data-live-region role="status" aria-live="polite" aria-atomic="true">{{ liveMessage }}</div>
<div class="rd-seg" data-segmented @click="setView">
<button v-for="s in SEG" :key="s.k" :class="{ on: mobileView === s.k }" data-view-btn :data-view="s.k">{{ s.l }}</button>
</div>

View file

@ -0,0 +1,285 @@
#!/usr/bin/env node
/**
* check-dashboard-filter-a11y.mjs — the dashboard's cross-filter must be OPERABLE and AUDIBLE.
*
* THE TWO DEFECTS THIS EXISTS TO PREVENT (both measured on main, 2026-09-26, both shipped).
*
* 1. A FILTER YOU CAN SET BUT CANNOT CLEAR WITHOUT A MOUSE. The filter chips were
* `<span data-chip>` with click delegation on the bar. A span is not focusable, carries no
* role, and the browser fires no click for it on Enter or Space. Every control that SETS a
* filter is keyboard-operable (the tree rail's rows, the dropdowns, the geocoder), so a
* keyboard user could walk into a narrowed board with no way out but reloading the page.
* That asymmetry — reachable in, unreachable out — is the defect, not the missing role.
*
* 2. A CROSS-FILTER THAT ANNOUNCES NOTHING. The whole point of brushing-and-linking is that
* acting on ONE control changes a number somewhere ELSE. The element contained exactly one
* role="status" (the table's selection pill) and ZERO aria-live regions, so a sighted user
* watched 2,107 become 693 across six surfaces while a screen-reader user was told nothing
* at all. The filter worked; the feedback did not exist.
*
* PLUS the localization half of the same class: a `role="separator"` resize handle whose
* aria-label is a RAW ENGLISH LITERAL announces itself in English in all 11 locales. Four of the
* five handles read theirs through T(); the map/table one did not, and it survived review because
* each half looks correct on its own — the ternary is right, the strings are right, and only the
* missing T( is wrong.
*
* ★ WHY THIS IS AN AST WALK AND NOT A GREP. grep cannot tell `:aria-label="T(...)"` (a bound
* expression) from `aria-label="T(...)"` (a literal that happens to contain the characters), cannot
* tell a chip that is a <button> from a <span> whose class is named like one, and cannot see that a
* live region sits inside a v-show'd cell. The template is parsed with @vue/compiler-sfc and the
* ELEMENT TREE is asserted, so every check is about the structure that ships.
*
* ★ AND WHY IT ALSO READS THE BUILT BUNDLE. Source-correct + bundle-stale is a real, repeated
* failure here (check-composite-interop carries the same part B for the same reason): a gate that
* only reads source will pass while the reader gets last week's element.
*
* NO BROWSER. Playwright is rega's alone (fleet policy). Nothing here launches one.
*
* NEGATIVE-TESTED 2026-09-26 — each of these makes it FAIL, naming the file and the reason:
* chips back to <span> -> A1 "chip is a <span>, not a <button>"
* type="button" removed -> A2
* :aria-label removed from the chip -> A3 (no accessible name)
* the live region given v-if="…" -> B3
* aria-live changed to "assertive" -> B1 (no polite region)
* the map/table aria-label put back to a raw -> C "aria-label is a raw literal"
* string literal
* a stale bundle (source fixed, not rebuilt) -> D
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from '@vue/compiler-sfc'
import { TREE_NAME } from '../../../internal/_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const CHIPS = join(ROOT, 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue')
const SHELL = join(ROOT, 'plugin-designs/research-dashboard/research-dashboard.vue')
// ★ THE CHIPS THE USER ACTUALLY TOUCHES ARE NOT IN DashboardFilterChips.vue.
// That component is exported from the kit's index.js and mounted by NOBODY — DashboardTopBar's
// header records why (round 1 of BUG-DUPLICATE-FILTER-BADGES removed it, and the second header's
// SearchFilterHeader has rendered the live strip ever since). A gate that only read the kit file
// would therefore be green about a screen that does not exist and blind to the one that does —
// the same "numeric probe for the wrong noun" that let the duplicate-badges bug survive ~50
// reports. Both are checked: the LIVE strip so a regression is caught, and the kit file so the
// defect is not lying in wait for the next dashboard that mounts it.
const LIVE_CHIPS = join(ROOT, 'library/components/search/SearchFilterHeader.vue')
const fails = []
const notes = []
const fail = (code, msg) => fails.push(`${code} ${msg}`)
// ── template AST helpers ─────────────────────────────────────────────────────────────────────
// @vue/compiler-sfc node types: 1 = ELEMENT, 2 = TEXT, 5 = INTERPOLATION.
// props entries: type 6 = plain ATTRIBUTE (name/value), type 7 = DIRECTIVE (v-bind/v-if/v-show/…).
function templateAst(file) {
const { descriptor, errors } = parse(readFileSync(file, 'utf8'), { filename: file })
if (errors && errors.length) throw new Error(`${file}: ${errors[0].message}`)
if (!descriptor.template) throw new Error(`${file}: no <template>`)
return descriptor.template.ast
}
function walk(node, visit) {
if (!node) return
if (node.type === 1) visit(node)
for (const c of node.children || []) walk(c, visit)
}
function elements(ast) { const out = []; walk(ast, (n) => out.push(n)); return out }
/** A plain attribute's value, or undefined when it is absent or bound. */
const attr = (el, name) => {
const p = (el.props || []).find((q) => q.type === 6 && q.name === name)
return p ? (p.value ? p.value.content : '') : undefined
}
/** A v-bind:<name> / :<name> directive's expression source, or undefined. */
const bound = (el, name) => {
const p = (el.props || []).find((q) => q.type === 7 && q.name === 'bind'
&& q.arg && q.arg.type === 4 && q.arg.content === name)
return p && p.exp ? p.exp.content : undefined
}
/** True when the element carries the named structural directive (v-if / v-else-if / v-show). */
const hasDirective = (el, name) => (el.props || []).some((q) => q.type === 7 && q.name === name)
/** True when the element renders any non-whitespace text or interpolation, at any depth. */
function hasText(el) {
let found = false
const rec = (n) => {
if (found || !n) return
if (n.type === 2 && String(n.content).trim()) found = true
if (n.type === 5) found = true
for (const c of n.children || []) rec(c)
}
for (const c of el.children || []) rec(c)
return found
}
const has = (el, name) => attr(el, name) !== undefined || bound(el, name) !== undefined
// ── A. THE CHIPS ARE OPERABLE BUTTONS ────────────────────────────────────────────────────────
{
const ast = templateAst(CHIPS)
const chips = elements(ast).filter((el) => has(el, 'data-chip'))
if (!chips.length) fail('A0', `${CHIPS}: no [data-chip] element found — the chip bar moved or was renamed; this gate is now blind.`)
for (const el of chips) {
const which = attr(el, 'data-chip') !== undefined ? `data-chip="${attr(el, 'data-chip')}"` : 'data-chip (bound)'
if (el.tag !== 'button') {
fail('A1', `DashboardFilterChips.vue: ${which} is a <${el.tag}>, not a <button> — not focusable, and Enter/Space fire no click, so a filter set by keyboard cannot be cleared by keyboard.`)
continue
}
if (attr(el, 'type') !== 'button') {
fail('A2', `DashboardFilterChips.vue: ${which} has no type="button" — inside a partner page's <form> it would default to submit and post the form on every chip press.`)
}
// Accessible name precedence: aria-label -> visible text. `title` is NOT one.
//
// ★ THE TWO CHIPS HAVE OPPOSITE REQUIREMENTS, and that is not an inconsistency.
// A REMOVABLE chip ([data-filter-chip]) reads "Religion: Islam". That is a perfectly good
// accessible NAME and a useless accessible ACTION: announced alone it tells the user what the
// filter is, never that pressing removes it. So it must carry an explicit aria-label naming
// the action. (This is the one case where "it already has visible text" is the wrong answer,
// which is why it is asserted rather than inferred.)
// The RESET-ALL chip reads "Reset all" — the words already ARE the action, so an aria-label
// would only override what the user can see and break voice control ("click Reset all").
const label = bound(el, 'aria-label') ?? attr(el, 'aria-label')
const removable = has(el, 'data-filter-chip')
if (removable && !label) {
fail('A3', `DashboardFilterChips.vue: ${which} is removable but has no aria-label — its visible text names the FILTER ("Religion: Islam"), not the ACTION, so it announces as a filter you cannot tell is a remove button. (title is a tooltip, not an accessible name.)`)
}
if (!removable && !label && !hasText(el)) {
fail('A3', `DashboardFilterChips.vue: ${which} has neither an aria-label nor visible text — it would be announced as a bare "button".`)
}
if (!removable && label && hasText(el)) {
fail('A4', `DashboardFilterChips.vue: ${which} has BOTH visible text and an aria-label — the label overrides the words the user can see, which breaks voice control.`)
}
}
notes.push(`kit chips (unmounted): ${chips.length} [data-chip] element(s), all <button type="button"> with an accessible name`)
}
// ── A-LIVE. THE STRIP THAT IS ACTUALLY ON SCREEN ─────────────────────────────────────────────
// SearchFilterHeader is library/ (rule 5B — studio lands changes to it). This gate only READS it,
// and it PASSES today: the ✕ is already a real <button type="button"> with a localized aria-label
// and "Clear All" is already a real button. That is worth pinning precisely BECAUSE it is already
// right — the failure mode here is a future refactor turning either back into a <span>, which no
// build error would catch.
{
const ast = templateAst(LIVE_CHIPS)
const els = elements(ast)
const x = els.filter((el) => (attr(el, 'class') || '').split(/\s+/).includes('sfh__chip-x'))
const clear = els.filter((el) => (attr(el, 'class') || '').split(/\s+/).includes('sfh__clear'))
if (!x.length) fail('A5', `SearchFilterHeader.vue: no .sfh__chip-x remove control found — the LIVE chip strip moved or was renamed; this gate is now blind to the only chips on screen.`)
for (const el of x) {
if (el.tag !== 'button') fail('A6', `SearchFilterHeader.vue: the chip ✕ is a <${el.tag}>, not a <button> — a filter that can be SET from the keyboard could not be CLEARED from the keyboard.`)
else if (attr(el, 'type') !== 'button') fail('A7', `SearchFilterHeader.vue: the chip ✕ has no type="button".`)
const label = bound(el, 'aria-label') ?? attr(el, 'aria-label')
if (!label) fail('A8', `SearchFilterHeader.vue: the chip ✕ has no aria-label — its only content is "×", which announces as nothing useful.`)
else if (attr(el, 'aria-label') !== undefined) fail('A9', `SearchFilterHeader.vue: the chip ✕ aria-label is a raw literal (${JSON.stringify(attr(el, 'aria-label'))}) — it announces in English in all 11 locales.`)
}
if (!clear.length) fail('A5', `SearchFilterHeader.vue: no .sfh__clear control found.`)
for (const el of clear) {
if (el.tag !== 'button') fail('A6', `SearchFilterHeader.vue: "Clear all" is a <${el.tag}>, not a <button>.`)
}
notes.push(`live chips (SearchFilterHeader): ✕ and "Clear all" are real <button type="button">, ✕ named from the catalogue`)
// ── THE ✕ IS BIG ENOUGH TO HIT (WCAG 2.5.8) ────────────────────────────────────────────────
// The chip's box is pinned to --dx-sel-row-h (16px) to hold a measured fixed-height invariant,
// and the ✕ carries padding:0 — so the only way OUT of a narrowed board was a ~13x16px glyph.
// The fix cannot be a taller button (that reopens the layout-shift bug the pin exists for), so
// it is an absolutely-positioned 24x24 ::after: pointer surface without intrinsic size.
// ASSERTED IN CSS because there is nothing else to read — no DOM, no browser. The failure this
// catches is a future author deleting the overlay as "an empty pseudo-element doing nothing".
{
const { descriptor } = parse(readFileSync(LIVE_CHIPS, 'utf8'), { filename: LIVE_CHIPS })
const css = (descriptor.styles || []).map((s) => s.content).join('\n')
const rule = /\.sfh__chip-x::after\s*\{([^}]*)\}/.exec(css)
if (!rule) {
fail('A10', `SearchFilterHeader.vue: no .sfh__chip-x::after rule — the chip ✕ is back to a ~13x16px target, and it is the ONLY way out of a narrowed board. (2.5.8 is a TARGET SIZE criterion: the floor applies at every width, never behind @media (pointer: coarse).)`)
} else {
const body = rule[1]
for (const dim of ['width', 'height']) {
const m = new RegExp(`(?:^|;|\\s)${dim}\\s*:\\s*(\\d+)px`).exec(body)
if (!m) fail('A11', `SearchFilterHeader.vue: .sfh__chip-x::after has no explicit ${dim} in px — the hit area is not measurable.`)
else if (Number(m[1]) < 24) fail('A11', `SearchFilterHeader.vue: .sfh__chip-x::after ${dim} is ${m[1]}px, under the 24px target-size floor.`)
}
if (!/position\s*:\s*absolute/.test(body)) {
fail('A12', `SearchFilterHeader.vue: .sfh__chip-x::after is not position:absolute — an in-flow 24px box would grow the chip past the pinned --dx-sel-row-h and reopen the layout shift that pin exists to prevent.`)
}
notes.push('live chip ✕: a 24x24 absolutely-positioned hit area, so the row height is untouched')
}
}
}
// ── B. THE SHELL HAS ONE ALWAYS-PRESENT POLITE LIVE REGION ───────────────────────────────────
const shellAst = templateAst(SHELL)
{
const all = elements(shellAst)
const polite = all.filter((el) => (attr(el, 'aria-live') || bound(el, 'aria-live')) === 'polite')
if (!polite.length) {
fail('B1', `research-dashboard.vue: no aria-live="polite" region. A cross-filter changes a number FAR from the control that changed it; with no live region a screen-reader user is told nothing when 2,107 becomes 693.`)
} else if (polite.length > 1) {
fail('B2', `research-dashboard.vue: ${polite.length} polite live regions — a single brush would be announced once per region, and polite announcements QUEUE, so the user waits through every repeat.`)
} else {
const el = polite[0]
for (const d of ['if', 'else-if', 'show']) {
if (hasDirective(el, d)) {
fail('B3', `research-dashboard.vue: the live region carries v-${d}. A region inserted already holding its text is routinely NOT announced — the accessibility tree has to be observing the node when the text changes. It must always be rendered and start empty.`)
}
}
if (attr(el, 'role') !== 'status') fail('B4', `research-dashboard.vue: the live region has no role="status".`)
if ((attr(el, 'aria-atomic') || bound(el, 'aria-atomic')) !== 'true') {
fail('B5', `research-dashboard.vue: the live region is not aria-atomic="true" — only the changed word is re-read, and "693" announced alone is not information.`)
}
if (!hasText(el)) fail('B6', `research-dashboard.vue: the live region renders no interpolation — it can never say anything.`)
notes.push('live region: one polite role="status" aria-atomic region, always rendered')
}
}
// ── C. EVERY RESIZE HANDLE'S NAME COMES FROM THE CATALOGUE ───────────────────────────────────
{
const handles = elements(shellAst).filter((el) => has(el, 'data-resize'))
if (!handles.length) fail('C0', `research-dashboard.vue: no [data-resize] handle found — this gate is now blind.`)
for (const el of handles) {
const which = `[data-resize="${attr(el, 'data-resize') ?? '?'}"]`
for (const name of ['aria-label', 'title']) {
const lit = attr(el, name)
const exp = bound(el, name)
if (lit !== undefined) {
fail('C1', `research-dashboard.vue: ${which} ${name} is a raw literal (${JSON.stringify(lit)}) — it announces in English in all 11 locales.`)
} else if (exp === undefined) {
fail('C2', `research-dashboard.vue: ${which} has no ${name}. A role="separator" with no accessible name is announced as an unnamed widget.`)
} else if (!/\bT\(/.test(exp)) {
fail('C3', `research-dashboard.vue: ${which} ${name} is bound but never reaches the catalogue (no T( in ${JSON.stringify(exp)}) — it announces in English in all 11 locales.`)
}
}
}
notes.push(`resize handles: ${handles.length}, every aria-label and title read through T()`)
}
// ── D. THE SHIPPED BUNDLE CARRIES ALL OF IT ──────────────────────────────────────────────────
// Source-correct and bundle-stale is a real, repeated failure here. SKIP (not pass) when nothing
// is built, so a clean checkout is not failed for a tree it does not have.
{
const bundle = join(ROOT, 'public', TREE_NAME, 'research-dashboard', 'research-dashboard.js')
if (!existsSync(bundle)) {
notes.push(`built bundle: SKIPPED — ${bundle.replace(ROOT + '/', '')} does not exist (nothing built)`)
} else {
const js = readFileSync(bundle, 'utf8')
// ONLY markers that are actually MOUNTED. `data-filter-chip` and `aria.removeFilter` belong to
// the unmounted kit component and are correctly tree-shaken out of this bundle — requiring them
// here would be a gate demanding that dead code ship, which is how a check teaches the next
// author to mount something the Driver asked to have removed.
const need = [
['aria-live', 'the polite live region'],
['data-live-region', 'the live region marker'],
['aria.resizeMapTableSplit', 'the map/table handle catalogue key'],
['sfh__chip-x', 'the live chip remove button'],
]
for (const [lit, what] of need) {
if (!js.includes(lit)) fail('D', `built research-dashboard.js is missing ${JSON.stringify(lit)} (${what}) — the source is fixed but the SHIPPED element is not. Run: BUNDLE=research-dashboard bun x vite build && bun run tree && bun run fix:sri`)
}
if (!fails.some((f) => f.startsWith('D'))) notes.push('built bundle: carries the live region, the live chip button and the map/table catalogue key')
}
}
// ── report ───────────────────────────────────────────────────────────────────────────────────
for (const n of notes) console.log(' · ' + n)
if (fails.length) {
console.error('\nDASHBOARD-FILTER-A11Y: FAIL')
for (const f of fails) console.error(' ' + f)
process.exit(1)
}
console.log('DASHBOARD-FILTER-A11Y: PASS — the chips are keyboard-operable and named, the shell announces every brush once, and no handle name is raw English.')

View file

@ -43,6 +43,28 @@ function onClick(e) {
if (k === '__all__') bus.clearFilters(); else bus.setFilter(k, null)
}
// ── ★ THE CHIP IS A BUTTON, AND THAT IS THE WHOLE KEYBOARD FIX ─────────────────────────────────
// These were <span data-chip> with click delegation on the bar. A span is not focusable, has no
// role, and fires no click from Enter or Space — so a filter that could be SET from the keyboard
// (the tree rail's rows and the dropdowns are both operable) could NOT BE CLEARED from the
// keyboard. That asymmetry is the defect: a keyboard user could walk themselves into a filtered
// view with no way out except reloading the page.
//
// A <button type="button"> fixes it with NO new handler, because the browser already synthesises a
// click for Enter and Space on a button. The existing delegation (closest('[data-chip]')) keeps
// working byte-for-byte — which is the point: adding a @keydown here would have created a SECOND
// activation path that can disagree with the click path, and the bar's one-listener design is
// deliberate (it is defineCustomElement-safe).
//
// `type="button"` is not decoration. A bare <button> inside a form defaults to type="submit"; a
// partner page that embeds this element inside its own form would have had every chip submit it.
//
// THE ACCESSIBLE NAME must say what the press DOES, not what the chip shows. The visible text is
// "Religion: Islam"; announced alone that is a label, not an action, so a screen-reader user hears
// the filter but not that pressing removes it. `title` is NOT an accessible name (this repo's
// check:a11y-button-names exists for exactly that confusion), so the name is an aria-label.
const removeLabel = (c) => T('aria.removeFilter', 'Remove filter {name}').replace('{name}', c.facet + ': ' + c.text)
useShadowStyles(`
/* BOUNDED so the chips never slide under the centred search bar (BUG-SELECTION-CHIPS-UNDER-SEARCHBAR).
MEASURED live at 1440x900 before the fix: search x 540..900, first chip x 482..718 — ~178px of
@ -67,9 +89,24 @@ useShadowStyles(`
.rd-chips{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; min-width:0;
overflow-x:auto; overflow-y:hidden; scrollbar-width:none; }
.rd-chips::-webkit-scrollbar{ height:0; width:0; }
/* A <button> does NOT inherit the host's font, resets margin to the UA's, and paints a UA
background/appearance — so every one of those is restated here. Without font-family:inherit the
chips would silently drop to the UA default face while every other chip-like control in the
dashboard kept the app font: the keyboard fix would have arrived as a typography regression.
text-align + margin:0 cover the other two UA defaults a span never had. */
.rd-chip{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:3px 9px; border-radius:999px;
background:#fff; color:#1a1a2e; border:1px solid #e67e22; cursor:pointer; line-height:1.2;
font-family:inherit; margin:0; text-align:left; -webkit-appearance:none; appearance:none;
/* WCAG 2.5.8 is a TARGET SIZE criterion, not a touch criterion — so the 24px floor belongs at
EVERY width, never behind @media (pointer:coarse). Measured before: 11px text x 1.2 + 6px
padding + 2px border = ~21px. The bar is 52px and the chips are centred in it, so the 3px
costs no layout; the font size is untouched. */
min-height:24px;
flex:0 0 auto; max-width:200px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* :focus-visible, not :focus — a MOUSE click on a button focuses it, and a permanent ring after
every click would read as a selection state the chip does not have. The offset keeps the ring
off the 1px orange border so the two do not merge into one muddy edge. */
.rd-chip:focus-visible{ outline:2px solid #1a237e; outline-offset:2px; }
/* the label span is what actually truncates; the facet prefix and the x stay readable */
.rd-chip .rd-chip-val{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.rd-chip .fc{ opacity:.55; font-weight:500; flex:0 0 auto; } .rd-chip i{ font-style:normal; opacity:.6; font-size:12px; flex:0 0 auto; }
@ -84,14 +121,18 @@ useShadowStyles(`
Language label (some run 40+ chars) stays readable on hover rather than being lost to the
ellipsis. The value lives in its own span because that is the only part that should shrink:
the facet prefix and the ✕ affordance must stay legible at every width. -->
<span v-for="c in chips" :key="c.k" class="rd-chip" :data-chip="c.k" data-filter-chip
:title="c.facet + ': ' + c.text">
<span class="fc">{{ c.facet }}:</span><span class="rd-chip-val">{{ c.text }}</span> <i>✕</i>
</span>
<button v-for="c in chips" :key="c.k" type="button" class="rd-chip" :data-chip="c.k" data-filter-chip
:title="c.facet + ': ' + c.text" :aria-label="removeLabel(c)">
<span class="fc">{{ c.facet }}:</span><span class="rd-chip-val">{{ c.text }}</span> <i aria-hidden="true">✕</i>
</button>
<!-- ★ REUSES controls.reset_all rather than adding a second key. This chip clears every
filter, which is the same action that button names, and two keys for one action is how
a control ends up saying two different things in two languages. The English shifts from
"Clear all" to "Reset all" — a deliberate consequence of having one name for one thing. -->
<span class="rd-chip rd-clear" data-chip="__all__">{{ T('controls.reset_all', 'Reset all') }}</span>
<!-- No aria-label: this one has VISIBLE TEXT, and visible text IS the accessible name. Adding
an aria-label would OVERRIDE it, which is how a control ends up announced as something
other than what it reads as — and it would break voice control, where the user says the
words they can see. -->
<button type="button" class="rd-chip rd-clear" data-chip="__all__">{{ T('controls.reset_all', 'Reset all') }}</button>
</div>
</template>