feat(showcase): WP-39 — linked code snippets + teaching animations
CI / frontend (push) Successful in 2m5s
CI / storybook-a11y (push) Successful in 5m56s
CI / backend (push) Successful in 1m30s
CI / e2e (push) Successful in 2m51s
CI / semgrep (push) Successful in 1m1s
CI / api-client-drift (push) Successful in 2m6s

Anti-drift snippets: `?raw` isn't supported by Angular's esbuild build, so real code
is exposed via // #region showcase:<name> markers in source (registration/remote-data/
postcode/change-request.machine/intake.machine) → scripts/gen-snippets.mjs → committed
snippets.generated.ts, with a CI drift gate so the shown code is the shipped code. The
/concepts page renders the 5 real snippets (union/fold/parse/machine/steps) with a source
caption + a tiny dependency-free highlightTs (+spec); deliberately-wrong illustrations stay
authored. Teaching motion: active state node pops on transition + parse result animates in
(existing reduced-motion-safe toolkit). Generated file prettier-ignored (like api-client).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 14:49:52 +02:00
co-authored by Claude Opus 4.8
parent f8cd77a48a
commit 19f2e9b734
16 changed files with 258 additions and 34 deletions
@@ -54,8 +54,10 @@ export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFA
return r.ok && r.value < scholingThreshold;
}
// #region showcase:steps
/** The fixed step list. Number of steps never changes; questions reveal inline. */
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
@@ -24,11 +24,13 @@ export type Errors = Partial<Record<keyof Draft, string>>;
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
* an invalid draft, a success screen with errors) are unrepresentable.
*/
// #region showcase:machine
export type ChangeRequestState =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing
| { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors
| { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string };
// #endregion showcase:machine
export const initial: ChangeRequestState = {
tag: 'Editing',
+3 -1
View File
@@ -5,10 +5,12 @@
* old flat interface allowed that impossible combination — this makes it
* unrepresentable.
*/
// #region showcase:union
export type RegistrationStatus =
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
| { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
// #endregion showcase:union
/** Just the discriminant — for atoms that only need the label/color. */
export type StatusTag = RegistrationStatus['tag'];
@@ -7,6 +7,7 @@ import { Brand, Result, ok, err } from '@shared/kernel/fp';
*/
export type Postcode = Brand<string, 'Postcode'>;
// #region showcase:parse
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
@@ -15,3 +16,4 @@ export function parsePostcode(raw: string): Result<string, Postcode> {
// Normalise to "1234 AB" — the parser also cleans up.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}
// #endregion showcase:parse
+3 -1
View File
@@ -27,6 +27,7 @@ export function fromResource<T>(
return { tag: 'Loading' };
}
// #region showcase:fold
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
@@ -42,9 +43,10 @@ export function foldRemote<E, T, R>(
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd);
return assertNever(rd); // add a variant → compile error until handled
}
}
// #endregion showcase:fold
// --- Combinators -----------------------------------------------------------
// Let several independent async sources be treated as one. When you combine
+71 -24
View File
@@ -11,6 +11,8 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
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 { 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> {
@@ -145,6 +147,18 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
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;
@@ -182,13 +196,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — vlakke interface</p>
<pre [innerHTML]="unionBad"></pre>
<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
@@ -234,7 +252,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
</div>
<div class="card card--good">
<p class="tag good">De exhaustieve fold</p>
<pre [innerHTML]="foldCode"></pre>
<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.
@@ -250,6 +271,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<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()"
@@ -261,14 +286,18 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
@let r = parsed();
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<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 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 {
<p class="tag bad">err</p>
<pre>{{ r.error }}</pre>
<div animate.enter="app-item-enter">
<p class="tag bad">err</p>
<pre>{{ r.error }}</pre>
</div>
}
</div>
</div>
@@ -284,13 +313,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — losse booleans</p>
<pre [innerHTML]="machineBad"></pre>
<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>
@@ -315,6 +348,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
<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>
@@ -357,20 +394,30 @@ export class ConceptsPage {
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
unionBad = `<span class="k">interface</span> Registration {
status: <span class="s">'Geregistreerd'</span> | <span class="s">'Doorgehaald'</span>;
herregistratieDatum: string; <span class="c">// altijd aanwezig 😬</span>
}`;
// 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 ? 🤷`,
};
foldCode = `<span class="k">foldRemote</span>(rd, {
loading: () => spinner,
empty: () => <span class="s">'geen data'</span>,
failure: (e) => alert(e),
success: (v) => render(v),
}); <span class="c">// mist er één → compile-fout</span>`;
/** 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)]),
);
machineBad = `submitting = <span class="k">signal</span>(false);
submitted = <span class="k">signal</span>(false);
errors = <span class="k">signal</span>&lt;...&gt;({});
<span class="c">// submitting === true && errors.size > 0 ? 🤷</span>`;
/** 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',
};
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { highlightTs } from './highlight-ts';
describe('highlightTs', () => {
it('wraps keywords, strings and comments in the styling spans', () => {
const out = highlightTs(`const x = 'hi'; // note`);
expect(out).toContain('<span class="k">const</span>');
expect(out).toContain(`<span class="s">'hi'</span>`);
expect(out).toContain('<span class="c">// note</span>');
});
it('escapes HTML metacharacters so the [innerHTML] sink is safe', () => {
const out = highlightTs(`type T = A<B> & C;`);
expect(out).toContain('&lt;B&gt;');
expect(out).toContain('&amp;');
expect(out).not.toContain('<B>');
});
it('treats the whole // tail as one comment (keywords after // are not re-highlighted)', () => {
const out = highlightTs(`x(); // return here`);
expect(out).toContain('<span class="c">// return here</span>');
expect(out).not.toContain('<span class="k">return</span>');
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Tiny, dependency-free TS highlighter for the teaching showcase (WP-39). Escapes HTML,
* then wraps line-comments, strings, and a fixed keyword set in `.c`/`.s`/`.k` spans (the
* classes `concepts.page` styles). Deliberately naive — good enough for the short, curated
* snippets shown here; not a real tokenizer. Input is always our OWN source (extracted by
* `scripts/gen-snippets.mjs` or authored inline), so the `[innerHTML]` sink is safe once
* the HTML metacharacters are escaped first. Pure.
*/
const KEYWORDS = [
'interface',
'type',
'export',
'import',
'from',
'const',
'let',
'return',
'function',
'switch',
'case',
'default',
'if',
'else',
'new',
'readonly',
'extends',
'as',
'void',
];
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export function highlightTs(code: string): string {
const kw = new RegExp(`\\b(${KEYWORDS.join('|')})\\b`, 'g');
return escapeHtml(code)
.split('\n')
.map((line) => {
// Line comment: everything from // to EOL is one comment span (skip the rest).
const c = line.indexOf('//');
const head = c === -1 ? line : line.slice(0, c);
const tail = c === -1 ? '' : `<span class="c">${line.slice(c)}</span>`;
const lit = head
.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => `<span class="s">${m}</span>`) // strings
.replace(kw, '<span class="k">$1</span>'); // keywords
return lit + tail;
})
.join('\n');
}
+9
View File
@@ -0,0 +1,9 @@
// GENERATED by `npm run gen:snippets` (scripts/gen-snippets.mjs) — do not edit.
// Source-of-truth code excerpts extracted from real files via // #region showcase:<name>.
export const SNIPPETS: Record<string, string> = {
"fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}",
"machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };",
"parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}",
"steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];",
"union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };",
};