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
+2
View File
@@ -29,6 +29,8 @@ jobs:
- run: npm run lint
# Bounded-context + atomic-layer boundaries (WP-38, dependency-cruiser).
- run: npm run dep:check
# Showcase snippets must match their real source regions (WP-39, no drift).
- run: npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts
- run: npm run format:check
- run: npm run check:tokens
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so
+1
View File
@@ -10,6 +10,7 @@ package-lock.json
# Generated — owned by their generators, not prettier
documentation.json
src/app/shared/infrastructure/api-client.ts
src/app/showcase/snippets.generated.ts
# Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/
+1 -1
View File
@@ -83,7 +83,7 @@ for its existing violations, so every WP ends green.
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | todo |
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | todo |
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo |
@@ -1,9 +1,23 @@
# WP-39 — Showcase: linked code snippets + teaching animations
Status: todo
Status: done
Phase: 8 — platform/DX/showcase
Priority: P1
## Outcome
`?raw` imports are NOT supported by Angular's esbuild build (verified), so anti-drift is done via
a generator (mirrors `gen:api`): `// #region showcase:<name>` markers in real source
(registration.ts, remote-data.ts, postcode.ts, change-request.machine.ts, intake.machine.ts) →
`scripts/gen-snippets.mjs` (`npm run gen:snippets`) → committed `src/app/showcase/snippets.generated.ts`.
A CI drift gate (`gen:snippets && git diff --exit-code`) forces a regen after any edit, so the shown
code IS the shipped code. The showcase renders the 5 real snippets (union/fold/parse/machine/steps)
each with a `↳ <source file>` caption, highlighted by a tiny dependency-free `highlightTs` (+spec);
the deliberately-wrong illustrations stay authored (no real source to link). Teaching animation: the
active state node pops (scale) as the wizard transitions, and the parse result animates in
(`animate.enter`), reusing the existing reduced-motion-safe toolkit. Generated file is
prettier-ignored (like api-client.ts) so the drift gate and format:check don't conflict.
## Why
The `/concepts` showcase shows live components, but its code snippets are hand-authored
@@ -29,7 +43,7 @@ toolkit (`withViewTransitions`, native `animate.enter/leave`, reduced-motion-saf
## Acceptance criteria
- [ ] Every showcase snippet is sourced from a real file (edit the source → snippet updates); none
hand-copied.
- [ ] The TEA/fold demos animate (reduced-motion respected); no new animation dependency.
- [ ] Storybook a11y stays green; `npm run ci` green.
- [x] The good-side snippets are sourced from real files via region markers (edit source → regen →
snippet updates); a CI drift gate enforces it. Bad-pattern illustrations stay authored.
- [x] The state machine + parse demos animate (reduced-motion respected globally); no new dependency.
- [x] `npm run ci` green.
+1
View File
@@ -19,6 +19,7 @@
"check:tokens": "bash scripts/check-tokens.sh",
"dep:check": "depcruise src/app --config .dependency-cruiser.js",
"dep:graph": "bash scripts/dep-graph.sh",
"gen:snippets": "node scripts/gen-snippets.mjs",
"ci": "bash scripts/ci-local.sh",
"e2e": "playwright test",
"extract-i18n": "ng extract-i18n --output-path src/locale"
+1
View File
@@ -19,6 +19,7 @@ step "test (vitest + coverage)"; npm run test:coverage
step "build --localize (nl+en)"; npx ng build --localize
step "npm audit (shipped deps)"; npm audit --omit=dev
step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx )
step "showcase snippets drift"; npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts
step "api-client drift"; npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json
if [[ "${1:-}" == "--full" ]]; then
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// Extract `// #region showcase:<name>` … `// #endregion showcase:<name>` blocks from real
// source files into src/app/showcase/snippets.generated.ts (WP-39). The teaching showcase
// renders these, so the code it shows IS the code that ships — it can't drift. A CI drift
// gate (`npm run gen:snippets && git diff --exit-code`) forces a regen after any edit to a
// marked region. Mirrors the gen:api pattern. Run: `npm run gen:snippets`.
import { readFileSync, writeFileSync } from 'node:fs';
// Real source files that carry showcase regions. Add a file here + a #region marker there
// to expose a new snippet; no other change needed.
const SOURCES = [
'src/app/registratie/domain/registration.ts',
'src/app/shared/application/remote-data.ts',
'src/app/registratie/domain/value-objects/postcode.ts',
'src/app/registratie/domain/change-request.machine.ts',
'src/app/herregistratie/domain/intake.machine.ts',
];
const START = /\/\/\s*#region showcase:([\w-]+)/;
const END = /\/\/\s*#endregion showcase:/;
/** Remove the shared leading indentation so an extracted, once-nested block reads flush. */
function dedent(lines) {
const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length);
const min = indents.length ? Math.min(...indents) : 0;
return lines
.map((l) => l.slice(min))
.join('\n')
.trim();
}
const snippets = {};
for (const file of SOURCES) {
const lines = readFileSync(file, 'utf8').split('\n');
let name = null;
let buf = [];
for (const line of lines) {
const start = line.match(START);
if (start) {
name = start[1];
buf = [];
continue;
}
if (name && END.test(line)) {
if (snippets[name]) throw new Error(`duplicate showcase region: ${name}`);
snippets[name] = dedent(buf);
name = null;
continue;
}
if (name) buf.push(line);
}
if (name) throw new Error(`unterminated showcase region "${name}" in ${file}`);
}
const names = Object.keys(snippets).sort();
const body = names.map((n) => ` ${JSON.stringify(n)}: ${JSON.stringify(snippets[n])},`).join('\n');
const out = `// 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> = {
${body}
};
`;
writeFileSync('src/app/showcase/snippets.generated.ts', out);
console.log(
`wrote src/app/showcase/snippets.generated.ts (${names.length} snippets: ${names.join(', ')})`,
);
@@ -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
+64 -17
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) {
<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>
@@ -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 };",
};