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:
eho
2026-07-02 12:32:50 +02:00
co-authored by Claude Opus 4.8
parent 180c08d4ee
commit b443f1fc04
74 changed files with 4286 additions and 1116 deletions
@@ -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 ?? ''" />
@@ -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>
}
}
@@ -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 })"
+7 -7
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 [];
@@ -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 });
@@ -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" />
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+20 -6
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>
+1 -1
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"
+6 -7
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>
`,
+5 -4
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>
@@ -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>
@@ -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>
`,
})
@@ -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>
`,
@@ -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> }
}
`,
})
+2 -2
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>();
@@ -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"
@@ -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>
@@ -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>
`,
@@ -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"
@@ -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"
@@ -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>
+9 -9
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">
+5 -7
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>
+83 -7
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