diff --git a/apps/ssp/src/app/showcase/concept-card.component.ts b/apps/ssp/src/app/showcase/concept-card.component.ts new file mode 100644 index 0000000..4623697 --- /dev/null +++ b/apps/ssp/src/app/showcase/concept-card.component.ts @@ -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 ``. 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 `` 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: ` +
+

+ {{ tag() }} +

+ @if (code(); as c) { + @if (src(); as s) { +
+

+            
↳ {{ s }}
+
+ } @else { +

+        }
+      }
+      
+    
+ `, +}) +export class ConceptCardComponent { + variant = input<'good' | 'bad' | 'plain'>('plain'); + tag = input.required(); + code = input(); + src = input(); +} diff --git a/apps/ssp/src/app/showcase/concepts.page.ts b/apps/ssp/src/app/showcase/concepts.page.ts index 829a216..9306828 100644 --- a/apps/ssp/src/app/showcase/concepts.page.ts +++ b/apps/ssp/src/app/showcase/concepts.page.ts @@ -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 can be driven through every state without HTTP. */ -function fakeResource(status: string, value?: T, error?: Error): Resource { - return { - value: () => value as T, - status: () => status, - error: () => error, - hasValue: () => value !== undefined, - reload: () => {}, - } as unknown as Resource; -} - -/** 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: ` -

+

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).

- - -
- 1 · Discriminated unions -

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

-
-
-

Fout — vlakke interface

-

-            

- Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand. -

-
-
-

Goed — sum type

-
-

-              
↳ {{ src['union'] }}
-
- -

- De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat - simpelweg niet. -

-
-
-
- - -
- 2 · RemoteData fold -

- Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans. -

-
-
-

Vier toestanden, één molecuul

-

Loading

- {{ v }} -

Empty

- {{ v }} -

Failure

- {{ v }} -

Success

-
    - @for (i of successRes.value(); track i) { -
  • {{ i }}
  • - } -
-
-
-

De exhaustieve fold

-
-

-              
↳ {{ src['fold'] }}
-
-

- Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem - afhandelt. -

-
-
-
- - -
- 3 · Parse, don't validate -

Na het parsen onthoudt het type dat de waarde geldig is.

-
-
-

Smart constructor → Result

-
-

-              
↳ {{ src['parse'] }}
-
- -
- @let r = parsed(); -
- @if (r.ok) { -
-

ok

-
Postcode ="{{ r.value }}"
-

- Een gevalideerde Postcode is een ander type dan een ruwe string. -

-
- } @else { -
-

err

-
{{ r.error }}
-
- } -
-
-
- - -
- 4 · Form als state machine -

- Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de - huidige. -

-
-
-

Fout — losse booleans

-

-            

- Niets verhindert"submitting" mét validatiefouten of een successcherm met errors. -

-
-
-

Goed — één tagged union

-
-

-              
↳ {{ src['machine'] }}
-
-
- @for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) { - {{ n }} - } -
- -
-
-
- - -
- 5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen" -

- Het aantal stappen ligt vast (STEPS); vervolgvragen verschijnen - binnen 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. -

-
-
-

Vaste stappen

-
-

-              
↳ {{ src['steps'] }}
-
-
- @for (s of iw.steps; track s; let last = $last) { - {{ s }} - @if (!last) { - - } - } -
-

- De stappen zijn altijd dezelfde; alleen de vragen binnen een stap verschijnen - of verdwijnen. -

-
-
-

De wizard

- -
-
-
- - -
- 6 · PII — maskeren & parsen -

- 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 elfproef afdwingt. -

-
-
-

Maskeren — atom

-

- BSN: - -

-
-

-              
↳ {{ src['mask'] }}
-
-

- Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het - behandelscherm). De atom bevat de maskeer-detectie — geen los *-gesnuffel - bij elke gebruiker. -

-
-
-

Parse (elfproef) → Result

- - @let b = bsnParsed(); - @if (bsnRaw()) { -
- @if (b.ok) { -

ok

-
Bsn ="{{ b.value }}"
- } @else { -

err

-
{{ b.error }}
- } -
- } -
-

-              
↳ {{ src['parseBsn'] }}
-
-
-
-
+ + + + + +
`, }) -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('loading'); - emptyRes = fakeResource('resolved', []); - errorRes = fakeResource('error', undefined, new Error('Demo')); - successRes = fakeResource('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 = { - 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 = 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 = { - 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 {} diff --git a/apps/ssp/src/app/showcase/form-machine.section.ts b/apps/ssp/src/app/showcase/form-machine.section.ts new file mode 100644 index 0000000..ae534ba --- /dev/null +++ b/apps/ssp/src/app/showcase/form-machine.section.ts @@ -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: ` +
+ 4 · Form als state machine +

+ Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige. +

+
+ +

+ Niets verhindert"submitting" mét validatiefouten of een successcherm met errors. +

+
+ +
+ @for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) { + {{ n }} + } +
+ +
+
+
+ `, +}) +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 = { + 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 = { + machine: 'registratie/domain/change-request.machine.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/parse.section.ts b/apps/ssp/src/app/showcase/parse.section.ts new file mode 100644 index 0000000..ac8aed0 --- /dev/null +++ b/apps/ssp/src/app/showcase/parse.section.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: ` +
+ 3 · Parse, don't validate +

Na het parsen onthoudt het type dat de waarde geldig is.

+
+ + + + @let r = parsed(); + + @if (r.ok) { +
+
Postcode ="{{ r.value }}"
+

+ Een gevalideerde Postcode is een ander type dan een ruwe string. +

+
+ } @else { +
+
{{ r.error }}
+
+ } +
+
+
+ `, +}) +export class ParseSection { + raw = signal(''); + parsed = computed(() => parsePostcode(this.raw())); + + protected readonly code: Record = { parse: highlightTs(SNIPPETS['parse']) }; + protected readonly src: Record = { + parse: 'registratie/domain/value-objects/postcode.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/pii.section.ts b/apps/ssp/src/app/showcase/pii.section.ts new file mode 100644 index 0000000..636fe1d --- /dev/null +++ b/apps/ssp/src/app/showcase/pii.section.ts @@ -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 + `` for its label, for the same reason section 2 does. */ +@Component({ + selector: 'app-concepts-pii-section', + imports: [ + FormsModule, + HeadingComponent, + TextInputComponent, + MaskedValueComponent, + ConceptCardComponent, + ], + template: ` +
+ 6 · PII — maskeren & parsen +

+ 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 elfproef afdwingt. +

+
+ +

+ BSN: + +

+

+ Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het + behandelscherm). De atom bevat de maskeer-detectie — geen los *-gesnuffel + bij elke gebruiker. +

+
+ + + @let b = bsnParsed(); + @if (bsnRaw()) { +
+ + @if (b.ok) { +
Bsn ="{{ b.value }}"
+ } @else { +
{{ b.error }}
+ } +
+
+ } +
+
+
+ `, +}) +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 = { + mask: highlightTs(SNIPPETS['mask']), + parseBsn: highlightTs(SNIPPETS['parseBsn']), + }; + protected readonly src: Record = { + mask: 'shared/kernel/pii.ts', + parseBsn: 'shared/kernel/bsn.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/remote-data.section.ts b/apps/ssp/src/app/showcase/remote-data.section.ts new file mode 100644 index 0000000..5b9e942 --- /dev/null +++ b/apps/ssp/src/app/showcase/remote-data.section.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 can be driven through every state without HTTP. */ +function fakeResource(status: string, value?: T, error?: Error): Resource { + return { + value: () => value as T, + status: () => status, + error: () => error, + hasValue: () => value !== undefined, + reload: () => {}, + } as unknown as Resource; +} + +/** 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 `` 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: ` +
+ 2 · RemoteData fold +

+ Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans. +

+
+ +
+ + {{ v }} + + + {{ v }} + + + {{ v }} + + +
    + @for (i of successRes.value(); track i) { +
  • {{ i }}
  • + } +
+
+
+
+ +

+ Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem + afhandelt. +

+
+
+
+ `, +}) +export class RemoteDataSection { + isEmpty = (v: string[]) => !v || v.length === 0; + + loadingRes = fakeResource('loading'); + emptyRes = fakeResource('resolved', []); + errorRes = fakeResource('error', undefined, new Error('Demo')); + successRes = fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']); + + protected readonly code: Record = { fold: highlightTs(SNIPPETS['fold']) }; + protected readonly src: Record = { fold: 'shared/application/remote-data.ts' }; +} diff --git a/apps/ssp/src/app/showcase/unions.section.ts b/apps/ssp/src/app/showcase/unions.section.ts new file mode 100644 index 0000000..0f2428c --- /dev/null +++ b/apps/ssp/src/app/showcase/unions.section.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: ` +
+ 1 · Discriminated unions +

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

+
+ +

+ Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand. +

+
+ + +

+ De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat + simpelweg niet. +

+
+
+
+ `, +}) +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 = { + 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 = { + union: 'registratie/domain/registration.ts', + }; +} diff --git a/apps/ssp/src/app/showcase/vragenlijst.section.ts b/apps/ssp/src/app/showcase/vragenlijst.section.ts new file mode 100644 index 0000000..49275d7 --- /dev/null +++ b/apps/ssp/src/app/showcase/vragenlijst.section.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: ` +
+ 5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen" +

+ Het aantal stappen ligt vast (STEPS); vervolgvragen verschijnen + binnen 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. +

+
+ +
+ @for (s of iw.steps; track s; let last = $last) { + {{ s }} + @if (!last) { + + } + } +
+

+ De stappen zijn altijd dezelfde; alleen de vragen binnen een stap verschijnen + of verdwijnen. +

+
+ + + +
+
+ `, +}) +export class VragenlijstSection { + protected readonly code: Record = { steps: highlightTs(SNIPPETS['steps']) }; + protected readonly src: Record = { + steps: 'herregistratie/domain/intake.machine.ts', + }; +} diff --git a/docs/project/readable-codebase/RD-24-concepts-sections.md b/docs/project/readable-codebase/RD-24-concepts-sections.md new file mode 100644 index 0000000..53a671f --- /dev/null +++ b/docs/project/readable-codebase/RD-24-concepts-sections.md @@ -0,0 +1,230 @@ +# RD-24 — Split `concepts.page.ts`, and fix the highlighting it has never rendered + +Status: done +Source: PLAN.md 3g, order step 6 + +## Why + +`concepts.page.ts` measures ~471 effective lines against a limit of 250, and carries +`/* eslint-disable max-lines */`. It is one template with six teaching sections and a 142-line +`styles:` block. + +A per-section split alone does not fix the styles, because Angular scopes a component's CSS to +its own template. Splitting without moving the CSS by owner would leave every section unstyled. + +**And measuring that constraint turned up a live bug** (decision 1). + +## Read first + +- `apps/ssp/src/app/showcase/concepts.page.ts` — the whole file: styles at 48-189, template at + 190-439. +- `libs/shared/styles.scss:105-130` — the `--app-devpanel-*` tokens and the existing + `.app-stack` / `.app-section` / `.app-text-subtle` globals. The new globals go beside them, + and the file's own comment says it exists to centralise exactly these idioms. +- `apps/ssp/src/app/showcase/highlight-ts.ts:42-45` — the `` markup whose + colours decision 1 restores. +- `scripts/check-tokens.sh:14` — the guard, and its `--include` glob. + +## Decisions (pre-made, don't relitigate) + +1. **The syntax highlighting is dead today. Fix it by making those rules global.** Verified + against the built output, not inferred: + + ``` + pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]{color:#c792ea} + ``` + + The `.k`/`.s`/`.c` spans arrive through `[innerHTML]`, so they never carry an `_ngcontent` + attribute, and the rule cannot match. `highlight-ts.ts` computes the spans, its spec passes, + and every keyword, string and comment renders in the plain foreground colour. No component in + this repository uses `ViewEncapsulation.None`, and there is no global rule for `.k`, `.s` or + `.c`. + + So `.app-code .k|.s|.c` becomes **global**, in `libs/shared/styles.scss`. A component cannot + own a rule that targets markup it did not render. + +2. **Five new tokens, beside `--app-devpanel-*`**, which exist for this same reason: + + ```scss + --app-code-bg: #1e2430; + --app-code-fg: #e6e9ef; + --app-code-keyword: #c792ea; + --app-code-string: #c3e88d; + --app-code-comment: #7e8aa0; + ``` + + `styles.scss` is the token bridge and the guard's one exempt file, so these literals belong + here and nowhere else. + +3. **Four new globals in `libs/shared/styles.scss`**, named with the existing `.app-` prefix: + + | Global | Replaces | Why not a component | + | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | + | `.app-code` | `pre` | must style `[innerHTML]` children (decision 1) | + | `.app-lead` | `.lead` | a page-level typography idiom, used by all six sections | + | `.app-cols` | `.cols` | same | + | `.app-note` | `.note` | its content includes markup (``), so it must be projected, and projected content keeps the _declaring_ component's scope | + + **Delete `.section` entirely** — the global `.app-section` already exists and does the job. + +4. **`concept-card.component.ts` owns the card vocabulary and renders it.** New component in + `apps/ssp/src/app/showcase/`. It owns `.card`, `.card--good`, `.card--bad`, `.tag`, its three + modifiers and both `::before` rules, plus `.linked` and `.linked .src`. + + Its API, driven by what the 12 current usages need: + + ```ts + variant = input<'good' | 'bad' | 'plain'>('plain'); // card--good / card--bad / tag colour + tag = input.required(); // the uppercase label + code = input(); // pre [innerHTML], optional + src = input(); // figcaption; wraps code in figure.linked + ``` + + Everything else is projected through ``. The card **renders the `
` itself**
+   when `code` is set — that is what keeps `.app-code`'s box styling working without relying on
+   projection.
+
+5. **Six section components, one per `
`**, in `apps/ssp/src/app/showcase/`: + + | File | Class | Heading | Own CSS | + | ------------------------- | -------------------- | --------------------------- | --------------------------------------------- | + | `unions.section.ts` | `UnionsSection` | 1 · Discriminated unions | none | + | `remote-data.section.ts` | `RemoteDataSection` | 2 · RemoteData fold | none | + | `parse.section.ts` | `ParseSection` | 3 · Parse, don't validate | none | + | `form-machine.section.ts` | `FormMachineSection` | 4 · Form als state machine | `.machine`, `.node`, `.node.on` | + | `vragenlijst.section.ts` | `VragenlijstSection` | 5 · Vragenlijst | `.steplist`, `.pill`, `.pill.extra`, `.arrow` | + | `pii.section.ts` | `PiiSection` | 6 · PII — maskeren & parsen | none | + + The "Own CSS" column is measured: those selectors appear in exactly one section each. Every + other selector is now a global or lives in the card. + +6. **The page keeps only what composes.** After the split `concepts.page.ts` holds its heading, + its intro, and six elements. It keeps no `styles:` block. `code` and `src` (the generated + snippets) move to whichever sections use them — each section imports + `snippets.generated.ts` directly. + +7. **Widen the colour guard, and fix the one file that widening catches.** + `scripts/check-tokens.sh:14` greps `--include='*.component.ts'`, so **every `*.page.ts`, + `*.section.ts` and `*.step.ts` in the repository is invisible to it** — including the six + sections this ticket creates and the six `*.step.ts` files RD-22 and RD-23 just added. That + is why this page accumulated 21 hardcoded colours unnoticed. + + Change the include to `--include='*.ts'` and exclude specs and stories, which legitimately + show colour swatches: + + ```bash + hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \ + | grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true) + ``` + + Measured: this newly catches exactly one other line, `libs/beheer/src/ui/audit.page.ts:39` + (`var(--rhc-color-rood-600, #a30000)`). Fix it by dropping the fallback, as decision 8 does + for this page. **Leave the CIBG-GAP marker check at `*.component.ts`** — a gap extension is a + component concept (ADR-0003). + +8. **Drop every `var(--rhc-…, #hex)` fallback.** All the referenced tokens are defined in the + bridge, so the fallback is dead weight that also trips the widened guard. `.card`'s + `background: #fff` becomes `var(--rhc-color-wit)` — verified: that token is defined in + `styles.scss`. + +9. **No stories.** `showcase` is a teaching page, not a feature, and it has no story today. + Adding six is not this ticket's job. + +10. **Delete `/* eslint-disable max-lines */` from the page.** Mandatory — the rules pin each + other in both directions. + +## Files + +- `libs/shared/styles.scss` — 5 tokens, 4 globals +- `scripts/check-tokens.sh` — one line (decision 7) +- `libs/beheer/src/ui/audit.page.ts` — one fallback (decision 7) +- `apps/ssp/src/app/showcase/concept-card.component.ts` (new) +- `apps/ssp/src/app/showcase/{unions,remote-data,parse,form-machine,vragenlijst,pii}.section.ts` (new) +- `apps/ssp/src/app/showcase/concepts.page.ts` + +## Steps + +1. Add the tokens and the four globals to `libs/shared/styles.scss` (decisions 2 and 3). +2. Write `concept-card.component.ts` (decision 4). +3. Move each `
` into its own file, replacing every `
` with + ``, `class="lead|cols|note"` with the `.app-*` names, and `
` with
+   either the card's `code` input or `
` for the four dynamic result blocks.
+4. Reduce the page to composition, with no `styles:` block.
+5. Widen the guard and fix `audit.page.ts` (decision 7).
+6. Delete the disable (decision 10).
+7. `git add -A`, then run the acceptance commands.
+8. Update this ticket's `Status:` to `done` and the README's RD-24 row to `done`.
+9. Commit all of it together.
+
+## Acceptance criteria
+
+Measured against the tree before handover. Run after `git add -A`.
+
+```bash
+D=apps/ssp/src/app/showcase
+git ls-files "$D/*.section.ts" | wc -l                       # is 0 -> MUST be 6
+git ls-files "$D/concept-card.component.ts" | wc -l          # is 0 -> MUST be 1
+git grep -c "eslint-disable max-lines" -- $D/concepts.page.ts   # is 1 -> MUST be 0
+git grep -c "styles:" -- $D/concepts.page.ts                 # is 1 -> MUST be 0
+```
+
+The colours left the page, and the guard now covers it:
+
+```bash
+git grep -cE "#[0-9a-fA-F]{3,6}" -- $D/concepts.page.ts      # is 21 -> MUST be 0
+git grep -c "include='\*\.component\.ts'" -- scripts/check-tokens.sh   # is 2 -> MUST be 1 (the CIBG-GAP check keeps it)
+npm run check:tokens                                          # exits 0
+```
+
+The highlighting rules are global, where innerHTML children can reach them (decision 1):
+
+```bash
+git grep -c "app-code" -- libs/shared/styles.scss             # MUST be >= 4
+git grep -c "app-code" -- $D/concepts.page.ts                 # MUST be 0
+```
+
+The teaching content did not change while being moved:
+
+```bash
+git grep -ho "code\['[a-zA-Z]*'\]" -- $D/ | sort -u | wc -l   # is 9 -> MUST still be 9
+```
+
+```bash
+npm run ci --full   # exits 0
+```
+
+## Verification
+
+**`--full` is required** — this edits `libs/shared/styles.scss`, which every story renders
+against.
+
+**Look at the page.** This is the one ticket in the arc whose main fix is invisible to every
+automated check: no test asserts a computed colour. Run `npm start`, open `/concepts`, and
+confirm that keywords, strings and comments in the code blocks are now coloured — purple, green
+and grey-italic against the dark background. If they are still monochrome, the rules are still
+scoped to a component.
+
+**Do not add a line-count command.** `npm run lint` is the exact check.
+
+## Out of scope
+
+- Changing any teaching copy, snippet or demo. This is a move, not a rewrite.
+- `highlight-ts.ts` itself. Its output is correct; only the CSS was unreachable.
+- Adding stories (decision 9).
+- The `--app-devpanel-*` tokens, and any other page's colours.
+
+## Risks
+
+- **Angular does not style projected or `[innerHTML]` content from the receiving component.**
+  This is the constraint that shapes decisions 1, 3 and 4. If you find yourself moving a rule
+  into a component and its markup comes from somewhere else, the rule belongs in the global
+  sheet.
+- **The `.app-note` case is subtle**: a note's text contains `` markup, so it must be
+  projected — which is exactly why it cannot be styled by the card. Global it is.
+- **The four dynamic `
` blocks** (the ok/err demo output in sections 3 and 6) are not code
+  snippets and have no `src`. Give them `class="app-code"` directly rather than forcing them
+  through the card's `code` input.
+- **Widening the guard is a two-line change with a measured blast radius of one other file**
+  (decision 7). If it catches more than `audit.page.ts:39`, stop and report — something landed
+  since this ticket was written.
+- **Deleting the disable is mandatory** (decision 10).
diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md
index 6603dc7..ccdc2c5 100644
--- a/docs/project/readable-codebase/README.md
+++ b/docs/project/readable-codebase/README.md
@@ -118,7 +118,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
 | RD-21 | `rich-text-dom.ts` helpers + spec cases                                      | 02         | yes       | done   |
 | RD-22 | `intake-wizard` to 3 step components                                         | 08, 20     | yes       | done   |
 | RD-23 | `registratie-wizard` to 3 steps + the upload-controller move                 | 08, 20     | yes       | done   |
-| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens       | 02         | yes       | todo   |
+| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens       | 02         | yes       | done   |
 | RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children            | 02         | yes       | todo   |
 | RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable         | 02         | yes       | todo   |
 | RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports              | 21         | yes       | todo   |
diff --git a/libs/beheer/src/ui/audit.page.ts b/libs/beheer/src/ui/audit.page.ts
index 3054f22..6df5795 100644
--- a/libs/beheer/src/ui/audit.page.ts
+++ b/libs/beheer/src/ui/audit.page.ts
@@ -36,7 +36,7 @@ import { AuditStore } from '@beheer/application/audit.store';
         font-weight: var(--rhc-text-font-weight-semi-bold);
       }
       .deny {
-        color: var(--rhc-color-rood-600, #a30000);
+        color: var(--rhc-color-rood-600);
         font-weight: var(--rhc-text-font-weight-semi-bold);
       }
     `,
diff --git a/libs/shared/styles.scss b/libs/shared/styles.scss
index 84af730..d357b32 100644
--- a/libs/shared/styles.scss
+++ b/libs/shared/styles.scss
@@ -113,6 +113,15 @@ body {
   --app-devpanel-accent: #9cdcfe;
   --app-devpanel-border: #444;
   --app-devpanel-shadow: rgb(0 0 0 / 0.4);
+
+  /* Showcase code-block palette (concepts.page.ts, RD-24): the same "exempt file"
+     reasoning as --app-devpanel-* above. A dark code-editor palette, kept off the CIBG
+     design system, for the teaching page's highlighted TS snippets. */
+  --app-code-bg: #1e2430;
+  --app-code-fg: #e6e9ef;
+  --app-code-keyword: #c792ea;
+  --app-code-string: #c3e88d;
+  --app-code-comment: #7e8aa0;
 }
 
 /* App utility classes: centralise the repeated inline layout idioms so components stay
@@ -127,6 +136,49 @@ body {
 .app-text-subtle {
   color: var(--rhc-color-foreground-subtle);
 }
+/* Highlighted code block (concepts.page.ts, RD-24). Global because the `.k`/`.s`/`.c`
+   keyword/string/comment spans arrive through `[innerHTML]` — they never carry the
+   rendering component's `_ngcontent` attribute, so a component-scoped rule can never
+   match them. This is why the highlighting never rendered before RD-24. */
+.app-code {
+  background: var(--app-code-bg);
+  color: var(--app-code-fg);
+  padding: 1rem;
+  border-radius: 8px;
+  overflow: auto;
+  font-size: 0.82rem;
+  line-height: 1.55;
+  margin: 0;
+}
+.app-code .k {
+  color: var(--app-code-keyword);
+}
+.app-code .s {
+  color: var(--app-code-string);
+}
+.app-code .c {
+  color: var(--app-code-comment);
+  font-style: italic;
+}
+/* Page-level typography idioms shared by every showcase section (concepts.page.ts). */
+.app-lead {
+  color: var(--rhc-color-grijs-700);
+  max-width: 46rem;
+  margin: 0.25rem 0 1.25rem;
+}
+.app-cols {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
+  gap: 1.5rem;
+  align-items: start;
+}
+/* A note whose content includes markup (e.g. ``) must be projected into its
+   card, so it keeps the DECLARING component's scope, not the card's — global it is. */
+.app-note {
+  font-size: 0.9rem;
+  color: var(--rhc-color-grijs-700);
+  margin: 0.75rem 0 0;
+}
 
 /* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
    The chrome gets its own stable view-transition-name so it's lifted out of the
diff --git a/scripts/check-tokens.sh b/scripts/check-tokens.sh
index 3e86c97..80afa43 100755
--- a/scripts/check-tokens.sh
+++ b/scripts/check-tokens.sh
@@ -1,7 +1,9 @@
 #!/usr/bin/env bash
-# WP-02 token guard: fail if any *.component.ts hardcodes a colour (hex/rgb/hsl)
-# instead of a --rhc-*/--app-* design token. Palette values live ONLY in the
-# styles.scss token bridge (the one exempt file — it IS the bridge).
+# WP-02 token guard: fail if any *.ts file hardcodes a colour (hex/rgb/hsl) instead of
+# a --rhc-*/--app-* design token. Specs and stories are exempt — they legitimately show
+# colour swatches. Palette values live ONLY in the styles.scss token bridge (the one
+# exempt file — it IS the bridge). Widened from *.component.ts to *.ts in RD-24: a
+# *.page.ts, *.section.ts or *.step.ts hardcoding a colour was invisible before that.
 #
 # px/rem are deliberately NOT grepped: too many false positives (font sizes,
 # transforms, media queries). Raw border widths are fixed by hand and mapped to
@@ -11,7 +13,8 @@
 # false positive (a colour word inside a comment or a data-URI). Keep the bar high.
 set -uo pipefail
 
-hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.component.ts' | grep -v 'token-ok' || true)
+hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \
+  | grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true)
 if [ -n "$hits" ]; then
   echo "$hits"
   echo 'FAIL: hardcoded colours in components (use --rhc-*/--app-* tokens, or add a `token-ok` marker + reason)'