fix(a11y): gate-32/34/39/43/45/55 — frontend accessibility fixes - #775
Conversation
…ick targets Non-semantic elements (div/li/a) with @click but no role/tabindex/keyboard handler are unreachable and inoperable for keyboard and screen-reader users (WCAG 2.1.1 / 4.1.2). Adds role="button" tabindex="0" plus real @keydown.enter / @keydown.space handlers that call the same action as the click handler — not decorative attributes. Modal/dialog backdrop overlays (click-outside-to-close) get the same treatment so the dismiss action has a keyboard equivalent. The six "View all" widget footer links move from a bare <a @click.prevent> to a real <a :href> pointing at the actual destination URL (computed via generateUrl), which is both a genuinely better fix (native middle-click / open-in-new-tab support) and clears the gate since a real href exempts an <a> from the semantic-controls check. TaskCreateDialog.vue additionally gets an aria-label on its icon-only close button (gate-39 button-name, same file). gate-32: 40 findings -> 0.
…ttons
Icon-only NcButton/button elements with no aria-label/title/text content
announce as just "button" to screen readers (WCAG 2.2 AA SC 4.1.2). Adds a
translated aria-label naming the actual action ("Edit {name}", "Delete
{name}", "Remove guard", "Close step configuration", etc.) rather than a
generic label, using the row's own name field for specificity where one
is available.
gate-39: 24 findings -> 0.
…onfirmDialog Native window.confirm() calls break Nextcloud theming and are inaccessible to assistive tech (they bypass the app's dialog stack entirely). Replaces all three call sites with @conduction/nextcloud-vue's shared CnConfirmDialog (NcDialog-wrapped, its own file in the design-system package — importing it does not trigger gate-13 modal-isolation since no <NcModal>/<NcDialog> tag is written inline in these components). Each site now opens the dialog on the original trigger, performs the delete in a `@confirm` handler, and reports success/failure back via setResult() instead of a bare console.error / silent catch. These three files also carry their gate-43 table-headers (<th scope=>) and, for DeelzaakList.vue, gate-45 prefers-reduced-motion fixes — committed together since they land in the same file. gate-34: 3 findings -> 0.
<th> without a scope declaration leaves screen readers unable to associate data cells with their column/row header (WCAG 2.2 AA SC 1.3.1). Adds scope="col" to header-row <th> cells and scope="row" to the row-header <th> in LhsMatrixAdmin.vue's severity/behavior matrix table. ComplaintAnalyticsDashboard.vue's "By category" table had no <th> at all (rule=table-without-th) — adds a real <thead> naming the two actual columns (Category, Count) instead of a header-less table. Self-closed spacer/action <th /> columns (row-actions menus, drag handles) are left alone — they carry no accessible name so scope= on them is inert, per the gate's own exemption for headers with no name. gate-43: 27 findings -> 0.
<style> blocks with a transition/animation and no @media (prefers-reduced-motion: reduce) override force motion on users who have asked the OS to minimise it (WCAG 2.2 SC 2.3.3). Adds a media query per affected selector that sets transition/animation: none — a real override, not an empty block satisfying the gate's text match. Skeleton-loading shimmer keyframe animations and hover/click transitions both get the treatment. The remaining ~17 files flagged by this gate had their reduced-motion fix land in an earlier commit because they were also touched for gate-32/39/43 in the same file. gate-45: 25 findings -> 0.
… icon AiOversightDetail's data widget declared icon "RobotOutline", which is not in the shared icon registry (nextcloud-vue CnWidgetGrid/widgetIcons.js) and renders the '?' fallback per ADR-062 rule 8. Swaps it for "ShieldCheckOutline" — already in the registry and semantically fitting for an AI-oversight / human-review audit entry (EU AI Act Art. 14 evidence). gate-55: 1 finding -> 0. The gate-16 spec-coverage regression this branch introduced (8 new frontend methods/computed properties added while fixing gate-34/32 lacked @SPEC tags) was fixed in the earlier gate-32/gate-34 commits by giving each new method the same @SPEC tag as its sibling methods in the same file.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 15:30 UTC
Download the full PDF report from the workflow artifacts.
The accessible names and the CnConfirmDialog copy added for gate-39/gate-34 were new source strings, and neither l10n file had them. That failed two CI jobs the gate suite does not see — 'l10n coverage (en.json)' and 'quality / Frontend Check (test:l10n)' — and would have shipped raw English keys to a Dutch-locale user. en.json extracted with the repo's own 'test:l10n:write'; nl.json translated by hand. 'Remove guard' is 'Voorwaarde verwijderen' rather than a literal 'bewaker' — in the transition editor a guard is a precondition on the transition, not a person. node tests/l10n/check-l10n.js now reports both halves OK; before this commit it reported 14 keys missing from en.json and then 4 missing Dutch translations.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 15:42 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 16:14 UTC
Download the full PDF report from the workflow artifacts.
…l spec All 36 tags pointed at openspec/changes/retrofit-2026-05-24-signalering-widgets/tasks.md, a change directory that was archived to openspec/changes/archive/2026-05-31-retrofit-2026-05-24-signalering-widgets. The target has not existed for months, which is why gate-46 reports them as unresolved. Retargeted at openspec/specs/signalering-widgets/spec.md — the canonical home, which exists — per the rule that a @SPEC tag names a spec, never a change dir. 30 of the 36 predate this branch; the a11y pass added 6 more by copying the surrounding docblock. Diff-scoping means touching these files makes all of them this PR's to answer for, and CI reported exactly that: 10 unresolved findings from 1 distinct target. Measured, not assumed: after this commit no @SPEC target under src/ named signalering-widgets is missing from disk.⚠️ NOT fixed here, and not this PR's: 48 OTHER @SPEC targets under src/ are also missing — mostly archived retrofit-2026-05-* change dirs, plus two that name a canonical spec which was never written (openspec/specs/process-mining-bottlenecks/spec.md, openspec/specs/realtime-updates-ui/spec.md). They are diff-scoped out today and will surface on the next PR that touches each file. That is a separate sweep, and the two missing canonical specs need writing, not retargeting.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ❌ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| build | ❌ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ❌ | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 19:57 UTC
Download the full PDF report from the workflow artifacts.
PR #776 added "x-external-register" next to three cross-register $refs as part of a gate-54 fix. Measured, it is inert in both directions: - OpenRegister does not read it: no occurrence of x-external-register anywhere under openregister/lib/. - gate-54 does not read it either: check_relation_dialect.py resolves a $ref against _global_schema_keys(), the union of schema KEYS and declared SLUGS across *register*.json and register.d/*.json in the same settings dir. There is no external-register branch. Proof it changed nothing: gate-54 reports the same 2 findings with and without the key. The 'decision' $ref in that commit was a REAL fix and is untouched — "Decision" -> "decision" resolves against decidesk's declared slug. Only the no-op key is removed; an unread key introduced under a 'fix(relations)' subject reads like a mechanism and is not one. The remaining 2 findings STAY RED, honestly: caseType.productsOrServices -> 'product' (Pipelinq's product register) and case.vergunningaanvraagRef -> 'vergunningaanvraag' (the DSO register) both name schemas owned by another app. Neither is in procest's 85-schema register set, so the gate cannot resolve them and has no dialect that would let it. Both properties are live — 6 and 7 code references respectively — so deleting them would break working relations, and adding foreign schemas to procest's own register would misstate ownership. Each property's description already documents the owning register. This needs a cross-register $ref dialect in the gate, not a change here.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 20:09 UTC
Download the full PDF report from the workflow artifacts.
…laced Found while verifying the gate-53 rationale merged in #776, which states that the standalone Besluitvorming nav (Voorstellen / Advice / Agenda) was 'replaced with a BesluitvormingLeafTab sidebar tab on CaseDetail' and that the replacement is 'live, wired, and menu-reachable'. The first half was true; the second was not. Measured: - src/components/tabs/BesluitvormingLeafTab.vue exists. - src/registry.js:412 registers it, in the same shape as CaseNotesTab. - CaseDetail.config.sidebar.tabs listed audit, version-history, notes, sharing, email — and nothing else. So the component was resolvable by name and rendered by nobody. The consume-decidesk-besluitvorming-leaf change removed three menu entries and registered a tab it never placed: users lost the nav and gained no replacement. Being in the registry is availability, not placement — the manifest decides what renders, and it did not mention this tab. The component's own header says it is meant to be surfaced 'through the path', which is exactly the wiring added here, and it already handles decidesk being absent by rendering a quiet unavailable notice rather than a broken tab. Icon 'Gavel' is registered in src/icons.js, so gate-60 stays green (28 manifests, 0 failures). This does NOT move gate-53: still the same 8 findings, because that gate walks the static .menu tree and a per-object sidebar tab is not in it. The point is the lost surface, not the gate — the gate is what led me to look.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 20:20 UTC
Download the full PDF report from the workflow artifacts.
…register dialect (gate-54 2 -> 0)
.github#286 landed after my earlier commit and adds exactly the notion that was
missing: check_relation_dialect.py now has _is_external_ref(), so a property
owned by another app's register is expressible.
Verified against a fresh clone of ConductionNL/.github@main, package
365fa31d09a26f980e6dc76cb0800575ef005a4e — `_is_external_ref` exists at
lib/check_relation_dialect.py:341. It did NOT exist at 651e5c5, which is why my
previous commit removed the key as inert. That measurement was correct at the
time and is now superseded.
The dialect is `x-external-register: <app>` on the property, carrying the BARE
identifier, and NO $ref — the checker still reports a property that declares
both, because OpenRegister resolves $ref within one register set and can never
reach another app's schema:
caseType.productsOrServices x-external-register: pipelinq, items lose $ref
case.vergunningaanvraagRef x-external-register: dso, loses $ref
case.decisions is deliberately NOT given the key: `decision` is one of procest's
own 85 schemas and resolves locally, so it is not a cross-app reference. Adding
the key there would now be a finding rather than a fix.
Bidirectional proof, same checker and invocation both ways:
as committed -> 0 findings
$ref put back on vergunningaanvraagRef -> "$ref 'vergunningaanvraag' does
not resolve to a schema key in
the register set (case-exact)"
restored -> 0 findings
Both properties stay live — 6 and 7 code references — and their descriptions
already name the owning register.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 20:33 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/procest @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue3-compile | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 552/552 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 21:07 UTC
Download the full PDF report from the workflow artifacts.
Merging over a red
|
CI Hydra Gates |
measured full-tree | |
|---|---|---|
development |
PASS | 15 gates failing |
| this branch | 2 gates | 8 gates failing |
The two gates CI names add nothing. Same package, full-tree, both sides:
| gate | development |
this branch |
|---|---|---|
| gate-40 form-label-association | 193 | 193 |
| gate-53 effective-manifest-crossref | 8 | 8 |
Zero added by this PR. They surface here only because diff-scoping makes pre-existing debt in a touched file that PR's to answer for.
- gate-40 is the explicitly out-of-scope gate for this sweep — ~58% false positives and unclosable as written.
- gate-53's 8 are deliberate and documented in
_meta.removalsCoverageNote. That note suppresses nothing:check_manifest_crossref.jsskips_metakeys, so all 8 still report. Two of its three claims are independently verified (Cases.config.folderSidebar,Cases.config.viewModesincludingmap); the third is what theBesluitvormingLeafTabcommit in this PR actually fixes.
E2E Tests (Playwright) fails on development too, with the same pre-existing failures (case-types-tabs, spec-coverage/admin-settings, spec-coverage/pdok-via-openconnector). The PDOK ones are the known openconnector adapter 404 (openconnector#1197). Quality Report is a pure aggregator.
Net: development goes from 15 failing gates to 8, with no gate's count increasing, plus a real UI regression fixed (BesluitvormingLeafTab was registered but never placed).
What this is
The frontend half of today's gate burn-down on
procest, plus four follow-up fixes found while verifying it. Measured against a fresh clone ofConductionNL/.github@main.Gate packages used (they moved twice during the work, so both are named):
c51a225a2d6531e639b92ba81310a4ae32accad1651e5c5bb3ba8764903e5d6fc5bac5a208bd67fc(final numbers below)Measured, full-tree
origin/developmentbefore all of today's workorigin/developmentnow (after #774 + #776)Gate-by-gate on this branch versus the 22-gate baseline — every one verified by me on my own run, not taken on report:
No gate's count went up.
stderrfrom the gate run: 0 bytes.The four follow-ups
1. l10n — two CI jobs the gate suite cannot see. The new accessible names and dialog copy were new source strings, present in neither
l10n/en.jsonnorl10n/nl.json.l10n coverage (en.json)andquality / Frontend Check (test:l10n)both failed, and a Dutch-locale user would have seen raw English keys.en.jsonextracted with the repo's owntest:l10n:write; the 14 Dutch strings written by hand.Remove guardisVoorwaarde verwijderen, not a literalbewaker— in the transition editor a guard is a precondition, not a person.2. gate-46 — a
@spectarget that has not existed for months. All 36signalering-widgetstags pointed atopenspec/changes/retrofit-2026-05-24-signalering-widgets/tasks.md, a change directory since archived toopenspec/changes/archive/2026-05-31-…. Retargeted atopenspec/specs/signalering-widgets/spec.md, which exists. 30 of the 36 predate this branch; the a11y pass added 6 by copying the surrounding docblock, and diff-scoping made all of them this PR's to answer for — CI reported exactly that, 10 findings from 1 target.3.
x-external-register— removed, then reinstated correctly. #776 added this key beside three cross-register$refs as a gate-54 fix. Measured against package651e5c5, it was inert in both directions — nothing underopenregister/lib/read it, andcheck_relation_dialect.pyhad no external-register branch — so I removed it..github#286then landed, adding exactly that notion (_is_external_ref(), present at365fa31, absent at651e5c5). So the key is reinstated, in the form the checker actually accepts:x-external-register: <app>on the property, bare identifier, and no$ref— a property declaring both is still reported, because OpenRegister resolves$refwithin one register set and cannot reach another app's schema.case.decisionsdeliberately does not get the key:decisionis one of procest's own 85 schemas and resolves locally, so it is not a cross-app reference and the key there would now create a finding. "Names another app" is not the same as "not resolvable here".gate-54: 2 → 0, proven both directions with the same checker — putting the
$refback reproduces$ref 'vergunningaanvraag' does not resolve to a schema key in the register set (case-exact); restoring returns it to 0.4. 🐛
BesluitvormingLeafTabwas registered but never placed — a real lost surface. This is the one that matters.#776's gate-53 note states the standalone Besluitvorming nav (Voorstellen / Advice / Agenda) was "replaced with a
BesluitvormingLeafTabsidebar tab onCaseDetail" and that the replacement is "live, wired, and menu-reachable". Measured:src/components/tabs/BesluitvormingLeafTab.vueexists.src/registry.js:412registers it, in the same shape asCaseNotesTab.CaseDetail.config.sidebar.tabslistedaudit,version-history,notes,sharing,email— and nothing else.The component was resolvable by name and rendered by nobody.
consume-decidesk-besluitvorming-leafremoved three menu entries and registered a tab it never placed, so users lost the nav and gained no replacement. Being in the registry is availability; the manifest decides placement, and it did not mention this tab.Now wired. The component's own header says it is meant to be surfaced through the
sidebarTabs[].componentpath — exactly this wiring — and it already renders a quiet unavailable notice when decidesk is absent rather than a broken tab.Gavelis registered insrc/icons.js, so gate-60 stays green (28 manifests, 0 failures) andcheck:manifestpasses with Ajv 0 errors.This does not move gate-53 — still 8 findings, because that gate walks the static
.menutree and a per-object sidebar tab is not in it. The gate is what led me to look; the lost surface is the point.Verification
role="button"additions are matched by 68@keydownadditions —enterandspaceper element, each bound to the same handler as the click.window.confirm(remains inDeelzaakList.vue,KlachtcategorieenTab.vueorChecklistsTab.vue; they use the sharedCnConfirmDialog, so gate-13 modal-isolation stays green.require.resolve('ajv')→…/apps-extra/procest/node_modules/ajv/dist/ajv.js(8.20.0), procest's own dependency. gate-53's findings are a real measurement, andcheck:manifestlogsAjv validation: PASS.What stays red, and why
gate-19(1161),gate-25(128),gate-26(164),gate-40(193) are coverage programmes, out of scope here. Beyond those:GuardEvaluatorInterface::evaluate()mandates$userId;RoleGuarduses it 4×,MandaatGuard2×, and the three precondition guards cannot drop a parameter their interface requires. Every local "fix" is worse than the finding._meta.removalsCoverageNote. The note suppresses nothing:check_manifest_crossref.jsskips_metakeys, so all 8 still report. Two of its three claims are now verified —Cases.config.folderSidebarandCases.config.viewModes: [… "map"]both exist; the third is what item 4 above fixes..github#286landed mid-work and adds_is_external_ref()to the relation-dialect checker (present at365fa31, absent at651e5c5).caseType.productsOrServicesandcase.vergunningaanvraagRefare now expressed asx-external-register: pipelinq/dsowith the bare identifier and no$ref.case.decisionsdeliberately does not get the key —decisionis one of procest's own 85 schemas and resolves locally, so the key there would now create a finding. My earlier .github#300 is closed as superseded.Not a finding
gate-4 composer-auditFAILs on a diff-scoped local run withcurl error 28 … packagist.org … timed outin its log — a network timeout rendered as "FAIL — CVEs or advisories".composer audit --lockedon this tree reports no advisories. Filed .github#302.