feat(design): re-theme to CIBG Huisstijl (Bootstrap 5.2), replacing RHC/Utrecht

The portal now adheres to the CIBG design system (designsystem.cibg.nl) — a customized
Bootstrap 5.2 build — replacing the Rijkshuisstijl-Community / Utrecht theme. See ADR-0003.

- Vendor @cibg/huisstijl@3.22.0 under public/cibg-huisstijl/, loaded via a <link> in
  index.html (Storybook serves it via staticDirs). Drop the two @rijkshuisstijl-community deps.
- Token bridge in styles.scss: redefine the app's ~54 --rhc-* tokens onto CIBG/--bs-* values,
  so components keep referencing tokens (no 300+ site rewrite). System-font stack; licensed
  RO/Rijks text fonts intentionally not shipped.
- Re-skin every shared atom to Bootstrap/CIBG classes (btn, form-control, form-check-*,
  table, breadcrumb, …) keeping their input() APIs. alert is hand-rolled (CIBG drops .alert);
  local .card/.badge renamed to avoid Bootstrap collisions.
- Domain pages: drop stray rhc-*/utrecht-* classes; registration-table → table table-striped.

Verified: build green, check:tokens OK, lint clean, 174 tests pass, build-storybook OK,
and the served build loads the vendored CSS (200, .btn-primary present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:32:50 +02:00
parent 180c08d4ee
commit b443f1fc04
74 changed files with 4286 additions and 1116 deletions

View File

@@ -81,7 +81,7 @@ 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="rhc-data-summary rhc-data-summary--row app-section">
<dl class="app-section">
<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 ?? ''" />

View File

@@ -13,7 +13,7 @@ const TYPE_LABELS: Record<AanvraagType, string> = {
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
};
/** Organism: one application on the dashboard's "Mijn aanvragen" list. Which badge
/** Organism: one application on the dashboard's"Mijn aanvragen" list. Which badge
+ actions it shows is driven by the pure `blockActions(status)` and the status
tag; the block only renders. Composes card + status-badge + button. */
@Component({
@@ -32,20 +32,20 @@ const TYPE_LABELS: Record<AanvraagType, string> = {
@switch (aanvraag().status.tag) {
@case ('Concept') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.stap">Stap {{ stap().index }} van {{ stap().count }}</p>
<p i18n="@@aanvraagBlock.stap">Stap {{ stap().index }} van {{ stap().count }}</p>
}
@case ('InBehandeling') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.inBehandeling">Referentie {{ ref() }} · ingediend op {{ aanvraag().submittedAt | date: 'longDate' }}</p>
<p i18n="@@aanvraagBlock.inBehandeling">Referentie {{ ref() }} · ingediend op {{ aanvraag().submittedAt | date: 'longDate' }}</p>
@if (manual()) {
<p class="rhc-paragraph app-text-subtle" i18n="@@aanvraagBlock.manual">Uw aanvraag wordt handmatig beoordeeld in de backoffice.</p>
<p class="app-text-subtle" i18n="@@aanvraagBlock.manual">Uw aanvraag wordt handmatig beoordeeld in de backoffice.</p>
}
}
@case ('Goedgekeurd') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.goedgekeurd">Referentie {{ ref() }}</p>
<p i18n="@@aanvraagBlock.goedgekeurd">Referentie {{ ref() }}</p>
}
@case ('Afgewezen') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.afgewezen">Referentie {{ ref() }}</p>
<p class="rhc-paragraph app-text-subtle">{{ reden() }}</p>
<p i18n="@@aanvraagBlock.afgewezen">Referentie {{ ref() }}</p>
<p class="app-text-subtle">{{ reden() }}</p>
}
}

View File

@@ -25,8 +25,8 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
app-form-field + app-form-field{display:block;margin-block-start:var(--rhc-space-max-lg)}
`],
template: `
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
<legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend>
<fieldset>
<legend>{{ legend() }}</legend>
<app-form-field i18n-label="@@address.straat" label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" required [error]="errors().straat">
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"

View File

@@ -19,8 +19,8 @@ import { Registration } from '@registratie/domain/registration';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { tasksFromProfile } from '@registratie/domain/tasks';
/** Page: "Mijn overzicht" — the portal home, following the NL Design System
"Mijn omgeving" pattern (side nav + "Wat moet ik regelen" + "Mijn zaken"). */
/** Page:"Mijn overzicht" — the portal home, following the NL Design System
"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */
@Component({
selector: 'app-dashboard-page',
imports: [
@@ -58,7 +58,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
@if (tasks.length) {
<app-task-list class="app-section" [tasks]="tasks" />
} @else {
<p class="rhc-paragraph app-text-subtle" i18n="@@dashboard.nietsOpenstaan">U heeft op dit moment niets openstaan.</p>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">U heeft op dit moment niets openstaan.</p>
}
</section>
@@ -68,7 +68,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-registration-summary [reg]="$any(p).registration" />
</div>
<app-card class="app-section" i18n-heading="@@dashboard.persoonsgegevens" heading="Persoonsgegevens (BRP)">
<dl class="rhc-data-summary rhc-data-summary--row">
<dl>
<app-data-row i18n-key="@@dashboard.straat" key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row i18n-key="@@dashboard.postcode" key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row i18n-key="@@dashboard.woonplaats" key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
@@ -92,7 +92,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="rhc-paragraph app-text-subtle" i18n="@@dashboard.geenSpecialismen">U heeft nog geen specialismen of aantekeningen.</p>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template>
</app-async>
</div>
@@ -104,7 +104,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
@for (a of acties; track a.to) {
<li>
<app-card [heading]="a.titel">
<p class="rhc-paragraph">{{ a.tekst }}</p>
<p>{{ a.tekst }}</p>
<app-link [to]="a.to">{{ a.actie }} →</app-link>
</app-card>
</li>
@@ -128,7 +128,7 @@ export class DashboardPage {
}
/** The user's applications, sorted Concept → In behandeling → resolved. Empty →
the "Mijn aanvragen" section is hidden (see template). */
the"Mijn aanvragen" section is hidden (see template). */
protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.apps.applications();
if (rd.tag !== 'Success') return [];

View File

@@ -38,8 +38,8 @@ const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
];
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
/** The server-owned geldigheidsvraag whose "ja" answer requires a Dutch-taalvaardigheid
const HANDMATIG = '__handmatig__'; // sentinel option:"my diploma isn't listed"
/** The server-owned geldigheidsvraag whose"ja" answer requires a Dutch-taalvaardigheid
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
@@ -47,7 +47,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
draft via an effect; the DUO diploma list renders through <app-async>; choosing
a diploma reveals its server-derived beroep. The draft is persisted to the
backend as a Concept aanvraag (createDraftSync) so a reload — or a "Verder gaan"
backend as a Concept aanvraag (createDraftSync) so a reload — or a"Verder gaan"
from the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
atoms/molecules. */
@Component({
@@ -119,7 +119,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<dl class="app-section">
<app-data-row i18n-key="@@regWizard.beroepAfgeleid" key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
</dl>
}
@@ -156,7 +156,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
}
@case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer">Controleer uw gegevens en dien de registratie in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<dl class="app-section">
<app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()" />
<app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()" />
@@ -274,7 +274,7 @@ export class RegistratieWizardComponent {
protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
/** BRP lookup outcome. A failure or"geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
@@ -311,7 +311,7 @@ export class RegistratieWizardComponent {
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
/** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
protected diplomaOptions = (data: DuoLookupDto) => [
@@ -384,7 +384,7 @@ export class RegistratieWizardComponent {
}
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */
restart() {
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
this.dispatch({ tag: 'Seed', state: initial });

View File

@@ -12,7 +12,7 @@ import { CardComponent } from '@shared/ui/card/card.component';
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent],
template: `
<app-card>
<dl class="rhc-data-summary rhc-data-summary--row">
<dl>
<app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer" />
<app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam" />
<app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep" />

View File

@@ -7,21 +7,21 @@ import { Aantekening } from '@registratie/domain/registration';
selector: 'app-registration-table',
imports: [DatePipe],
template: `
<div class="utrecht-table-container utrecht-table-container--overflow-inline">
<table class="utrecht-table utrecht-table--html-table utrecht-table--alternate-row-color">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th class="utrecht-table__header-cell" i18n="@@table.type">Type</th>
<th class="utrecht-table__header-cell" i18n="@@table.omschrijving">Omschrijving</th>
<th class="utrecht-table__header-cell" i18n="@@table.datum">Datum</th>
<th scope="col" i18n="@@table.type">Type</th>
<th scope="col" i18n="@@table.omschrijving">Omschrijving</th>
<th scope="col" i18n="@@table.datum">Datum</th>
</tr>
</thead>
<tbody>
@for (row of rows(); track row.omschrijving) {
<tr>
<td class="utrecht-table__cell">{{ row.type }}</td>
<td class="utrecht-table__cell">{{ row.omschrijving }}</td>
<td class="utrecht-table__cell">{{ row.datum | date:'mediumDate' }}</td>
<td>{{ row.type }}</td>
<td>{{ row.omschrijving }}</td>
<td>{{ row.datum | date:'mediumDate' }}</td>
</tr>
}
</tbody>

View File

@@ -26,10 +26,10 @@ export interface BreadcrumbItem {
:host(.inverse) .sep { color: var(--rhc-color-foreground-on-primary); }
`],
template: `
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
<ol class="utrecht-breadcrumb-nav__list list">
<nav i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
<ol class="list">
@for (item of items(); track item.label; let last = $last; let first = $first) {
<li class="utrecht-breadcrumb-nav__item crumb">
<li class="crumb">
@if (!first) { <span class="sep" aria-hidden="true"></span> }
@if (item.link && !last) {
<a [routerLink]="item.link">{{ item.label }}</a>

View File

@@ -22,7 +22,7 @@ import { LinkComponent } from '@shared/ui/link/link.component';
}
<app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) {
<p class="rhc-paragraph intro">{{ intro() }}</p>
<p class="intro">{{ intro() }}</p>
}
<ng-content />
</div>

View File

@@ -11,16 +11,17 @@ import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.componen
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent, DebugStateComponent],
styles: [`
:host{display:block}
.skip{position:absolute;left:var(--app-skip-link-offset)}
.layout{min-block-size:100vh;align-items:stretch}
.skip{position:absolute;left:var(--app-skip-link-offset);z-index:1030}
.skip:focus{left:var(--rhc-space-max-md);top:var(--rhc-space-max-md);background:var(--rhc-color-wit);padding:var(--rhc-space-max-sm) var(--rhc-space-max-md);border-radius:var(--rhc-border-radius-sm)}
.layout{display:flex;flex-direction:column;min-block-size:100vh}
.main{flex:1;inline-size:100%;box-sizing:border-box}
.content{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}
`],
template: `
<a href="#main" class="rhc-skip-link skip" i18n="@@shell.skipLink">Naar de inhoud</a>
<div class="utrecht-page-layout utrecht-page-layout--stretch layout">
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
<div class="layout">
<app-site-header />
<main id="main" class="utrecht-page-content main">
<main id="main" class="main">
<div class="content">
<router-outlet />
</div>

View File

@@ -19,7 +19,7 @@ import { Component } from '@angular/core';
.meta{inline-size:100%;border-block-start:var(--rhc-border-width-sm) solid var(--rhc-color-lintblauw-600);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}
`],
template: `
<footer class="utrecht-page-footer bar">
<footer class="bar">
<div class="inner">
<div>
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>

View File

@@ -35,11 +35,11 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
@switch (status()) {
@case ('editing') {
<app-stepper class="app-section" [steps]="steps()" [current]="current()" />
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
<h2 #stepHeading tabindex="-1" class="app-section">{{ stepTitle() }}</h2>
@if (errors().length) {
<div #errorSummary tabindex="-1" role="alert" aria-labelledby="wizard-error-title" class="app-section">
<app-alert type="error">
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title" i18n="@@wizard.errorTitle">Er ging iets mis met uw invoer</h3>
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">Er ging iets mis met uw invoer</h3>
<ul class="es-list">
@for (e of errors(); track e.id) {
<li><a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a></li>

View File

@@ -2,16 +2,30 @@ import { Component, input } from '@angular/core';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
/** Atom: alert/message banner. */
/** Atom: alert/message banner. CIBG's Bootstrap build drops the stock `.alert`, so this
is a hand-rolled surface styled from the (CIBG-valued) token bridge — no raw hex. */
@Component({
selector: 'app-alert',
styles: [`
.alert-box{
border:var(--rhc-border-width-sm) solid;
border-inline-start-width:var(--rhc-border-width-md);
border-radius:var(--rhc-border-radius-md);
padding:var(--rhc-space-max-md) var(--rhc-space-max-lg);
color:var(--rhc-color-foreground-default);
}
.alert-info{background:var(--rhc-color-hemelblauw-100);border-color:var(--rhc-color-foreground-link)}
.alert-ok{background:var(--rhc-color-groen-300);border-color:var(--rhc-color-groen-500)}
.alert-warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-geel-600)}
.alert-error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-rood-500)}
`],
template: `
<div
class="utrecht-alert rhc-alert"
[class.utrecht-alert--info]="type() === 'info'"
[class.utrecht-alert--ok]="type() === 'ok'"
[class.utrecht-alert--warning]="type() === 'warning'"
[class.utrecht-alert--error]="type() === 'error'"
class="alert-box"
[class.alert-info]="type() === 'info'"
[class.alert-ok]="type() === 'ok'"
[class.alert-warning]="type() === 'warning'"
[class.alert-error]="type() === 'error'"
role="status">
<ng-content />
</div>

View File

@@ -54,7 +54,7 @@ export class AsyncErrorDirective {
}
@case ('Empty') {
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
@else { <p class="rhc-paragraph">{{ emptyText() }}</p> }
@else { <p>{{ emptyText() }}</p> }
}
@case ('Success') {
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"

View File

@@ -2,19 +2,18 @@ import { Component, input } from '@angular/core';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
/** Atom: button. Thin wrapper over the Utrecht/RHC button CSS. */
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
@Component({
selector: 'app-button',
template: `
<button
[type]="type()"
[disabled]="disabled()"
class="utrecht-button rhc-button"
[class.utrecht-button--primary-action]="variant() === 'primary'"
[class.utrecht-button--secondary-action]="variant() === 'secondary'"
[class.utrecht-button--subtle]="variant() === 'subtle'"
[class.utrecht-button--danger]="variant() === 'danger'"
[class.utrecht-button--disabled]="disabled()">
class="btn"
[class.btn-primary]="variant() === 'primary'"
[class.btn-outline-primary]="variant() === 'secondary'"
[class.btn-link]="variant() === 'subtle'"
[class.btn-danger]="variant() === 'danger'">
<ng-content />
</button>
`,

View File

@@ -3,13 +3,14 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
/** Molecule: a content card. Standardises the repeated card surface (white,
subtle border, rounded, padded) so pages compose cards instead of hand-rolling
`<div class="rhc-card">`. Optional heading; the rest is projected. */
a hand-rolled card surface. Optional heading; the rest is projected.
Local class is .app-card (NOT Bootstrap's .card, whose padding-on-body layout differs). */
@Component({
selector: 'app-card',
imports: [HeadingComponent],
styles: [`
:host{display:block;block-size:100%}
.card{
.app-card{
background:var(--rhc-color-wit);
border:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius:var(--rhc-border-radius-md);
@@ -17,10 +18,10 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
block-size:100%;
box-sizing:border-box;
}
.card > * + *{margin-block-start:var(--rhc-space-max-md)}
.app-card > * + *{margin-block-start:var(--rhc-space-max-md)}
`],
template: `
<section class="card">
<section class="app-card">
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
<ng-content />
</section>

View File

@@ -8,11 +8,12 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
styles: [`
:host{display:block}
label{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md);cursor:pointer}
input{inline-size:1.1rem;block-size:1.1rem}
/* form-check-input floats/margins for the .form-check layout; here it's flex-aligned. */
.form-check-input{margin:0;float:none;inline-size:1.1rem;block-size:1.1rem}
`],
template: `
<label>
<input type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
<label class="form-check-label">
<input class="form-check-input" type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
(change)="onToggle($event)" (blur)="onTouched()" />
<span>{{ label() }}</span>
</label>

View File

@@ -1,16 +1,27 @@
import { Component, input } from '@angular/core';
/** Molecule: one key/value row inside an RHC data-summary.
Wrap several of these in .rhc-data-summary (see registration-summary). */
/** Molecule: one key/value row in a data summary. Hand-rolled from tokens
(CIBG has no data-summary component). Wrap several in a <dl> (see registration-summary). */
@Component({
selector: 'app-data-row',
// The RHC item draws a divider below every row; drop it on the last one so the
// list ends cleanly instead of looking like a trailing empty row.
styles: [`:host:last-child .rhc-data-summary__item { border-block-end-style: none; }`],
styles: [`
.item{
display:grid;
grid-template-columns:minmax(8rem, 14rem) 1fr;
gap:var(--rhc-space-max-md);
padding-block:var(--rhc-space-max-md);
border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
margin:0;
}
/* drop the divider on the last row so the list ends cleanly */
:host:last-child .item{border-block-end-style:none}
.item-key{font-weight:var(--rhc-text-font-weight-semi-bold);margin:0}
.item-value{margin:0}
`],
template: `
<div class="rhc-data-summary__item">
<dt class="rhc-data-summary__item-key">{{ key() }}</dt>
<dd class="rhc-data-summary__item-value"><ng-content>{{ value() }}</ng-content></dd>
<div class="item">
<dt class="item-key">{{ key() }}</dt>
<dd class="item-value"><ng-content>{{ value() }}</ng-content></dd>
</div>
`,
})

View File

@@ -12,16 +12,16 @@ import { Component, booleanAttribute, input } from '@angular/core';
.error{color:var(--rhc-color-foreground-default);font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-start:var(--rhc-space-max-sm);border-inline-start:var(--rhc-border-width-md) solid var(--rhc-color-rood-500);padding-inline-start:var(--rhc-space-max-md)}
`],
template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
<label class="utrecht-form-label label" [id]="fieldId() + '-label'" [for]="fieldId()">
<div class="form-field">
<label class="form-label label" [id]="fieldId() + '-label'" [for]="fieldId()">
{{ label() }}@if (required()) { <span class="req" i18n="@@formField.verplicht">(verplicht)</span> }
</label>
@if (description()) {
<div class="utrecht-form-field-description desc" [id]="fieldId() + '-desc'">{{ description() }}</div>
<div class="form-text desc" [id]="fieldId() + '-desc'">{{ description() }}</div>
}
<ng-content />
@if (error()) {
<div class="utrecht-form-field-error-message error" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
<div class="error" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
}
</div>
`,

View File

@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
/** Atom: heading. Renders the right h1..h5 with RHC heading styling.
/** Atom: heading. Renders the right h1..h5; CIBG/Bootstrap styles native headings.
Single <ng-content> captured in a template — multiple ng-content across
@switch branches silently drops the projected content. */
@Component({
@@ -10,11 +10,11 @@ import { NgTemplateOutlet } from '@angular/common';
template: `
<ng-template #content><ng-content /></ng-template>
@switch (level()) {
@case (1) { <h1 class="rhc-heading nl-heading--level-1"><ng-container [ngTemplateOutlet]="content" /></h1> }
@case (2) { <h2 class="rhc-heading nl-heading--level-2"><ng-container [ngTemplateOutlet]="content" /></h2> }
@case (3) { <h3 class="rhc-heading nl-heading--level-3"><ng-container [ngTemplateOutlet]="content" /></h3> }
@case (4) { <h4 class="rhc-heading nl-heading--level-4"><ng-container [ngTemplateOutlet]="content" /></h4> }
@default { <h5 class="rhc-heading nl-heading--level-5"><ng-container [ngTemplateOutlet]="content" /></h5> }
@case (1) { <h1><ng-container [ngTemplateOutlet]="content" /></h1> }
@case (2) { <h2><ng-container [ngTemplateOutlet]="content" /></h2> }
@case (3) { <h3><ng-container [ngTemplateOutlet]="content" /></h3> }
@case (4) { <h4><ng-container [ngTemplateOutlet]="content" /></h4> }
@default { <h5><ng-container [ngTemplateOutlet]="content" /></h5> }
}
`,
})

View File

@@ -1,11 +1,11 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Atom: link. Internal router link styled as an RHC/Utrecht link. */
/** Atom: link. Internal router link; CIBG/Bootstrap styles bare anchors. */
@Component({
selector: 'app-link',
imports: [RouterLink],
template: `<a [routerLink]="to()" class="rhc-link nl-link utrecht-link"><ng-content /></a>`,
template: `<a [routerLink]="to()" class="link-primary"><ng-content /></a>`,
})
export class LinkComponent {
to = input.required<string>();

View File

@@ -18,25 +18,27 @@ export const JA_NEE: RadioOption[] = [
@Component({
selector: 'app-radio-group',
styles: [`
.rhc-radio-option {
.radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
margin: 0;
}
/* form-check-input floats/margins for the .form-check layout; here it's flex-aligned. */
.radio-option .form-check-input { margin: 0; float: none; }
`],
template: `
<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 rhc-radio-option">
<label class="form-check-label radio-option">
<input
class="utrecht-radio-button"
[class.utrecht-radio-button--checked]="value === opt.value"
class="form-check-input"
[class.is-invalid]="invalid()"
type="radio"
[name]="name()"
[value]="opt.value"

View File

@@ -52,12 +52,12 @@ export interface PlaceholderOption {
template: `
@if (editable()) {
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
<span class="rte-sep" aria-hidden="true"></span>
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="list('bullet')" [attr.aria-label]="bulletListLabel()">•</button>
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="list('number')" [attr.aria-label]="numberListLabel()">1.</button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="list('bullet')" [attr.aria-label]="bulletListLabel()">•</button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="list('number')" [attr.aria-label]="numberListLabel()">1.</button>
@if (placeholders().length) {
<span class="rte-sep" aria-hidden="true"></span>
<label>

View File

@@ -5,10 +5,14 @@ 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)}`],
// Local class is NOT Bootstrap's .badge (which would add pill padding/colour) — hence .status-badge.
styles: [`
.status-badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}
.dot{inline-size:0.75rem;block-size:0.75rem;border-radius:var(--rhc-border-radius-round);flex:none}
`],
template: `
<span class="badge">
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span class="status-badge">
<span class="dot" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ label() }}</span>
</span>
`,

View File

@@ -10,8 +10,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
`],
template: `
<input
class="utrecht-textbox rhc-text-input"
[class.utrecht-textbox--invalid]="invalid()"
class="form-control"
[class.is-invalid]="invalid()"
[type]="type()"
[id]="inputId()"
[attr.aria-invalid]="invalid() ? 'true' : null"

View File

@@ -6,20 +6,21 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
@Component({
selector: 'app-delivery-channel-toggle',
styles: [`
.rhc-radio-option {
.radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
margin: 0;
}
.radio-option .form-check-input { margin: 0; float: none; }
`],
template: `
<div class="utrecht-form-field-radio-group" role="radiogroup">
<div role="radiogroup">
@for (opt of options; track opt.value) {
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
<label class="form-check-label radio-option">
<input
class="utrecht-radio-button"
[class.utrecht-radio-button--checked]="channel() === opt.value"
class="form-check-input"
type="radio"
[name]="name()"
[value]="opt.value"

View File

@@ -35,9 +35,7 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
[multiple]="multiple()"
[disabled]="disabled()"
(change)="onChange($event)" />
<span
class="utrecht-button rhc-button utrecht-button--secondary-action label"
[class.utrecht-button--disabled]="disabled()">
<span class="btn btn-outline-primary label" [class.disabled]="disabled()">
<span aria-hidden="true">↑</span>
<span>{{ label() }}</span>
</span>

View File

@@ -17,8 +17,8 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
return { value: () => value as T, status: () => status, error: () => error, hasValue: () => value !== undefined, reload: () => {} } as unknown as Resource<T>;
}
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
with the "after" where the type system rules it out. Composition-only. */
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
with the"after" where the type system rules it out. Composition-only. */
@Component({
selector: 'app-concepts-page',
imports: [
@@ -53,7 +53,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
<p class="lead">
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens
"fout" (de oude vorm liet het toe) naast "goed" (het type maakt het onmogelijk).
"fout" (de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
</p>
<!-- 1. Discriminated unions -->
@@ -88,7 +88,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<p class="tag plain">Failure</p>
<app-async [resource]="errorRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
<p class="tag plain">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v><ul class="rhc-unordered-list">@for (i of v; track i) {<li>{{ i }}</li>}</ul></ng-template></app-async>
<app-async [resource]="successRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v><ul>@for (i of v; track i) {<li>{{ i }}</li>}</ul></ng-template></app-async>
</div>
<div class="card card--good">
<p class="tag good">De exhaustieve fold</p>
@@ -110,7 +110,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<div class="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok">
@if (parsed().ok) {
<p class="tag good">ok</p>
<pre>Postcode = "{{ $any(parsed()).value }}"</pre>
<pre>Postcode ="{{ $any(parsed()).value }}"</pre>
<p class="note">Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.</p>
} @else {
<p class="tag bad">err</p>
@@ -128,7 +128,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<div class="card card--bad">
<p class="tag bad">Fout — losse booleans</p>
<pre [innerHTML]="machineBad"></pre>
<p class="note">Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.</p>
<p class="note">Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.</p>
</div>
<div class="card card--good">
<p class="tag good">Goed — één tagged union</p>
@@ -144,11 +144,11 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<!-- 5. Fixed steps, questions revealed inline -->
<section class="section">
<app-heading [level]="2">5 · Vragenlijst met vaste stappen — "vragen tonen, niet stappen toevoegen"</app-heading>
<app-heading [level]="2">5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading>
<p class="lead">
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen <em>binnen</em> een stap
op basis van eerdere antwoorden. Antwoord "ja" op buitenland of vul weinig uren in, en er komt een
extra vraag bij in dezelfde stap — de voortgang "van N" blijft gelijk.
op basis van eerdere antwoorden. Antwoord"ja" op buitenland of vul weinig uren in, en er komt een
extra vraag bij in dezelfde stap — de voortgang"van N" blijft gelijk.
</p>
<div class="cols">
<div class="card card--good">

View File

@@ -6,14 +6,12 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Free substitute for RijksSansVF (the licensed Rijksoverheid font). The
design tokens already fall back to 'Fira Sans'. ponytail: no @font-face dance. -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- CIBG Huisstijl (customized Bootstrap 5.2). Loaded as a <link> so its relative
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css">
</head>
<!-- rhc-theme = component tokens, lintblauw = Rijksoverheid lint-blue palette -->
<body class="rhc-theme lintblauw utrecht-document">
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,12 +1,88 @@
/* Rijkshuisstijl design tokens (already themed) + Utrecht/RHC component CSS.
ponytail: we import the published CSS instead of hand-writing styles — the
whole point is that the design system gives us Rijkshuisstijl for free. */
@import '@rijkshuisstijl-community/design-tokens/dist/index.css'; /* .rhc-theme tokens */
@import '@rijkshuisstijl-community/design-tokens/dist/lintblauw/index.css'; /* .lintblauw palette */
@import '@rijkshuisstijl-community/components-css/dist/index.css'; /* component classes */
/* CIBG Huisstijl (a customized Bootstrap 5.2 build) is loaded as a plain <link> in
index.html (public/cibg-huisstijl/css/huisstijl.min.css) so its relative url(../fonts|
icons|images) references resolve at runtime.
Token bridge: the app's components reference a semantic `--rhc-*` token vocabulary
(316 refs, 54 tokens). Rather than rewrite every reference, we redefine those tokens
HERE onto CIBG/Bootstrap values (--bs-* where one exists, CIBG palette hex otherwise).
The names are now an internal alias set; the VALUES are CIBG. (styles.scss is exempt
from check:tokens, so palette hex is allowed in this one file.) */
:root {
/* — semantic foreground/background/border — */
--rhc-color-foreground-default: var(--bs-body-color);
--rhc-color-foreground-subtle: #617286; /* CIBG meta grey */
--rhc-color-foreground-link: var(--bs-link-color); /* #01689b */
--rhc-color-foreground-link-hover: #154273; /* CIBG active/hover */
--rhc-color-foreground-on-primary: #fff;
--rhc-color-border-default: var(--bs-border-color);
--rhc-color-border-subtle: #e2e8f0;
--rhc-color-border-strong: #adb5bd;
--rhc-color-wit: #fff;
/* — greys — */
--rhc-color-cool-grey-100: #f8fafc;
--rhc-color-cool-grey-200: #f1f5f9;
--rhc-color-cool-grey-300: #e2e8f0;
--rhc-color-grijs-100: #f1f5f9;
--rhc-color-grijs-200: #e2e8f0;
--rhc-color-grijs-300: #cbd5e1;
--rhc-color-grijs-400: #94a3b8;
--rhc-color-grijs-700: #334155;
/* — blues (CIBG primary family) — */
--rhc-color-lintblauw-100: #e6eef3;
--rhc-color-lintblauw-500: #01689b;
--rhc-color-lintblauw-600: #015a87;
--rhc-color-lintblauw-700: #154273;
--rhc-color-hemelblauw-100: #e6f0f5;
--rhc-color-hemelblauw-500: #01689b;
--rhc-color-hemelblauw-700: #154273;
--rhc-color-donkerblauw-700: #154273;
/* — status colors (CIBG semantic) — */
--rhc-color-groen-300: #e3f0d8;
--rhc-color-groen-500: #39870c;
--rhc-color-groen-700: #2a6609;
--rhc-color-rood-100: #fbe4e2;
--rhc-color-rood-300: #f0a9a3;
--rhc-color-rood-500: #d52b1e;
--rhc-color-rood-600: #b81f14;
--rhc-color-geel-100: #fff4d6;
--rhc-color-geel-600: #ffb612;
--rhc-color-oranje-500: #e17000;
/* — spacing scale (rem, Bootstrap-aligned) — */
--rhc-space-max-xs: 0.25rem;
--rhc-space-max-sm: 0.5rem;
--rhc-space-max-md: 1rem;
--rhc-space-max-lg: 1.5rem;
--rhc-space-max-xl: 2rem;
--rhc-space-max-2xl: 3rem;
--rhc-space-max-3xl: 4rem;
--rhc-space-max-4xl: 5rem;
--rhc-space-max-5xl: 6rem;
/* — border — */
--rhc-border-radius-sm: var(--bs-border-radius-sm);
--rhc-border-radius-md: var(--bs-border-radius);
--rhc-border-radius-round: 50%;
--rhc-border-width-sm: 1px;
--rhc-border-width-md: 2px;
/* — typography — */
--rhc-text-font-size-sm: 0.875rem;
--rhc-text-font-size-lg: 1.25rem;
--rhc-text-font-weight-regular: 400;
--rhc-text-font-weight-semi-bold: 600;
--rhc-text-font-weight-bold: 700;
--rhc-text-line-height-sm: 1.4;
/* System-font stack (licensed Rijksoverheid fonts intentionally not bundled — POC). */
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
html, body { margin: 0; min-height: 100%; }
/* Rijkshuisstijl pages sit on white; cards/panels carry the subtle grey. */
/* Pages sit on white; cards/panels carry the subtle grey. */
body { background: var(--rhc-color-wit); color: var(--rhc-color-foreground-default); }
/* App theme layer: a few app-specific measures RHC has no token for (content/form