feat(a11y): WP-01 — axe-on-every-story CI gate

Turn the interactive Storybook a11y addon into a build gate:
- @storybook/test-runner + axe-playwright over the static build
  (.storybook/test-runner.ts reads the a11y tags from story context)
- test-storybook / test-storybook:ci scripts; storybook-a11y CI job
- triage: escape-hatch a11y.disable on stories whose display:contents
  wrapper splits <ul>/<li> or <dl>/<dt>/<dd> (structural, deferred to
  WP-11/WP-12, each with justification + cross-ref)
- fix trivial violations: footer/wizard-shell contrast, text-input label,
  wizard stories missing provideApiClient

Verified: broken story fails the gate; 133 stories pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 19:13:18 +02:00
parent b4fb0be769
commit 97f7de4590
22 changed files with 7795 additions and 1819 deletions

View File

@@ -19,6 +19,20 @@ jobs:
- run: npm test - run: npm test
- run: npm run build - run: npm run build
storybook-a11y:
# Axe runs against every story in the static build; a violation fails the build.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build-storybook
- run: npm run test-storybook:ci
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

21
.storybook/test-runner.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { TestRunnerConfig } from '@storybook/test-runner';
import { getStoryContext } from '@storybook/test-runner';
import { injectAxe, checkA11y } from 'axe-playwright';
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page, context) {
const storyContext = await getStoryContext(page, context);
if (storyContext.parameters?.a11y?.disable) return;
await checkA11y(page, '#storybook-root', {
axeOptions: storyContext.parameters?.a11y?.config,
detailedReport: true,
detailedReportOptions: { html: true },
});
},
};
export default config;

View File

@@ -40,7 +40,7 @@ for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
|----|-------|-------|--------| |----|-------|-------|--------|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | todo | | [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | todo | | [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | todo |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | todo | | [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | todo |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo | | [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |

View File

@@ -1,6 +1,6 @@
# WP-01 — Axe-on-every-story CI gate # WP-01 — Axe-on-every-story CI gate
Status: todo Status: done (pending commit)
Phase: 0 — enforcement & gates Phase: 0 — enforcement & gates
## Why ## Why
@@ -59,11 +59,11 @@ build", so every story added or changed by later WPs is automatically covered.
## Acceptance criteria ## Acceptance criteria
- [ ] `npm run test-storybook:ci` passes locally against a fresh `storybook-static/`. - [x] `npm run test-storybook:ci` passes locally against a fresh `storybook-static/`.
- [ ] CI has a `storybook-a11y` job; an axe violation makes it fail (verify once with a - [x] CI has a `storybook-a11y` job; an axe violation makes it fail (verified once with a
deliberately broken story, then revert). deliberately broken story, then reverted).
- [ ] Every `a11y.disable` skip carries a justification comment + WP cross-ref. - [x] Every `a11y.disable` skip carries a justification comment + WP cross-ref.
- [ ] Backlog README GREEN updated. - [x] Backlog README GREEN updated.
## Verification ## Verification

File diff suppressed because one or more lines are too long

8512
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@
"test": "ng test", "test": "ng test",
"storybook": "ng run atomic-design-poc:storybook", "storybook": "ng run atomic-design-poc:storybook",
"build-storybook": "ng run atomic-design-poc:build-storybook", "build-storybook": "ng run atomic-design-poc:build-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\"",
"check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts' --exclude='debug-state.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi" "check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts' --exclude='debug-state.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi"
}, },
"private": true, "private": true,
@@ -39,14 +41,19 @@
"@storybook/addon-docs": "^10.4.6", "@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6", "@storybook/angular": "^10.4.6",
"@storybook/test-runner": "^0.24.4",
"axe-playwright": "^2.2.2",
"concurrently": "^10.0.3",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"http-server": "^14.1.1",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"nswag": "^14.7.1", "nswag": "^14.7.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"storybook": "^10.4.6", "storybook": "^10.4.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.0",
"vitest": "^4.0.8" "vitest": "^4.0.8",
"wait-on": "^9.0.10"
}, },
"comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.", "comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.",
"overrides": { "overrides": {

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular'; import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine'; import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { initialUpload } from '@shared/upload/upload.machine'; import { initialUpload } from '@shared/upload/upload.machine';
@@ -13,7 +14,7 @@ const meta: Meta<HerregistratieWizardComponent> = {
component: HerregistratieWizardComponent, component: HerregistratieWizardComponent,
// The wizard injects BigProfileStore (for the optimistic cross-page flag), // The wizard injects BigProfileStore (for the optimistic cross-page flag),
// which creates httpResources — so the story needs an HttpClient. // which creates httpResources — so the story needs an HttpClient.
decorators: [applicationConfig({ providers: [provideHttpClient()] })], decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
}; };
export default meta; export default meta;
type Story = StoryObj<HerregistratieWizardComponent>; type Story = StoryObj<HerregistratieWizardComponent>;

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular'; import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { IntakeWizardComponent } from './intake-wizard.component'; import { IntakeWizardComponent } from './intake-wizard.component';
import { IntakeState, Answers } from '@herregistratie/domain/intake.machine'; import { IntakeState, Answers } from '@herregistratie/domain/intake.machine';
import { Uren } from '@registratie/domain/value-objects/uren'; import { Uren } from '@registratie/domain/value-objects/uren';
@@ -11,7 +12,7 @@ const meta: Meta<IntakeWizardComponent> = {
title: 'Herregistratie/IntakeWizard', title: 'Herregistratie/IntakeWizard',
component: IntakeWizardComponent, component: IntakeWizardComponent,
// Injects BigProfileStore (optimistic flag) which creates httpResources. // Injects BigProfileStore (optimistic flag) which creates httpResources.
decorators: [applicationConfig({ providers: [provideHttpClient()] })], decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
}; };
export default meta; export default meta;
type Story = StoryObj<IntakeWizardComponent>; type Story = StoryObj<IntakeWizardComponent>;

View File

@@ -22,6 +22,12 @@ const meta: Meta<AanvraagBlockComponent> = {
// A row is an <li> — the keuzelijst styling needs the real list context. // A row is an <li> — the keuzelijst styling needs the real list context.
template: `<ul class="keuzelijst__list"><app-aanvraag-block [aanvraag]="aanvraag" /></ul>`, template: `<ul class="keuzelijst__list"><app-aanvraag-block [aanvraag]="aanvraag" /></ul>`,
}), }),
parameters: {
// Structural: app-aanvraag-block's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<AanvraagBlockComponent>; type Story = StoryObj<AanvraagBlockComponent>;

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular'; import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { RegistratieWizardComponent } from './registratie-wizard.component'; import { RegistratieWizardComponent } from './registratie-wizard.component';
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { initialUpload } from '@shared/upload/upload.machine'; import { initialUpload } from '@shared/upload/upload.machine';
@@ -25,7 +26,7 @@ const validData: ValidRegistratie = {
const meta: Meta<RegistratieWizardComponent> = { const meta: Meta<RegistratieWizardComponent> = {
title: 'Registratie/RegistratieWizard', title: 'Registratie/RegistratieWizard',
component: RegistratieWizardComponent, component: RegistratieWizardComponent,
decorators: [applicationConfig({ providers: [provideHttpClient()] })], decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
}; };
export default meta; export default meta;
type Story = StoryObj<RegistratieWizardComponent>; type Story = StoryObj<RegistratieWizardComponent>;

View File

@@ -13,6 +13,11 @@ const base = {
const meta: Meta<RegistrationSummaryComponent> = { const meta: Meta<RegistrationSummaryComponent> = {
title: 'Organisms/Registration Summary', title: 'Organisms/Registration Summary',
component: RegistrationSummaryComponent, component: RegistrationSummaryComponent,
parameters: {
// Structural: app-data-row's host sits between the <dl> and its <dt>/<dd> —
// fixed by the WP-12 Datablock rework. See docs/backlog/WP-12-datablock.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<RegistrationSummaryComponent>; type Story = StoryObj<RegistrationSummaryComponent>;

View File

@@ -12,11 +12,15 @@ import { Component } from '@angular/core';
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box;display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;justify-content:space-between} .inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box;display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;justify-content:space-between}
.tagline{font-style:italic;font-weight:var(--rhc-text-font-weight-semi-bold);font-size:var(--rhc-text-font-size-lg);max-inline-size:18rem} .tagline{font-style:italic;font-weight:var(--rhc-text-font-weight-semi-bold);font-size:var(--rhc-text-font-size-lg);max-inline-size:18rem}
.ministry{margin-block-start:var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-on-primary)} .ministry{margin-block-start:var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-on-primary)}
.col h2{margin:0 0 var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);font-weight:var(--rhc-text-font-weight-bold);text-transform:uppercase;letter-spacing:.04em} /* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
color regardless of specificity — restate on-primary explicitly for this dark bar. */
.col h2{margin:0 0 var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);font-weight:var(--rhc-text-font-weight-bold);text-transform:uppercase;letter-spacing:.04em;color:var(--rhc-color-foreground-on-primary)}
.links{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-sm);font-size:var(--rhc-text-font-size-sm)} .links{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-sm);font-size:var(--rhc-text-font-size-sm)}
.links a{color:var(--rhc-color-foreground-on-primary);text-decoration:none} .links a{color:var(--rhc-color-foreground-on-primary);text-decoration:none}
.links a:hover{text-decoration:underline} .links a:hover{text-decoration:underline}
.meta{inline-size:100%;border-block-start:var(--rhc-border-width-sm) solid rgb(255 255 255 / 0.25);margin-block-start:var(--rhc-space-max-xl);padding-block-start:var(--rhc-space-max-lg);font-size:var(--rhc-text-font-size-sm);opacity:.85} /* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
dark grey — override rather than rename to keep the CIBG-mirroring class name. */
.meta{inline-size:100%;border-block-start:var(--rhc-border-width-sm) solid rgb(255 255 255 / 0.25);margin-block-start:var(--rhc-space-max-xl);padding-block-start:var(--rhc-space-max-lg);font-size:var(--rhc-text-font-size-sm);opacity:.85;color:var(--rhc-color-foreground-on-primary)}
`], `],
template: ` template: `
<footer class="bar"> <footer class="bar">

View File

@@ -35,6 +35,8 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
styles: [` styles: [`
.es-title{margin:0 0 var(--rhc-space-max-sm)} .es-title{margin:0 0 var(--rhc-space-max-sm)}
.es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)} .es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)}
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
.es-list a{color:var(--rhc-color-lintblauw-700)}
`], `],
template: ` template: `
@switch (status()) { @switch (status()) {

View File

@@ -12,6 +12,12 @@ const meta: Meta<ApplicationLinkComponent> = {
// Rows are <li>s — a real list gives them their normal layout in the story. // Rows are <li>s — a real list gives them their normal layout in the story.
template: `<ul class="list-unstyled"><app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable" /></ul>`, template: `<ul class="list-unstyled"><app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable" /></ul>`,
}), }),
parameters: {
// Structural: app-application-link's host sits between the <ul> and its <li> —
// axe's list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<ApplicationLinkComponent>; type Story = StoryObj<ApplicationLinkComponent>;

View File

@@ -19,6 +19,11 @@ const meta: Meta<ApplicationListComponent> = {
<app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." to="/registreren" /> <app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." to="/registreren" />
</app-application-list>`, </app-application-list>`,
}), }),
parameters: {
// Structural: app-application-link's host sits between the <ul> and its <li> —
// fixed by the WP-11 markup rework. See docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<ApplicationListComponent>; type Story = StoryObj<ApplicationListComponent>;

View File

@@ -12,6 +12,12 @@ const meta: Meta<ChoiceLinkComponent> = {
// Rows are <li>s — a real list gives them their normal layout in the story. // Rows are <li>s — a real list gives them their normal layout in the story.
template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`, template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`,
}), }),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
// list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<ChoiceLinkComponent>; type Story = StoryObj<ChoiceLinkComponent>;

View File

@@ -19,6 +19,11 @@ const meta: Meta<ChoiceListComponent> = {
<app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" /> <app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" />
</app-choice-list>`, </app-choice-list>`,
}), }),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> —
// fixed by the WP-11 markup rework. See docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<ChoiceListComponent>; type Story = StoryObj<ChoiceListComponent>;

View File

@@ -10,6 +10,12 @@ const meta: Meta<DataRowComponent> = {
template: `<dl class="row mb-0"><app-data-row [key]="key" [value]="value" /></dl>`, template: `<dl class="row mb-0"><app-data-row [key]="key" [value]="value" /></dl>`,
}), }),
args: { key: 'BIG-nummer', value: '19012345601' }, args: { key: 'BIG-nummer', value: '19012345601' },
parameters: {
// Structural: app-data-row's host sits between the <dl> and its <dt>/<dd> —
// axe's definition-list rule needs them adjacent regardless of `display:contents`.
// WP-12 (CIBG Datablock) reworks this markup; see docs/backlog/WP-12-datablock.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<DataRowComponent>; type Story = StoryObj<DataRowComponent>;

View File

@@ -16,6 +16,11 @@ const meta: Meta<ReviewSectionComponent> = {
<app-data-row key="Woonplaats" value="Utrecht" /> <app-data-row key="Woonplaats" value="Utrecht" />
</app-review-section>`, </app-review-section>`,
}), }),
parameters: {
// Structural: app-data-row's host sits between the <dl> and its <dt>/<dd> —
// fixed by the WP-12 Datablock rework. See docs/backlog/WP-12-datablock.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<ReviewSectionComponent>; type Story = StoryObj<ReviewSectionComponent>;

View File

@@ -8,6 +8,12 @@ const meta: Meta<TaskListComponent> = {
component: TaskListComponent, component: TaskListComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })], decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({ props: args, template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />` }), render: (args) => ({ props: args, template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />` }),
parameters: {
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
}; };
export default meta; export default meta;
type Story = StoryObj<TaskListComponent>; type Story = StoryObj<TaskListComponent>;

View File

@@ -15,4 +15,4 @@ type Story = StoryObj<TextInputComponent>;
export const Default: Story = {}; export const Default: Story = {};
export const Invalid: Story = { args: { invalid: true } }; export const Invalid: Story = { args: { invalid: true } };
export const Password: Story = { args: { type: 'password', placeholder: '' } }; export const Password: Story = { args: { type: 'password', placeholder: 'Wachtwoord' } };