feat(design): adopt CIBG component patterns (header, forms, wizards, dashboard)

Re-skins the app's layout on top of the CIBG Huisstijl theme (previous commit) so it
matches designsystem.cibg.nl, not just its colour tokens — magenta ("robijn") header,
horizontal nav, and the CIBG component markup for forms/wizards/dashboard.

- Header: logo block + robijn titlebar (breadcrumb + user menu) + grey horizontal nav
  (4 links) replacing the dashboard side-nav; breadcrumb restyled for the titlebar
  (no background of its own — CIBG's global `header nav` rule otherwise bleeds a grey
  fill into it, fixed by scoping an override inside BreadcrumbComponent).
- Forms: form-field/radio-group/checkbox rebuilt on CIBG's horizontal `form-group row`
  / `form-check.styled` markup (label col-md-4, control col-md-8); same input() APIs.
- Wizards: stepper rebuilt as the CIBG "stappenindicator" (numbered circles, visited
  steps clickable for back-nav, title merged in); wizard-shell adopts the CIBG
  procesnavigatie button row. Back-navigation wired into all three wizard machines
  (registratie-wizard already had it; added `GaNaarStap` to intake/herregistratie
  machines, pure + spec'd).
- New shared/ui molecules: confirmation (animated bevestiging checkmark, replaces
  plain alerts on submit), review-section (controlestap sections with "Wijzigen"),
  application-list/application-link (CIBG "aanvragen" rows, replace the dashboard's
  card grid and aanvraag-block).
- Cleanup: delete side-nav and now-unused styles.scss utilities (.app-overview,
  .app-form-panel, .app-card-grid); correct design-tokens.mdx (it referenced tokens
  that no longer exist) and document the CIBG-value token bridge.

Verified: build/lint/check:tokens green, 178 tests pass (4 new GaNaarStap cases), and
manually driven end-to-end (dashboard, a full herregistratie submission through to the
confirmation screen, mobile width, keyboard focus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-02 14:33:05 +02:00
co-authored by Claude Fable 5
parent 7887355ca3
commit 6257d7ede3
44 changed files with 769 additions and 591 deletions
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
import { initial, next, back, gaNaarStap, submit, resolve, reduce, WizardState } from './herregistratie.machine';
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
@@ -46,6 +46,17 @@ describe('wizard.machine', () => {
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
const e3 = editing3('4160', '200');
expect(gaNaarStap(e3, 3)).toBe(e3); // same step -> no-op
const submitting = submit(e3);
expect(gaNaarStap(submitting, 1)).toBe(submitting); // not Editing -> no-op
});
});
describe('reduce (message-driven)', () => {
@@ -87,6 +87,13 @@ export function back(s: WizardState): WizardState {
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
}
/** Jump back to an earlier step to correct data (controle → step N). Forward
jumps are not allowed (would skip validation). */
export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {
if (s.tag !== 'Editing' || step >= s.step) return s;
return { ...s, step, errors: {} };
}
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 3) return s;
@@ -121,6 +128,7 @@ export type WizardMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
@@ -136,6 +144,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.step);
case 'Submit':
return submit(s);
case 'Retry':
@@ -8,6 +8,7 @@ import {
currentStep,
next,
back,
gaNaarStap,
submit,
resolve,
reduce,
@@ -67,6 +68,19 @@ describe('navigation', () => {
it('Back never goes below the first step', () => {
expect(back(initial)).toBe(initial);
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
expect((gaNaarStap(s, 0) as any).cursor).toBe(0);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
});
});
describe('submit', () => {
@@ -156,6 +156,13 @@ export function back(s: IntakeState): IntakeState {
return { ...s, cursor: s.cursor - 1, errors: {} };
}
/** Jump back to an earlier step to correct answers (review → step N). Forward
jumps are not allowed (would skip validation). */
export function gaNaarStap(s: IntakeState, cursor: number): IntakeState {
if (s.tag !== 'Answering' || cursor < 0 || cursor >= s.cursor) return s;
return { ...s, cursor, errors: {} };
}
export function submit(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateAll(s.answers, s.scholingThreshold);
@@ -171,6 +178,7 @@ export type IntakeMsg =
| { tag: 'SetAnswer'; key: keyof Answers; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
@@ -186,6 +194,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.cursor);
case 'Submit':
return submit(s);
case 'Retry':
@@ -3,7 +3,8 @@ import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -22,12 +23,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent],
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, ConfirmationComponent, WizardShellComponent, DocumentUploadComponent],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="step() - 1"
[stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName" processName="Herregistratie aanvragen"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1"
@@ -36,7 +38,8 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()">
(retry)="onRetry()"
(goToStep)="goToStep($event)">
@switch (step()) {
@case (1) {
@@ -71,7 +74,7 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
}
<div wizardSuccess>
<app-alert type="ok" i18n="@@herregWizard.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
<app-confirmation i18n-title="@@herregWizard.success.title" title="Uw aanvraag tot herregistratie is ontvangen" />
</div>
</app-wizard-shell>
`,
@@ -127,7 +130,15 @@ export class HerregistratieWizardComponent {
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => (this.step() < 3 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`));
protected primaryLabel = computed(() => {
const step = this.step();
return step < 3 ? naarStapLabel(step + 1, this.stepLabels[step]) : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
});
/** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
}
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
@@ -6,7 +6,9 @@ import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -32,12 +34,13 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
@Component({
selector: 'app-intake-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent],
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
i18n-processName="@@intake.processName" processName="Herregistratie-intake"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
@@ -46,7 +49,8 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()">
(retry)="onRetry()"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })">
@switch (step()) {
@case ('buitenland') {
@@ -81,12 +85,18 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
}
@case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
<dl class="app-section">
<app-review-section i18n-heading="@@intake.sectie.buitenland" heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria" editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">
<app-data-row i18n-key="@@intake.review.buitenNl" key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'" />
@if (answers().buitenlandGewerkt === 'ja') {
<app-data-row i18n-key="@@intake.review.land" key="Land" [value]="answers().land ?? ''" />
<app-data-row i18n-key="@@intake.review.buitenlandseUren" key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''" />
}
</app-review-section>
<app-review-section class="app-section" i18n-heading="@@intake.sectie.werk" heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria" editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">
<app-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''" />
@if (scholingZichtbaar()) {
<app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''" />
@@ -94,15 +104,16 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
@if (answers().scholingGevolgd === 'ja') {
<app-data-row i18n-key="@@intake.review.punten" key="Nascholingspunten" [value]="answers().punten ?? ''" />
}
</dl>
</app-review-section>
}
}
<div wizardSuccess>
<app-alert type="ok" i18n="@@intake.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw">Opnieuw beginnen</app-button>
</div>
<app-confirmation i18n-title="@@intake.success.title" title="Uw aanvraag tot herregistratie is ontvangen">
<div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw">Opnieuw beginnen</app-button>
</div>
</app-confirmation>
</div>
</app-wizard-shell>
`,
@@ -155,7 +166,11 @@ export class IntakeWizardComponent {
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
protected stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => (this.step() === 'review' ? $localize`:@@intake.indienen:Aanvraag indienen` : $localize`:@@wizard.volgende:Volgende`));
protected primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]);
});
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {