feat(fp): WP-17 — app-level a11y: route focus, template lint, WCAG checklist

Adds route-change focus management (new page's h1, afterNextRender) plus
scroll-position restoration wired once in app.config.ts; angular-eslint's
templateAccessibility bundle linting every inline template via
processInlineTemplates (verified firing with a planted violation, one real
hit fixed in rich-text-editor); docs/wcag-checklist.md and Foundations/
Accessibility MDX tying the four a11y layers (axe, lint, play tests,
manual checklist) together. The checklist pass already earned its keep —
it found a real 320px overflow in aanvraag-block's warning alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 08:26:01 +02:00
parent f3de30b72c
commit e272869f00
11 changed files with 942 additions and 506 deletions

View File

@@ -56,7 +56,7 @@ for its existing violations, so every WP ends green.
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |

View File

@@ -1,6 +1,6 @@
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
Status: todo
Status: done (pending commit)
Phase: 4 — a11y
## Why
@@ -59,11 +59,30 @@ Three app-level gaps close the WCAG story:
## Acceptance criteria
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
view transitions still play.
- [ ] Template a11y rules active and _proven_ to fire; lint green.
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
- [x] Template a11y rules active and _proven_ to fire; lint green.
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
## Deviation from the original plan
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
setup. Less code to hand-maintain, same coverage plus more.
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
`.feedback` flex row doesn't wrap. Documented in `docs/wcag-checklist.md` with the
root cause, **not fixed** — fixing live component CSS found via the checklist is the
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
WP's own deliverable. Flagged here so it isn't lost.
- "Screen reader" column left unfilled for every page — the pass available in this
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
coverage than was done.
## Verification

59
docs/wcag-checklist.md Normal file
View File

@@ -0,0 +1,59 @@
# WCAG manual checklist
Automation (axe on every story — WP-01; template a11y lint — WP-17; the form-field/alert
play tests — WP-16) catches structural and component-level issues. It cannot catch
cross-page flows: tab order across a whole page, focus traps, zoom/reflow, or how a
screen reader actually narrates a journey. This checklist is the manual complement —
a living doc, filled in per page as it's walked, not a one-time sign-off.
See `src/docs/a11y.mdx` (Storybook → Foundations → Accessibility) for how this fits
with the automated layers.
## How to run a page through this checklist
1. **Keyboard walk**: `Tab`/`Shift+Tab` through the whole page. Every interactive
element reachable, in a sensible order, with a visible focus ring; no trap (you can
always tab back out).
2. **No traps**: a modal/dropdown/menu, if present, returns focus on close/`Escape`.
3. **200% zoom / reflow**: browser zoom to 200% (or a 320px-wide viewport). Content
reflows to one column; nothing is clipped or requires horizontal scroll.
4. **Screen reader pass**: NVDA (Windows) or VoiceOver (macOS) — navigate by heading
and by tab; confirm labels, descriptions, and error announcements are heard, not
just visible.
5. **Visible focus**: every focused element has a visible indicator (no
`outline: none` without a replacement).
6. **Error announcement**: submitting an invalid form announces the error (this is
what WP-16's `role="alert"` + `aria-describedby` wiring is for) — confirm it's
actually heard, not just present in the DOM.
## Status
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
| --------------------------- | :-----------: | :------: | :---------------: | :------------: | :------------: | :-----------------: |
| Login (`/login`) | ✅ | ✅ | ✅ | — | ✅ | n/a¹ |
| Dashboard (`/dashboard`) | — | — | ❌² | — | — | — |
| Registratie wizard | — | — | — | — | ✅³ | ✅³ |
| Herregistratie wizard | — | — | — | — | — | — |
| Brief (letter composition) | — | — | — | — | — | — |
¹ Login's demo form has no client-side validation/error state to exercise.
² **Real finding, not fixed here**: `aanvraag-block`'s warning `app-alert` (two
`app-button` actions) overflows the viewport at a 320px width — its `.feedback` flex
row doesn't wrap, pushing the second button past the edge. Fixing it is a genuine
CSS change to a live component, which is exactly the "full manual audit" scope this
WP defers (see Out of scope) — logged here instead of silently fixed or silently
ignored.
³ Spot-checked only: submitting the wizard with required fields empty renders
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring
works end-to-end on a real form, not just in the play test's synthetic composition.
Full keyboard walk / zoom / screen-reader pass on this page not yet done.
"Screen reader" is unfilled everywhere — this pass used a headless browser (keyboard
emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't
read the automation above as a substitute for that row; it isn't one.
Filling in the remaining rows (and fixing finding ²) is ongoing work (WP-17's
out-of-scope note) — this table makes what's been checked, and what hasn't, visible
rather than assumed.

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
/**
* Enforces the architecture's working agreements that were previously only
@@ -28,8 +29,18 @@ export default [
},
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-explicit-any': 'error' },
// This repo has no .html files — every template is inline. This processor
// extracts each component's template string into a virtual `*.html` file,
// which the template-a11y block below (`files: ['src/**/*.html']`) then lints.
processor: angular.processInlineTemplates,
},
// Template a11y (WP-17): the official angular-eslint accessibility bundle
// (alt-text, label-has-associated-control, click/mouse-events-have-key-events,
// interactive-supports-focus, valid-aria, no-autofocus, and more) linting the
// virtual `.html` files the processor above extracts from inline templates.
...angular.configs.templateAccessibility.map((c) => ({ ...c, files: ['src/**/*.html'] })),
// Tests legitimately use `any` to feed invalid messages/states into reducers.
{
files: ['src/**/*.spec.ts'],

196
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6",
"@storybook/test-runner": "^0.24.4",
"angular-eslint": "^22.0.0",
"axe-playwright": "^2.2.2",
"concurrently": "^10.0.3",
"eslint": "^10.6.0",
@@ -532,6 +533,167 @@
"yarn": ">= 1.13.0"
}
},
"node_modules/@angular-eslint/builder": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-22.0.0.tgz",
"integrity": "sha512-T2vWQYUhJs6iUlgocHV12OgoxbmN63f17a+tgW+3sYrKN0KAB3xuHsPOoYpRYoWqkVVC44HD441Ju4IDvo8vKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/architect": ">= 0.2200.0 < 0.2300.0",
"@angular-devkit/core": ">= 22.0.0 < 23.0.0"
},
"peerDependencies": {
"@angular/cli": ">= 22.0.0 < 23.0.0",
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/bundled-angular-compiler": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-22.0.0.tgz",
"integrity": "sha512-rv15vGDpGW8zZFaLdhQ+iIO1f0bZds/xvuxoX277hFisXp5Kt6FumJNNIb4g/qxq3xsY46a7fD6R7KvGY3smHg==",
"dev": true,
"license": "MIT"
},
"node_modules/@angular-eslint/eslint-plugin": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-22.0.0.tgz",
"integrity": "sha512-mKLScPZhqG64ic0KIQoxqSqCdkPwtEZuTOuunvc9lYTw05MJSHRUM2yVFODlCGq97c6BN1F6KBk2I+a+KFnr1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "22.0.0",
"@angular-eslint/utils": "22.0.0",
"ts-api-utils": "^2.1.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/eslint-plugin-template": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-22.0.0.tgz",
"integrity": "sha512-y6XL5HJ8C31NpBvkVHpU3bWc+Rk9g1zRtHrs39omhuT29eEUcS3zu47HMFV6tf8rHOI97B2Mstg6qYS5XL9ATg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "22.0.0",
"@angular-eslint/utils": "22.0.0",
"aria-query": "5.3.2",
"axobject-query": "4.1.0"
},
"peerDependencies": {
"@angular-eslint/template-parser": "22.0.0",
"@typescript-eslint/types": "^8.0.0",
"@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/schematics": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-22.0.0.tgz",
"integrity": "sha512-gsJQx6c+WIWC5d+NAqn4rRdUzwhinUCTNmCM9x4wygV9DrbAfVG+6OFPEbaDMryNvf0HYDcnGclbIbXjukGCaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": ">= 22.0.0 < 23.0.0",
"@angular-devkit/schematics": ">= 22.0.0 < 23.0.0",
"@angular-eslint/eslint-plugin": "22.0.0",
"@angular-eslint/eslint-plugin-template": "22.0.0",
"ignore": "7.0.5",
"semver": "7.8.0",
"strip-json-comments": "3.1.1"
},
"peerDependencies": {
"@angular/cli": ">= 22.0.0 < 23.0.0"
}
},
"node_modules/@angular-eslint/schematics/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@angular-eslint/schematics/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@angular-eslint/template-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-22.0.0.tgz",
"integrity": "sha512-jU5MKQ24bBB4J99gSSexmUrLm2LvTJZCuCHhNTQ1LavWX4e1lrIxhm+6pJILOm6Cixf8jyNXnHMty6nljX8J+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "22.0.0",
"eslint-scope": "9.1.2"
},
"peerDependencies": {
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@angular-eslint/template-parser/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@angular-eslint/utils": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-22.0.0.tgz",
"integrity": "sha512-VFodMojghnPYm+B3U+HRYrqebPMj8NyobNjVzDdY8V5XIBW+4ivOSEINIz81G48rmm/NZKwj56+bJ88bVX4KIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "22.0.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "*"
}
},
"node_modules/@angular/build": {
"version": "22.0.4",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-22.0.4.tgz",
@@ -11093,6 +11255,30 @@
"node": ">= 14.0.0"
}
},
"node_modules/angular-eslint": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-22.0.0.tgz",
"integrity": "sha512-6tHLndzM6rU+2iuICakJS/hD1scK5sWLkcD7828zStT1ViA9zX8z9g/V1IlBiKEdZeMsl+m7K2DlNc34AkYyoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": ">= 22.0.0 < 23.0.0",
"@angular-devkit/schematics": ">= 22.0.0 < 23.0.0",
"@angular-eslint/builder": "22.0.0",
"@angular-eslint/eslint-plugin": "22.0.0",
"@angular-eslint/eslint-plugin-template": "22.0.0",
"@angular-eslint/schematics": "22.0.0",
"@angular-eslint/template-parser": "22.0.0",
"@typescript-eslint/types": "^8.0.0",
"@typescript-eslint/utils": "^8.0.0"
},
"peerDependencies": {
"@angular/cli": ">= 22.0.0 < 23.0.0",
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "*",
"typescript-eslint": "^8.0.0"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -11411,6 +11597,16 @@
"node": ">= 6"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/babel-jest": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz",

View File

@@ -44,6 +44,7 @@
"@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6",
"@storybook/test-runner": "^0.24.4",
"angular-eslint": "^22.0.0",
"axe-playwright": "^2.2.2",
"concurrently": "^10.0.3",
"eslint": "^10.6.0",

View File

@@ -4,7 +4,7 @@ import {
isDevMode,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
@@ -16,6 +16,7 @@ import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus';
registerLocaleData(localeNl);
@@ -24,6 +25,7 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
// animate: for the transition's duration Firefox's `::view-transition`
@@ -48,5 +50,6 @@ export const appConfig: ApplicationConfig = {
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
{ provide: LOCALE_ID, useValue: 'nl' },
provideRouteFocus(),
],
};

View File

@@ -0,0 +1,45 @@
import {
DOCUMENT,
ENVIRONMENT_INITIALIZER,
EnvironmentInjector,
afterNextRender,
inject,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
/** Template-layer wiring (not a component): on every route change after the
initial load, moves focus to the new page's `<h1>` (page-shell always
renders one) so screen-reader/keyboard users land on the new content
instead of wherever focus happened to be. Falls back to `#main` (the
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
so it doesn't race Angular's view-transition DOM swap. */
export function provideRouteFocus() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const router = inject(Router);
const document = inject(DOCUMENT);
const injector = inject(EnvironmentInjector);
let isInitialLoad = true;
router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) return;
if (isInitialLoad) {
isInitialLoad = false;
return;
}
afterNextRender(
() => {
const target =
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
if (!target) return;
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
},
{ injector },
);
});
},
};
}

View File

@@ -161,6 +161,7 @@ export interface PlaceholderOption {
#editor
class="rte-editable"
[attr.contenteditable]="editable()"
[attr.tabindex]="editable() ? '0' : null"
(input)="emit()"
(keydown)="onKeydown($event)"
role="textbox"

69
src/docs/a11y.mdx Normal file
View File

@@ -0,0 +1,69 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AlertStories from '../app/shared/ui/alert/alert.stories';
import * as FormFieldStories from '../app/shared/ui/form-field/form-field.stories';
<Meta title="Foundations/Accessibility" />
# Accessibility
No single tool catches every a11y class of bug, so this repo layers four, each catching
what the ones below/above it can't.
## The layers
1. **Axe on every story** (WP-01) — `@storybook/addon-a11y` in the panel, plus
`@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`).
Catches structural/contrast/ARIA-shape violations on every component, automatically,
as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`,
only with an inline justification comment + a cross-reference to the WP that will fix
it (see e.g. `task-list.stories.ts`).
2. **Template a11y lint** (WP-17) — `angular-eslint`'s `templateAccessibility` config
(`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`,
`interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline
template via `angular.processInlineTemplates` (this repo has no `.html` files — every
template is a string in the `@Component` decorator; the processor extracts each one
into a virtual file the template rules can lint). Catches missing alt text, unlabelled
controls, and interactive elements that can't be reached by keyboard — at lint time,
before a story even exists.
3. **Play tests** (WP-16) — Storybook stories assert the wiring axe/lint can't see:
`form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins
`-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for
errors vs `role="status"` for info/ok/warning. These run as part of the same
`test-storybook:ci` gate as the axe checks, so a regression fails CI, not just a panel.
4. **Manual WCAG checklist** (`docs/wcag-checklist.md`) — what none of the above can see:
tab order across a whole page, focus traps, 200%-zoom reflow, and how a real screen
reader narrates a flow. A living per-page checklist, not a one-time audit — it already
caught a real bug (a dashboard alert overflowing at 320px) that no automated layer here
would have flagged.
## Component wiring this protects
<Canvas of={FormFieldStories.WithDescriptionAndError} />
The description (`-desc`) and error (`-error`) ids are joined in a pinned order so a
screen reader announces the hint, then the error, never neither. See
`text-input.component.ts`'s `describedBy()`.
<Canvas of={AlertStories.Error} />
Errors are `role="alert"` (assertive — interrupts, because the user needs to know
*now*); info/ok/warning stay `role="status"` (polite) so they don't interrupt whatever
the user is doing. See `alert.component.ts`.
## Route-change focus
Client-side routing has no page (re)load, so a screen reader/keyboard user's focus stays
wherever it was — usually the link they just clicked, now detached from any content that
matters. `shared/layout/route-focus.ts` moves focus to the new page's `<h1>` (every page
has exactly one via `page-shell`) on every navigation after the initial load, deferred via
`afterNextRender` so it doesn't race the view-transition DOM swap. Scroll position resets
the same way (`withInMemoryScrolling`), both wired once in `app.config.ts` — not per page.
## Where the skip register lives
`npm run lint` fails the build on a real template a11y violation, and `test-storybook:ci`
fails it on a real axe violation. Both can be locally disabled — the lint rule via a
normal ESLint disable comment, axe via `parameters: { a11y: { disable: true } }` — but
only with a comment naming *why* and a cross-reference to the WP expected to remove the
skip (see `docs/backlog/WP-13-cibg-gap-register.md`'s marker convention, reused here).
Grep `a11y: { disable: true }` in `*.stories.ts` for the current list.