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:
@@ -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>();
|
||||||
|
}
|
||||||
@@ -1,497 +1,38 @@
|
|||||||
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
|
import { Component } from '@angular/core';
|
||||||
import { Component, computed, signal } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import type { Resource } from '@angular/core';
|
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { UnionsSection } from './unions.section';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { RemoteDataSection } from './remote-data.section';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ParseSection } from './parse.section';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { FormMachineSection } from './form-machine.section';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { VragenlijstSection } from './vragenlijst.section';
|
||||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
import { PiiSection } from './pii.section';
|
||||||
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';
|
|
||||||
|
|
||||||
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
|
||||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
with the "after" where the type system rules it out. Composition-only. */
|
||||||
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. */
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-concepts-page',
|
selector: 'app-concepts-page',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
PageShellComponent,
|
PageShellComponent,
|
||||||
HeadingComponent,
|
UnionsSection,
|
||||||
TextInputComponent,
|
RemoteDataSection,
|
||||||
...ASYNC,
|
ParseSection,
|
||||||
SkeletonComponent,
|
FormMachineSection,
|
||||||
RegistrationSummaryComponent,
|
VragenlijstSection,
|
||||||
HerregistratieWizardComponent,
|
PiiSection,
|
||||||
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);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
<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"
|
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).
|
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
|
||||||
</p>
|
</p>
|
||||||
|
<app-concepts-unions-section />
|
||||||
<!-- 1. Discriminated unions -->
|
<app-concepts-remote-data-section />
|
||||||
<section class="section">
|
<app-concepts-parse-section />
|
||||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
<app-concepts-form-machine-section />
|
||||||
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
<app-concepts-vragenlijst-section />
|
||||||
<div class="cols">
|
<app-concepts-pii-section />
|
||||||
<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-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ConceptsPage {
|
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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 `<span class="k|s|c">` 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 (`<code>`), 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<string>(); // the uppercase label
|
||||||
|
code = input<string | undefined>(); // pre [innerHTML], optional
|
||||||
|
src = input<string | undefined>(); // figcaption; wraps code in figure.linked
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else is projected through `<ng-content />`. The card **renders the `<pre>` 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 `<section>`**, 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 `<section>` into its own file, replacing every `<div class="card …">` with
|
||||||
|
`<app-concept-card>`, `class="lead|cols|note"` with the `.app-*` names, and `<pre>` with
|
||||||
|
either the card's `code` input or `<pre class="app-code">` 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 `<code>` markup, so it must be
|
||||||
|
projected — which is exactly why it cannot be styled by the card. Global it is.
|
||||||
|
- **The four dynamic `<pre>` 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).
|
||||||
@@ -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-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | done |
|
||||||
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | 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-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-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-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 |
|
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo |
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { AuditStore } from '@beheer/application/audit.store';
|
|||||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||||
}
|
}
|
||||||
.deny {
|
.deny {
|
||||||
color: var(--rhc-color-rood-600, #a30000);
|
color: var(--rhc-color-rood-600);
|
||||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ body {
|
|||||||
--app-devpanel-accent: #9cdcfe;
|
--app-devpanel-accent: #9cdcfe;
|
||||||
--app-devpanel-border: #444;
|
--app-devpanel-border: #444;
|
||||||
--app-devpanel-shadow: rgb(0 0 0 / 0.4);
|
--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
|
/* App utility classes: centralise the repeated inline layout idioms so components stay
|
||||||
@@ -127,6 +136,49 @@ body {
|
|||||||
.app-text-subtle {
|
.app-text-subtle {
|
||||||
color: var(--rhc-color-foreground-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. `<code>`) 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.
|
/* 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
|
The chrome gets its own stable view-transition-name so it's lifted out of the
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# WP-02 token guard: fail if any *.component.ts hardcodes a colour (hex/rgb/hsl)
|
# WP-02 token guard: fail if any *.ts file hardcodes a colour (hex/rgb/hsl) instead of
|
||||||
# instead of a --rhc-*/--app-* design token. Palette values live ONLY in the
|
# a --rhc-*/--app-* design token. Specs and stories are exempt — they legitimately show
|
||||||
# styles.scss token bridge (the one exempt file — it IS the bridge).
|
# 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,
|
# 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
|
# 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.
|
# false positive (a colour word inside a comment or a data-URI). Keep the bar high.
|
||||||
set -uo pipefail
|
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
|
if [ -n "$hits" ]; then
|
||||||
echo "$hits"
|
echo "$hits"
|
||||||
echo 'FAIL: hardcoded colours in components (use --rhc-*/--app-* tokens, or add a `token-ok` marker + reason)'
|
echo 'FAIL: hardcoded colours in components (use --rhc-*/--app-* tokens, or add a `token-ok` marker + reason)'
|
||||||
|
|||||||
Reference in New Issue
Block a user