Skip to content

fix(a11y): keyboard parity, table semantics and reduced-motion — gates 32, 43 and 45 to green - #150

Merged
rubenvdlinde merged 3 commits into
developmentfrom
fix/a11y-gates-0808
Aug 9, 2026
Merged

fix(a11y): keyboard parity, table semantics and reduced-motion — gates 32, 43 and 45 to green#150
rubenvdlinde merged 3 commits into
developmentfrom
fix/a11y-gates-0808

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

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. @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 <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 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

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>, 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 into NcButton/NcSelect internals.

Measurement (full-tree, not diff-scoped)

hydra-gates @main over the whole tree, ajv provisioned into the gates package exactly as quality.yml does:

failing gates
origin/development (c70f7b8d9) 15
this branch 12

Gates 32, 43 and 45 green. No other gate's verdict changed, by name.

Suite and lint

  • vitest: 140 files / 1366 tests before → 140 / 1369 after, all passing.
  • eslint: 0 errors, 0 warnings. Adding scope="col" made several <th> multi-attribute, which vue/singleline-html-element-content-newline reformats; run through lint-fix so this branch introduces no new warnings (development has zero).

Can-fail proofs — all four run

mutation result
remove @focusin from the row ❌ "focusing a field inside a row emits select(index)" — 1 failed | 18 passed
role="group"role="button" ❌ "expected 'button' to be 'group'" — 1 failed | 18 passed
drop scope="col" from the sample header ❌ "expected undefined to be 'col'" — 1 failed | 6 passed
sampleBody slice(1)slice(0) (header duplicated as data) ❌ "expected … to have a length of 2 but got 3" — 1 failed | 6 passed

Worth recording: the first attempt at the role mutation 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 a tabindex assertion 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 @param comments in <script> blocks:

src/dialogs/IconUploadSection.vue:193   * @param {Event} e - The `<img>` `error` event ...
src/dialogs/IconUploadSection.vue:206   * @param {Event} e - The `<img>` `error` event ...
src/components/ApplicationCard.vue:236  * @param {Event} e - The `<img>` `error` event ...

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: two ok: and one FLAGGED: [<img>] per site. The gate flattens the whole .vue file with tr '\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.

…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.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ ee89e14

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.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ f1d6318

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.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Not self-merged. Hydra Gates is RED here and PASSES on development's siblings (#147, #149), so the merge rule's "strict subset by name" is not satisfied — this needs a human read.

Failing set on this PR: E2E Tests (Playwright), Hydra Gates, Quality Report.
Failing set on development (run 31181334654): E2E Tests (Playwright), Hydra Gates, Quality Report.

Equal, not a strict subset. Flagging rather than merging.

What the Hydra Gates job actually found

[gate-31] img-alt:                  FAIL — 1
[gate-34] window-confirm:           FAIL — 1
[gate-39] button-name:              FAIL — 1
[gate-40] form-label-association:   FAIL — 5
[gate-46] spec-anchor-existence:    FAIL — 2
5 gate(s) failed

All 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 origin/development (c70f7b8d9) already is — no diff scoping at all:

gate full-tree on development in this PR's diff scope
gate-31 img-alt 3 1
gate-34 window-confirm 7 1
gate-39 button-name 22 1
gate-40 form-label-association 54 5
gate-46 spec-anchor-existence 102 2

Every diff-scoped finding is a strict subset of the full-tree finding set for the same gate. Nothing new appears.

Per-gate notes:

  • gate-31 (1) — a false positive, evidenced in the PR description: the literal <img> inside a JSDoc @param in ApplicationCard.vue. Filed gate-31 img-alt flattens the whole .vue file, so an <img> written inside a JSDoc comment counts as an image .github#235.
  • gate-34 (1) — the pre-existing window.prompt in ConnectorFieldMapper.vue:199, present before this PR; this PR only added scope="col" to that file's table.
  • gate-39 (1) — a pre-existing icon-only <button> with :title but no aria-label, one of the 22 already on development.
  • gate-40 (5)PageListEditor's inputs, 5 of the 54 already on development. This gate has a measured 58% false-positive rate fleet-wide and its remediation can override a visible label, so it is not being burned down blind.
  • gate-46 (2) — the two @spec tags on the new computed properties copy the same target the file already carries ten times: openspec/changes/openbuild-data-import-wizard/tasks.md#2.1. That change was archived to openspec/changes/archive/2026-07-07-openbuild-data-import-wizard/, so every anchor of that shape in the file dangles. This is gate-46 vs the phpcs SpecTagSniff: the sniff tells you to point @spec at a change dir, gate-46 fails you for it .github#228 — the repo's own blocking phpcs sniff instructs the openspec/changes/{name}/tasks.md#task-N form, which dangles the moment the change is archived. Fixing the findings without fixing the sniff regenerates them, so it was deliberately not done. Note the full-tree count is unchanged (102 → 102): the two new tags are duplicates of an existing distinct target, not a new one.

What this PR is measured to have done

Full-tree, no diff scoping, ajv provisioned into the gates package as quality.yml does:

  • origin/development: 15 failing gates
  • this branch: 12 — gates 32, 43 and 45 green, no other verdict changed.

Frontend suite 140 files / 1369 tests passing (1366 before), eslint 0 errors 0 warnings, test:l10n OK after 721b67b72.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 36b26af

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.

@rubenvdlinde
rubenvdlinde merged commit 13e40f6 into development Aug 9, 2026
29 of 32 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/a11y-gates-0808 branch August 14, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants