fix(a11y): keyboard parity, table semantics and reduced-motion — gates 32, 43 and 45 to green - #150
Conversation
…s 32, 43 and 45 to green
Three mechanical accessibility gates, 13 findings, all read and confirmed real
before touching anything.
gate-32 semantic-controls (2) — WCAG 2.2 AA 2.1.1 Keyboard
PageListEditor's page row was selectable by CLICK ONLY. Every field inside it
carries `@click.stop`, so a keyboard user tabbing into a row's inputs was
editing a page that had never been selected. `@focusin` is the substantive
repair: focus reaching any descendant now selects the row, the same outcome
the mouse always had.
The row gets `role="group"` + `aria-label`, NOT `role="button"`. A button's
children are presentational, so `role="button"` on a container full of inputs
would hide those inputs from assistive technology — it satisfies the gate and
makes the page less accessible. `tabindex="-1"` makes the row programmatically
focusable without inserting a second tab stop ahead of its own fields.
WalkthroughDesigner's tour <li> has no nested interactive content, so there
`role="button"` + Enter/Space handlers IS right, plus `aria-current` so the
open tour is announced rather than only coloured.
gate-43 table-headers (8) — WCAG 2.2 AA 1.3.1 Info and Relationships
Seven tables carried `<th>` with no `scope`. The eighth, ImportDataWizard's
CSV sample, had no `<th>` at all: `sampleRows` is populated only by
readCsvSample(), and a CSV's first line IS its header — it was rendering as
ordinary `<td>` data. Promoting row 0 to a `<thead>` of `<th scope="col">` is
both the a11y fix and a truer rendering of the file.
Three action columns had a bare `<th />`. They are still column headers, so
they keep `scope="col"` and gain an sr-only name rather than being downgraded
to `<td>` — the downgrade would have satisfied the gate by removing the
header.
gate-45 prefers-reduced-motion (3) — WCAG 2.2 AA 2.3.3
Three `transition:` declarations with no reduced-motion fallback. Each fix is
scoped to the component's own selector, never a blanket
`* { transition: none }`, so it cannot reach into NcButton/NcSelect internals.
Measured, full-tree (hydra-gates @main over the entire tree, not diff-scoped,
with ajv provisioned into the gates package exactly as quality.yml does):
`development` fails 15 gates; with this change, 12. Gates 32, 43 and 45 are
green; no other gate's verdict changed.
Frontend suite: 140 files / 1366 tests before, 140 / 1369 after — all passing.
eslint: 0 errors, 0 warnings (the added `scope="col"` made several `<th>`
multi-attribute, which the `vue/singleline-html-element-content-newline` rule
reformats; run through `lint-fix` so the branch introduces no new warnings —
`development` has zero).
Can-fail proofs, all four run:
- remove `@focusin` from the row -> "focusing a field inside a row emits
select(index)" FAILS (1 failed | 18 passed).
- `role="group"` -> `role="button"` -> "expected 'button' to be 'group'"
(1 failed | 18 passed). NB the first attempt at this mutation edited the
word inside the explanatory COMMENT and the suite stayed green — a reminder
that a string match is not a code match, in both directions.
- drop `scope="col"` from the sample header -> "expected undefined to be
'col'" (1 failed | 6 passed).
- `sampleBody` slice(1) -> slice(0), i.e. the header duplicated as data ->
"expected ... to have a length of 2 but got 3" (1 failed | 6 passed).
The new tests assert on the CELLS and the EMITTED EVENT, never on the `<thead>`
wrapper or the presence of an attribute: an empty `<thead>` would satisfy a
container assertion while announcing nothing, and a `tabindex` assertion would
pass over a handler that does nothing.
NOT fixed here, deliberately: gate-31 img-alt's 3 findings are false positives.
All three are the literal string `<img>` inside JSDoc `@param` comments in
`<script>` blocks; every real `<img>` in those files already carries `:alt`,
which the gate's own regex accepts. Reproduced with the gate's own matcher.
Reported upstream rather than "fixed" by rewording documentation.
`Frontend Check (test:l10n)` went RED on this branch and nowhere else:
l10n-check: FAIL — 1 translation key(s) used in source but MISSING
from l10n/en.json:
• "Page {position}" src/components/page-editor/PageListEditor.vue:66
The row's `aria-label` introduced a new translatable literal. `test:l10n` is
exactly the check that is supposed to notice, and it did — this is the gate
working, not noise. Registered in en.json (source === English) and translated in
nl.json rather than left English-only, since the aria-label is what a Dutch
screen-reader user hears.
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-08 11:18 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-08 11:56 UTC
Download the full PDF report from the workflow artifacts.
|
Not self-merged. Failing set on this PR: Equal, not a strict subset. Flagging rather than merging. What the Hydra Gates job actually foundAll five are pre-existing debt in the files this PR touches, pulled into scope by ADR-020 diff scoping — not defects introduced here. Proved against the base tree with the files forced into scope, which is what the full-tree run at
Every diff-scoped finding is a strict subset of the full-tree finding set for the same gate. Nothing new appears. Per-gate notes:
What this PR is measured to have doneFull-tree, no diff scoping,
Frontend suite 140 files / 1369 tests passing (1366 before), eslint 0 errors 0 warnings, |
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| check-gitignore | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 14:22 UTC
Download the full PDF report from the workflow artifacts.
Three mechanical accessibility gates, 13 findings, every one read and confirmed real before anything was touched.
gate-32 semantic-controls (2) — WCAG 2.2 AA 2.1.1 Keyboard
PageListEditor's page row was selectable by click only. Every field inside it carries@click.stop, so a keyboard user tabbing into a row's inputs was editing a page that had never been selected.@focusinis the substantive repair — focus reaching any descendant now selects the row, the same outcome the mouse always had.The row gets
role="group"+aria-label, notrole="button". A button's children are presentational, sorole="button"on a container full of<input>s would hide those inputs from assistive technology — it satisfies the gate and makes the page less accessible.tabindex="-1"makes the row programmatically focusable without inserting a second tab stop ahead of its own fields.WalkthroughDesigner's tour<li>has no nested interactive content, so thererole="button"+ Enter/Space handlers is right, plusaria-currentso the open tour is announced rather than only coloured.gate-43 table-headers (8) — WCAG 2.2 AA 1.3.1
Seven tables carried
<th>with noscope. The eighth —ImportDataWizard's CSV sample — had no<th>at all:sampleRowsis populated only byreadCsvSample(), and a CSV's first line is its header; it was rendering as ordinary<td>data. Promoting row 0 to a<thead>of<th scope="col">is both the a11y fix and a truer rendering of the file.Three action columns had a bare
<th />. They are still column headers, so they keepscope="col"and gain an sr-only name — rather than being downgraded to<td>, which would have satisfied the gate by removing the header.gate-45 prefers-reduced-motion (3) — WCAG 2.2 AA 2.3.3
Three
transition:declarations with no reduced-motion fallback. Each fix is scoped to the component's own selector, never a blanket* { transition: none }, so it cannot reach intoNcButton/NcSelectinternals.Measurement (full-tree, not diff-scoped)
hydra-gates
@mainover the whole tree,ajvprovisioned into the gates package exactly asquality.ymldoes:origin/development(c70f7b8d9)Gates 32, 43 and 45 green. No other gate's verdict changed, by name.
Suite and lint
scope="col"made several<th>multi-attribute, whichvue/singleline-html-element-content-newlinereformats; run throughlint-fixso this branch introduces no new warnings (developmenthas zero).Can-fail proofs — all four run
@focusinfrom the rowrole="group"→role="button"scope="col"from the sample headersampleBodyslice(1)→slice(0)(header duplicated as data)Worth recording: the first attempt at the
rolemutation edited the word inside the explanatory comment and the suite stayed green. A string match is not a code match — in both directions. The mutation was redone against the actual attribute line.The new tests assert on the cells and the emitted event, never on the
<thead>wrapper or on the mere presence of an attribute: an empty<thead>would satisfy a container assertion while announcing nothing, and atabindexassertion would pass over a handler that does nothing.Deliberately NOT fixed here
gate-31 img-alt's 3 findings are false positives. All three are the literal string
<img>inside JSDoc@paramcomments in<script>blocks:Every real
<img>in those files already carries:alt, which the gate's own regex explicitly accepts ((^|[[:space:]])(:?alt|v-bind:alt|alt-text)=). Reproduced by running the gate's own matcher over the files: twook:and oneFLAGGED: [<img>]per site. The gate flattens the whole.vuefile withtr '\n' ' 'and never strips<script>or comments.Rewording the documentation to dodge the grep would be comment-satisfaction, so it was not done. Reported upstream instead.