Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs

Checkpoint of in-progress work: the registration wizard (address prefill,
DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't-
validate value objects, infrastructure adapters, plus CLAUDE.md and the
architecture/ADR docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:23:52 +02:00
parent 8a8a2f0f29
commit 64385999eb
58 changed files with 7271 additions and 556 deletions

View File

@@ -35,6 +35,7 @@ export class AsyncErrorDirective {
selector: 'app-async',
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
template: `
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
@switch (rd().tag) {
@case ('Loading') {
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
@@ -60,6 +61,7 @@ export class AsyncErrorDirective {
[ngTemplateOutletContext]="{ $implicit: value() }" />
}
}
</div>
`,
})
export class AsyncComponent<T> {

View File

@@ -6,13 +6,13 @@ import { Component, input } from '@angular/core';
selector: 'app-form-field',
template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
<label class="utrecht-form-label" [for]="fieldId()">{{ label() }}</label>
<label class="utrecht-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
@if (description()) {
<div class="utrecht-form-field-description">{{ description() }}</div>
<div class="utrecht-form-field-description" [id]="fieldId() + '-desc'">{{ description() }}</div>
}
<ng-content />
@if (error()) {
<div class="utrecht-form-field-error-message" role="alert">{{ error() }}</div>
<div class="utrecht-form-field-error-message" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
}
</div>
`,

View File

@@ -10,10 +10,23 @@ export interface RadioOption {
form control so it works with ngModel just like the text-input atom. */
@Component({
selector: 'app-radio-group',
styles: [`
.rhc-radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
}
`],
template: `
<div class="utrecht-form-field-radio-group" role="radiogroup">
<div
class="utrecht-form-field-radio-group"
role="radiogroup"
[attr.aria-labelledby]="name() + '-label'"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() ? name() + '-error' : null">
@for (opt of options(); track opt.value) {
<label class="utrecht-form-label utrecht-form-label--radio-button" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0">
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
<input
class="utrecht-radio-button"
[class.utrecht-radio-button--checked]="value === opt.value"
@@ -34,6 +47,7 @@ export interface RadioOption {
export class RadioGroupComponent implements ControlValueAccessor {
options = input.required<RadioOption[]>();
name = input.required<string>();
invalid = input(false);
value = '';
disabled = false;

View File

@@ -6,15 +6,15 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
selector: 'app-skeleton',
styles: [`
:host{display:block}
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
margin-block-end:0.6rem}
.sk{background:linear-gradient(90deg,var(--rhc-color-cool-grey-200) 25%,var(--rhc-color-cool-grey-100) 37%,var(--rhc-color-cool-grey-200) 63%);
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);
margin-block-end:var(--rhc-space-max-lg)}
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
`],
template: `
@if (visible()) {
@for (l of lines(); track $index) {
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
<div class="sk" aria-hidden="true" [style.width]="width()" [style.height]="height()"></div>
}
}
`,

View File

@@ -6,10 +6,10 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
selector: 'app-spinner',
styles: [`
:host{display:block}
.sp{width:2rem;height:2rem;border-radius:50%;
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
animation:sp 0.8s linear infinite;margin:1.5rem auto}
.sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
border-block-start-color:var(--rhc-color-lintblauw-700);
animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}
@keyframes sp{to{transform:rotate(360deg)}}
`],
template: `

View File

@@ -5,8 +5,9 @@ import { Component, input } from '@angular/core';
This keeps the shared UI kernel free of any domain knowledge. */
@Component({
selector: 'app-status-badge',
styles: [`.badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}`],
template: `
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="badge">
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ label() }}</span>
</span>

View File

@@ -0,0 +1,46 @@
import { Component, input } from '@angular/core';
/** Molecule: a horizontal step indicator for a multi-step flow. Visual progress
plus accessible semantics — an ordered list with `aria-current="step"` on the
active step and a visually-hidden "Stap X van Y" summary. Domain-free. */
@Component({
selector: 'app-stepper',
styles: [`
:host{display:block}
.sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}
.step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}
.num{
display:grid;place-items:center;
inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);
border-radius:var(--rhc-border-radius-round);
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);
font-weight:var(--rhc-text-font-weight-bold);
}
.step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}
.step--current{color:var(--rhc-color-foreground-default)}
.step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}
.step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
`],
template: `
<nav aria-label="Voortgang">
<p class="sr-only">Stap {{ current() + 1 }} van {{ steps().length }}: {{ steps()[current()] }}</p>
<ol class="steps">
@for (label of steps(); track label; let i = $index) {
<li
class="step"
[class.step--current]="i === current()"
[class.step--done]="i < current()"
[attr.aria-current]="i === current() ? 'step' : null">
<span class="num" aria-hidden="true">{{ i + 1 }}</span>
<span class="label">{{ label }}</span>
</li>
}
</ol>
</nav>
`,
})
export class StepperComponent {
steps = input.required<string[]>();
current = input.required<number>();
}

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { StepperComponent } from './stepper.component';
const meta: Meta<StepperComponent> = {
title: 'Molecules/Stepper',
component: StepperComponent,
render: (args) => ({
props: args,
template: `<app-stepper [steps]="steps" [current]="current" />`,
}),
};
export default meta;
type Story = StoryObj<StepperComponent>;
const steps = ['Adres', 'Beroep', 'Controle'];
export const Eerste: Story = { args: { steps, current: 0 } };
export const Midden: Story = { args: { steps, current: 1 } };
export const Laatste: Story = { args: { steps, current: 2 } };

View File

@@ -10,6 +10,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
[class.utrecht-textbox--invalid]="invalid()"
[type]="type()"
[id]="inputId()"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null"
[placeholder]="placeholder()"
[disabled]="disabled"
[value]="value"