refactor: split concepts.page into 6 sections, fix dead highlighting (RD-24)

The page held six teaching sections and a 142-line `styles:` block, at 471
effective lines against a limit of 250. It is now 36 lines of composition.

Angular scopes a component's CSS to markup that component rendered, so the
split had to move each rule to its owner. `concept-card` owns the card
vocabulary and renders it. `.app-code`, `.app-lead`, `.app-cols` and
`.app-note` become globals, because their targets are projected or arrive
through `[innerHTML]`.

That constraint exposed a live bug. The syntax-highlighting rules compiled to
`pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]`, but `highlight-ts` injects the
`.k`/`.s`/`.c` spans through `[innerHTML]`, so they carry no scope attribute
and the rule never matched. Keywords, strings and comments have always
rendered in the plain foreground colour. The rules are global now, on five new
`--app-code-*` tokens.

Widen the colour guard while here: it scanned only `*.component.ts`, so every
`*.page.ts`, `*.section.ts` and `*.step.ts` was invisible to it. That is how
this page collected 21 hardcoded colours. One other file needed a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 23:44:01 +02:00
co-authored by Claude Sonnet 5
parent d5a7a25a78
commit 630d68045f
13 changed files with 908 additions and 488 deletions
@@ -0,0 +1,105 @@
import { Component, input } from '@angular/core';
/** Molecule-shaped teaching card: the showcase's "before/after" box. Renders its own
tag pill and, when `code` is set, the highlighted snippet — everything else comes in
via `<ng-content />`. Angular does not style projected content from the receiving
component, so any other card-shaped block inside a section (a sub-label, a nested
ok/err result) nests another `<app-concept-card>` rather than writing raw `.tag`
markup — that markup would carry the SECTION's scope, not this component's, and the
rule here would never match it. */
@Component({
selector: 'app-concept-card',
imports: [],
styles: [
`
.card {
border: 1px solid var(--rhc-color-grijs-200);
border-radius: 10px;
padding: 1.25rem;
background: var(--rhc-color-wit);
}
.card--bad {
border-color: var(--rhc-color-rood-300);
}
.card--good {
border-color: var(--rhc-color-groen-300);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
}
.tag.bad {
color: var(--rhc-color-rood-600);
}
.tag.bad::before {
background: var(--rhc-color-rood-500);
}
.tag.good {
color: var(--rhc-color-groen-700);
}
.tag.good::before {
background: var(--rhc-color-groen-500);
}
.tag.plain {
color: var(--rhc-color-grijs-700);
}
.tag.plain::before {
display: none;
}
.linked {
margin: 0 0 1rem;
}
.linked .src {
font-size: 0.72rem;
color: var(--rhc-color-grijs-700);
margin: 0.35rem 0 0;
font-family: monospace;
}
`,
],
template: `
<div
class="card"
[class.card--good]="variant() === 'good'"
[class.card--bad]="variant() === 'bad'"
>
<p
class="tag"
[class.good]="variant() === 'good'"
[class.bad]="variant() === 'bad'"
[class.plain]="variant() === 'plain'"
>
{{ tag() }}
</p>
@if (code(); as c) {
@if (src(); as s) {
<figure class="linked">
<pre class="app-code" [innerHTML]="c"></pre>
<figcaption class="src">↳ {{ s }}</figcaption>
</figure>
} @else {
<pre class="app-code" [innerHTML]="c"></pre>
}
}
<ng-content />
</div>
`,
})
export class ConceptCardComponent {
variant = input<'good' | 'bad' | 'plain'>('plain');
tag = input.required<string>();
code = input<string | undefined>();
src = input<string | undefined>();
}
+23 -482
View File
@@ -1,497 +1,38 @@
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core';
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
import { UnionsSection } from './unions.section';
import { RemoteDataSection } from './remote-data.section';
import { ParseSection } from './parse.section';
import { FormMachineSection } from './form-machine.section';
import { VragenlijstSection } from './vragenlijst.section';
import { PiiSection } from './pii.section';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
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: [
FormsModule,
PageShellComponent,
HeadingComponent,
TextInputComponent,
...ASYNC,
SkeletonComponent,
RegistrationSummaryComponent,
HerregistratieWizardComponent,
IntakeWizardComponent,
MaskedValueComponent,
],
styles: [
`
.section {
margin: 0 0 3rem;
}
.lead {
color: var(--rhc-color-grijs-700);
max-width: 46rem;
margin: 0.25rem 0 1.25rem;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
align-items: start;
}
.card {
border: 1px solid var(--rhc-color-grijs-200, #e5e5e5);
border-radius: 10px;
padding: 1.25rem;
background: #fff;
}
.card--bad {
border-color: var(--rhc-color-rood-300, #f0b4b4);
}
.card--good {
border-color: var(--rhc-color-groen-300, #b4e0b4);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
}
.tag.bad {
color: var(--rhc-color-rood-600, #a30000);
}
.tag.bad::before {
background: var(--rhc-color-rood-500, #d52b1e);
}
.tag.good {
color: var(--rhc-color-groen-700, #277337);
}
.tag.good::before {
background: var(--rhc-color-groen-500, #39870c);
}
.tag.plain {
color: var(--rhc-color-grijs-700);
}
.tag.plain::before {
display: none;
}
pre {
background: #1e2430;
color: #e6e9ef;
padding: 1rem;
border-radius: 8px;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
margin: 0;
}
pre .k {
color: #c792ea;
}
pre .s {
color: #c3e88d;
}
pre .c {
color: #7e8aa0;
font-style: italic;
}
.note {
font-size: 0.9rem;
color: var(--rhc-color-grijs-700);
margin: 0.75rem 0 0;
}
/* live state diagram */
.machine {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
.node {
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--rhc-color-grijs-300, #ccc);
font-size: 0.82rem;
color: var(--rhc-color-grijs-700);
transition: all 0.15s;
}
.node.on {
background: var(--rhc-color-hemelblauw-100, #e5f1fb);
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
color: var(--rhc-color-hemelblauw-700, #00567d);
font-weight: 700;
/* teaching motion: the active state pops as the wizard transitions (the .node
transition above animates it; reduced-motion is handled globally). */
transform: scale(1.06);
}
.linked {
margin: 0 0 1rem;
}
.linked .src {
font-size: 0.72rem;
color: var(--rhc-color-grijs-700);
margin: 0.35rem 0 0;
font-family: monospace;
}
.steplist {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0 0 1rem;
}
.pill {
padding: 0.3rem 0.7rem;
border-radius: 8px;
background: var(--rhc-color-grijs-100, #f3f3f3);
font-size: 0.8rem;
}
.pill.extra {
background: var(--rhc-color-geel-100, #fff6d6);
border: 1px dashed var(--rhc-color-geel-600, #c79a00);
}
.arrow {
color: var(--rhc-color-grijs-400, #999);
}
`,
UnionsSection,
RemoteDataSection,
ParseSection,
FormMachineSection,
VragenlijstSection,
PiiSection,
],
template: `
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
<p class="lead">
<p class="app-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).
</p>
<!-- 1. Discriminated unions -->
<section class="section">
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — vlakke interface</p>
<pre [innerHTML]="code['unionBad']"></pre>
<p class="note">
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
</p>
</div>
<div class="card card--good">
<p class="tag good">Goed — sum type</p>
<figure class="linked">
<pre [innerHTML]="code['union']"></pre>
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
</figure>
<app-registration-summary [reg]="doorgehaald" />
<p class="note">
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
simpelweg niet.
</p>
</div>
</div>
</section>
<!-- 2. RemoteData fold -->
<section class="section">
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<p class="lead">
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Vier toestanden, één molecuul</p>
<p class="tag plain">Loading</p>
<app-async [resource]="loadingRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
><ng-template appAsyncLoading
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
></app-async>
<p class="tag plain">Empty</p>
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
<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
><ul>
@for (i of successRes.value(); track i) {
<li>{{ i }}</li>
}
</ul></ng-template
></app-async
>
</div>
<div class="card card--good">
<p class="tag good">De exhaustieve fold</p>
<figure class="linked">
<pre [innerHTML]="code['fold']"></pre>
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
</figure>
<p class="note">
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
afhandelt.
</p>
</div>
</div>
</section>
<!-- 3. Parse, don't validate -->
<section class="section">
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
<div class="cols">
<div class="card">
<p class="tag good">Smart constructor → Result</p>
<figure class="linked">
<pre [innerHTML]="code['parse']"></pre>
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
</figure>
<app-text-input
inputId="pc"
[ngModel]="raw()"
(ngModelChange)="raw.set($event)"
name="pc"
placeholder="Typ een postcode, bijv. 1234 AB"
/>
</div>
@let r = parsed();
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<div animate.enter="app-item-enter">
<p class="tag good">ok</p>
<pre>Postcode ="{{ r.value }}"</pre>
<p class="note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p>
</div>
} @else {
<div animate.enter="app-item-enter">
<p class="tag bad">err</p>
<pre>{{ r.error }}</pre>
</div>
}
</div>
</div>
</section>
<!-- 4. State machine / wizard (live state diagram) -->
<section class="section">
<app-heading [level]="2">4 · Form als state machine</app-heading>
<p class="lead">
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
huidige.
</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — losse booleans</p>
<pre [innerHTML]="code['machineBad']"></pre>
<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>
<figure class="linked">
<pre [innerHTML]="code['machine']"></pre>
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
</figure>
<div class="machine">
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
}
</div>
<app-herregistratie-wizard #w />
</div>
</div>
</section>
<!-- 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
>
<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.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Vaste stappen</p>
<figure class="linked">
<pre [innerHTML]="code['steps']"></pre>
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
</figure>
<div class="steplist">
@for (s of iw.steps; track s; let last = $last) {
<span class="pill">{{ s }}</span>
@if (!last) {
<span class="arrow">→</span>
}
}
</div>
<p class="note">
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
of verdwijnen.
</p>
</div>
<div class="card card--good">
<p class="tag good">De wizard</p>
<app-intake-wizard #iw />
</div>
</div>
</section>
<!-- 6. PII: mask + parse -->
<section class="section">
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
<p class="lead">
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Maskeren — atom</p>
<p>
BSN:
<app-masked-value
[value]="bsnShown()"
[canReveal]="true"
revealLabel="Toon BSN"
(reveal)="bsnRevealed.set(true)"
/>
</p>
<figure class="linked">
<pre [innerHTML]="code['mask']"></pre>
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
</figure>
<p class="note">
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
bij elke gebruiker.
</p>
</div>
<div class="card">
<p class="tag good">Parse (elfproef) → Result</p>
<app-text-input
inputId="bsn"
[ngModel]="bsnRaw()"
(ngModelChange)="bsnRaw.set($event)"
name="bsn"
placeholder="Typ een BSN, bijv. 123456782"
/>
@let b = bsnParsed();
@if (bsnRaw()) {
<div animate.enter="app-item-enter">
@if (b.ok) {
<p class="tag good">ok</p>
<pre>Bsn ="{{ b.value }}"</pre>
} @else {
<p class="tag bad">err</p>
<pre>{{ b.error }}</pre>
}
</div>
}
<figure class="linked">
<pre [innerHTML]="code['parseBsn']"></pre>
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
</figure>
</div>
</div>
</section>
<app-concepts-unions-section />
<app-concepts-remote-data-section />
<app-concepts-parse-section />
<app-concepts-form-machine-section />
<app-concepts-vragenlijst-section />
<app-concepts-pii-section />
</app-page-shell>
`,
})
export class ConceptsPage {
isEmpty = (v: string[]) => !v || v.length === 0;
doorgehaald: Registration = {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
demoBsn = '123456782';
bsnRevealed = signal(false);
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
bsnRaw = signal('');
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
private readonly illustrations: Record<string, string> = {
unionBad: `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`,
machineBad: `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`,
};
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
by gen:snippets — they can't drift), the illustrations are authored above. */
protected readonly code: Record<string, string> = Object.fromEntries(
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
);
/** The real file each linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
union: 'registratie/domain/registration.ts',
fold: 'shared/application/remote-data.ts',
parse: 'registratie/domain/value-objects/postcode.ts',
machine: 'registratie/domain/change-request.machine.ts',
steps: 'herregistratie/domain/intake.machine.ts',
parseBsn: 'shared/kernel/bsn.ts',
mask: 'shared/kernel/pii.ts',
};
}
export class ConceptsPage {}
@@ -0,0 +1,87 @@
import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 4: form as a state machine, shown as a live state diagram. One tagged union
drives the UI — the marked state below is the wizard's current one. Composition-only. */
@Component({
selector: 'app-concepts-form-machine-section',
imports: [HeadingComponent, HerregistratieWizardComponent, ConceptCardComponent],
styles: [
`
.machine {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
.node {
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--rhc-color-grijs-300);
font-size: 0.82rem;
color: var(--rhc-color-grijs-700);
transition: all 0.15s;
}
.node.on {
background: var(--rhc-color-hemelblauw-100);
border-color: var(--rhc-color-hemelblauw-500);
color: var(--rhc-color-hemelblauw-700);
font-weight: 700;
/* teaching motion: the active state pops as the wizard transitions (the .node
transition above animates it; reduced-motion is handled globally). */
transform: scale(1.06);
}
`,
],
template: `
<section class="app-section">
<app-heading [level]="2">4 · Form als state machine</app-heading>
<p class="app-lead">
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.
</p>
<div class="app-cols">
<app-concept-card variant="bad" tag="Fout — losse booleans" [code]="code['machineBad']">
<p class="app-note">
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
</p>
</app-concept-card>
<app-concept-card
variant="good"
tag="Goed — één tagged union"
[code]="code['machine']"
[src]="src['machine']"
>
<div class="machine">
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
}
</div>
<app-herregistratie-wizard #w />
</app-concept-card>
</div>
</section>
`,
})
export class FormMachineSection {
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
private readonly machineBad = `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`;
/** Highlighted HTML per snippet: `machine` comes from SNIPPETS (extracted from source by
gen:snippets — it can't drift), `machineBad` is authored above. */
protected readonly code: Record<string, string> = {
machineBad: highlightTs(this.machineBad),
machine: highlightTs(SNIPPETS['machine']),
};
/** The real file the linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
machine: 'registratie/domain/change-request.machine.ts',
};
}
@@ -0,0 +1,60 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 3: parse, don't validate. After parsing, the TYPE remembers the value is valid.
Composition-only; owns its own postcode demo. */
@Component({
selector: 'app-concepts-parse-section',
imports: [FormsModule, HeadingComponent, TextInputComponent, ConceptCardComponent],
template: `
<section class="app-section">
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<p class="app-lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
<div class="app-cols">
<app-concept-card
tag="Smart constructor → Result"
[code]="code['parse']"
[src]="src['parse']"
>
<app-text-input
inputId="pc"
[ngModel]="raw()"
(ngModelChange)="raw.set($event)"
name="pc"
placeholder="Typ een postcode, bijv. 1234 AB"
/>
</app-concept-card>
@let r = parsed();
<app-concept-card [variant]="r.ok ? 'good' : 'bad'" [tag]="r.ok ? 'ok' : 'err'">
@if (r.ok) {
<div animate.enter="app-item-enter">
<pre class="app-code">Postcode ="{{ r.value }}"</pre>
<p class="app-note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p>
</div>
} @else {
<div animate.enter="app-item-enter">
<pre class="app-code">{{ r.error }}</pre>
</div>
}
</app-concept-card>
</div>
</section>
`,
})
export class ParseSection {
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
protected readonly code: Record<string, string> = { parse: highlightTs(SNIPPETS['parse']) };
protected readonly src: Record<string, string> = {
parse: 'registratie/domain/value-objects/postcode.ts',
};
}
+102
View File
@@ -0,0 +1,102 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 6: PII — masking and parsing. A BSN (Dutch citizen service number) is
special-category personal data (GDPR art. 9): masked by default, revealed only after
a logged action; "parse, don't validate" on the most sensitive field — a pure function
enforces the checksum. Composition-only. The ok/err result nests its own
`<app-concept-card>` for its label, for the same reason section 2 does. */
@Component({
selector: 'app-concepts-pii-section',
imports: [
FormsModule,
HeadingComponent,
TextInputComponent,
MaskedValueComponent,
ConceptCardComponent,
],
template: `
<section class="app-section">
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
<p class="app-lead">
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard gemaskeerd
tonen, alleen tonen na een vastgelegde handeling; en"parse, don't validate" op het
gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
</p>
<div class="app-cols">
<app-concept-card
variant="good"
tag="Maskeren — atom"
[code]="code['mask']"
[src]="src['mask']"
>
<p>
BSN:
<app-masked-value
[value]="bsnShown()"
[canReveal]="true"
revealLabel="Toon BSN"
(reveal)="bsnRevealed.set(true)"
/>
</p>
<p class="app-note">
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
bij elke gebruiker.
</p>
</app-concept-card>
<app-concept-card
tag="Parse (elfproef) → Result"
[code]="code['parseBsn']"
[src]="src['parseBsn']"
>
<app-text-input
inputId="bsn"
[ngModel]="bsnRaw()"
(ngModelChange)="bsnRaw.set($event)"
name="bsn"
placeholder="Typ een BSN, bijv. 123456782"
/>
@let b = bsnParsed();
@if (bsnRaw()) {
<div animate.enter="app-item-enter">
<app-concept-card [variant]="b.ok ? 'good' : 'bad'" [tag]="b.ok ? 'ok' : 'err'">
@if (b.ok) {
<pre class="app-code">Bsn ="{{ b.value }}"</pre>
} @else {
<pre class="app-code">{{ b.error }}</pre>
}
</app-concept-card>
</div>
}
</app-concept-card>
</div>
</section>
`,
})
export class PiiSection {
// Masked-by-default value that reveals locally (the real reveal is step-up-gated +
// audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
demoBsn = '123456782';
bsnRevealed = signal(false);
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
bsnRaw = signal('');
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
protected readonly code: Record<string, string> = {
mask: highlightTs(SNIPPETS['mask']),
parseBsn: highlightTs(SNIPPETS['parseBsn']),
};
protected readonly src: Record<string, string> = {
mask: 'shared/kernel/pii.ts',
parseBsn: 'shared/kernel/bsn.ts',
};
}
@@ -0,0 +1,92 @@
import { Component } from '@angular/core';
import type { Resource } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
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>;
}
/** Section 2: RemoteData fold. One value with four mutually exclusive states, instead of
three loose booleans. Composition-only; owns its own fake resources. Each of the four
demo states nests its own `<app-concept-card>` for its label — a plain `.tag` element
written here would carry this section's scope, not the card's, and stay unstyled. */
@Component({
selector: 'app-concepts-remote-data-section',
imports: [HeadingComponent, ...ASYNC, SkeletonComponent, ConceptCardComponent],
template: `
<section class="app-section">
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<p class="app-lead">
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
</p>
<div class="app-cols">
<app-concept-card variant="good" tag="Vier toestanden, één molecuul">
<div class="app-stack">
<app-concept-card variant="plain" tag="Loading">
<app-async [resource]="loadingRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
><ng-template appAsyncLoading
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
></app-async>
</app-concept-card>
<app-concept-card variant="plain" tag="Empty">
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
</app-concept-card>
<app-concept-card variant="plain" tag="Failure">
<app-async [resource]="errorRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
</app-concept-card>
<app-concept-card variant="plain" tag="Success">
<app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded
><ul>
@for (i of successRes.value(); track i) {
<li>{{ i }}</li>
}
</ul></ng-template
></app-async
>
</app-concept-card>
</div>
</app-concept-card>
<app-concept-card
variant="good"
tag="De exhaustieve fold"
[code]="code['fold']"
[src]="src['fold']"
>
<p class="app-note">
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
afhandelt.
</p>
</app-concept-card>
</div>
</section>
`,
})
export class RemoteDataSection {
isEmpty = (v: string[]) => !v || v.length === 0;
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
protected readonly code: Record<string, string> = { fold: highlightTs(SNIPPETS['fold']) };
protected readonly src: Record<string, string> = { fold: 'shared/application/remote-data.ts' };
}
@@ -0,0 +1,67 @@
import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { Registration } from '@registratie/domain/registration';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 1: discriminated unions. Each variant carries exactly the data that fits it —
nothing more. Composition-only; owns its own demo data. */
@Component({
selector: 'app-concepts-unions-section',
imports: [HeadingComponent, RegistrationSummaryComponent, ConceptCardComponent],
template: `
<section class="app-section">
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<p class="app-lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
<div class="app-cols">
<app-concept-card variant="bad" tag="Fout — vlakke interface" [code]="code['unionBad']">
<p class="app-note">
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
</p>
</app-concept-card>
<app-concept-card
variant="good"
tag="Goed — sum type"
[code]="code['union']"
[src]="src['union']"
>
<app-registration-summary [reg]="doorgehaald" />
<p class="app-note">
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
simpelweg niet.
</p>
</app-concept-card>
</div>
</section>
`,
})
export class UnionsSection {
doorgehaald: Registration = {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
private readonly unionBad = `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`;
/** Highlighted HTML per snippet: `union` comes from SNIPPETS (extracted from source by
gen:snippets — it can't drift), `unionBad` is authored above. */
protected readonly code: Record<string, string> = {
unionBad: highlightTs(this.unionBad),
union: highlightTs(SNIPPETS['union']),
};
/** The real file the linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
union: 'registratie/domain/registration.ts',
};
}
@@ -0,0 +1,81 @@
import { Component } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { ConceptCardComponent } from './concept-card.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Section 5: a questionnaire with a fixed step count — "show questions, don't add
steps". The step count never changes; follow-up questions reveal inline based on
earlier answers. Composition-only. */
@Component({
selector: 'app-concepts-vragenlijst-section',
imports: [HeadingComponent, IntakeWizardComponent, ConceptCardComponent],
styles: [
`
.steplist {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0 0 1rem;
}
.pill {
padding: 0.3rem 0.7rem;
border-radius: 8px;
background: var(--rhc-color-grijs-100);
font-size: 0.8rem;
}
.pill.extra {
background: var(--rhc-color-geel-100);
border: 1px dashed var(--rhc-color-geel-600);
}
.arrow {
color: var(--rhc-color-grijs-400);
}
`,
],
template: `
<section class="app-section">
<app-heading [level]="2"
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
>
<p class="app-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.
</p>
<div class="app-cols">
<app-concept-card
variant="good"
tag="Vaste stappen"
[code]="code['steps']"
[src]="src['steps']"
>
<div class="steplist">
@for (s of iw.steps; track s; let last = $last) {
<span class="pill">{{ s }}</span>
@if (!last) {
<span class="arrow">→</span>
}
}
</div>
<p class="app-note">
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
of verdwijnen.
</p>
</app-concept-card>
<app-concept-card variant="good" tag="De wizard">
<app-intake-wizard #iw />
</app-concept-card>
</div>
</section>
`,
})
export class VragenlijstSection {
protected readonly code: Record<string, string> = { steps: highlightTs(SNIPPETS['steps']) };
protected readonly src: Record<string, string> = {
steps: 'herregistratie/domain/intake.machine.ts',
};
}