feat(fp): WP-20 — second locale proof (nl/en build seam)

angular.json gains an i18n block (sourceLocale nl, en translation file) and
an `en` build/serve configuration with i18nMissingTranslation: "error" so a
new $localize string without an English unit fails the build, not silently
falls back. CI now runs `ng build --localize` to build both locales every
run. Verified end-to-end, not just "the build succeeded": the nl bundle
ships "Inloggen met DigiD", the en bundle ships "Log in with DigiD".

Incidental: prettier/compodoc regen noise in docs/wcag-checklist.md,
src/docs/a11y.mdx, documentation.json from the same working session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 18:16:11 +02:00
parent 26c2c5acd0
commit e276629107
10 changed files with 5405 additions and 53 deletions

View File

@@ -30,7 +30,10 @@ jobs:
- run: npm run format:check - run: npm run format:check
- run: npm run check:tokens - run: npm run check:tokens
- run: npm test - run: npm test
- run: npm run build # --localize builds every configured locale (nl + en, angular.json's i18n
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
- run: npx ng build --localize
# The shipped bundle must stay clean; dev-only advisories are excluded. # The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev - run: npm audit --omit=dev

View File

@@ -173,6 +173,14 @@ degrade to an instant navigation.
**dev-only** — it is not wired into production builds. **dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags - `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified). Angular 22; the builder runs fine (build verified).
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
`en` — a genuine second-locale build, not just an unexercised claim — into
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
### Dependency security ### Dependency security
@@ -186,6 +194,9 @@ We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 2
### Deliberately out of scope (POC) ### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n, Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, NgRx,
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
itself _is_ implemented.) itself _is_ implemented.) i18n's build seam is proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the `en` file is demo-quality, and locale is a build-time
choice, not a switch in the running app.

View File

@@ -17,6 +17,14 @@
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"i18n": {
"sourceLocale": "nl",
"locales": {
"en": {
"translation": "src/locale/messages.en.xlf"
}
}
},
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
@@ -31,7 +39,8 @@
} }
], ],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"polyfills": ["@angular/localize/init"] "polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -59,6 +68,9 @@
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
},
"en": {
"localize": ["en"]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
@@ -71,6 +83,9 @@
}, },
"development": { "development": {
"buildTarget": "atomic-design-poc:build:development" "buildTarget": "atomic-design-poc:build:development"
},
"en": {
"buildTarget": "atomic-design-poc:build:development,en"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"

View File

@@ -1,6 +1,6 @@
# WP-20 — Second locale proof # WP-20 — Second locale proof
Status: todo Status: done (pending commit)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -73,12 +73,14 @@ seam is built into every component but never proven to actually work end to end.
## Acceptance criteria ## Acceptance criteria
- [ ] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s). - [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
- [ ] `messages.en.xlf` exists with a translation for every extracted unit. - [x] `messages.en.xlf` exists with a translation for every extracted unit.
- [ ] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output - [x] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
bundle in CI, and CI fails if the `en` file is missing a unit the source gains. bundle in CI, and CI fails if the `en` file is missing a unit the source gains.
- [ ] Manually verified: the `en` build actually shows English strings in a browser, - [x] Manually verified: the `en` build actually shows English strings in a browser,
not just "the build succeeded." not just "the build succeeded." (`login.submit`: `nl` bundle ships "Inloggen
met DigiD", `en` bundle ships "Log in with DigiD" — checked in the built JS,
not just that the build succeeded.)
## Verification ## Verification

View File

@@ -30,25 +30,25 @@ with the automated layers.
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement | | Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
| --------------------------- | :-----------: | :------: | :---------------: | :------------: | :------------: | :-----------------: | | -------------------------- | :-----------: | :------: | :--------------: | :-----------: | :-----------: | :----------------: |
| Login (`/login`) | ✅ | | | | | n/a¹ | | Login (`/login`) | | | | | | n/a¹ |
| Dashboard (`/dashboard`) | — | | ❌² | | | | | Dashboard (`/dashboard`) | | | ❌² | | | |
| Registratie wizard | — | | | | ✅³ | ✅³ | | Registratie wizard | | | | | ✅³ | ✅³ |
| Herregistratie wizard | — | | | | | | | Herregistratie wizard | | | | | | |
| Brief (letter composition) | — | | | | | | | Brief (letter composition) | | | | | | |
¹ Login's demo form has no client-side validation/error state to exercise. ¹ 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 ² **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 `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 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 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 WP defers (see Out of scope) — logged here instead of silently fixed or silently
ignored. ignored.
³ Spot-checked only: submitting the wizard with required fields empty renders ³ Spot-checked only: submitting the wizard with required fields empty renders
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring `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. 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. 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 "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 emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't

View File

@@ -29733,6 +29733,17 @@
"description": "<p>Value object: an e-mail address. &quot;Parse, don&#39;t validate&quot; — an Email is a\ndistinct type from a raw string, mintable only via parseEmail, so holding one\nis proof it is well-formed. Format-only check (the FE keeps format validation\nfor instant feedback; the backend stays the authority — see ADR-0001).</p>\n", "description": "<p>Value object: an e-mail address. &quot;Parse, don&#39;t validate&quot; — an Email is a\ndistinct type from a raw string, mintable only via parseEmail, so holding one\nis proof it is well-formed. Format-only check (the FE keeps format validation\nfor instant feedback; the backend stays the authority — see ADR-0001).</p>\n",
"kind": 184 "kind": 184
}, },
{
"name": "Err",
"ctype": "miscellaneous",
"subtype": "typealias",
"rawtype": "Error | undefined",
"file": "src/app/shared/application/access.store.ts",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"kind": 193
},
{ {
"name": "Err", "name": "Err",
"ctype": "miscellaneous", "ctype": "miscellaneous",
@@ -29766,17 +29777,6 @@
"description": "", "description": "",
"kind": 193 "kind": 193
}, },
{
"name": "Err",
"ctype": "miscellaneous",
"subtype": "typealias",
"rawtype": "Error | undefined",
"file": "src/app/shared/application/access.store.ts",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"kind": 193
},
{ {
"name": "Errors", "name": "Errors",
"ctype": "miscellaneous", "ctype": "miscellaneous",
@@ -37419,6 +37419,19 @@
"kind": 184 "kind": 184
} }
], ],
"src/app/shared/application/access.store.ts": [
{
"name": "Err",
"ctype": "miscellaneous",
"subtype": "typealias",
"rawtype": "Error | undefined",
"file": "src/app/shared/application/access.store.ts",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"kind": 193
}
],
"src/app/registratie/application/applications.store.ts": [ "src/app/registratie/application/applications.store.ts": [
{ {
"name": "Err", "name": "Err",
@@ -37458,19 +37471,6 @@
"kind": 193 "kind": 193
} }
], ],
"src/app/shared/application/access.store.ts": [
{
"name": "Err",
"ctype": "miscellaneous",
"subtype": "typealias",
"rawtype": "Error | undefined",
"file": "src/app/shared/application/access.store.ts",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"kind": 193
}
],
"src/app/herregistratie/domain/intake.machine.ts": [ "src/app/herregistratie/domain/intake.machine.ts": [
{ {
"name": "Errors", "name": "Errors",

View File

@@ -16,7 +16,8 @@
"test-storybook": "test-storybook", "test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"", "test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
"check:tokens": "bash scripts/check-tokens.sh", "check:tokens": "bash scripts/check-tokens.sh",
"e2e": "playwright test" "e2e": "playwright test",
"extract-i18n": "ng extract-i18n --output-path src/locale"
}, },
"private": true, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",

View File

@@ -47,7 +47,7 @@ screen reader announces the hint, then the error, never neither. See
<Canvas of={AlertStories.Error} /> <Canvas of={AlertStories.Error} />
Errors are `role="alert"` (assertive — interrupts, because the user needs to know 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 _now_); info/ok/warning stay `role="status"` (polite) so they don't interrupt whatever
the user is doing. See `alert.component.ts`. the user is doing. See `alert.component.ts`.
## Route-change focus ## Route-change focus
@@ -64,6 +64,6 @@ the same way (`withInMemoryScrolling`), both wired once in `app.config.ts` — n
`npm run lint` fails the build on a real template a11y violation, and `test-storybook:ci` `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 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 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 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). 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. Grep `a11y: { disable: true }` in `*.stories.ts` for the current list.

2833
src/locale/messages.en.xlf Normal file

File diff suppressed because it is too large Load Diff

2487
src/locale/messages.xlf Normal file

File diff suppressed because it is too large Load Diff