diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fa1299..0f4dec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.prettierignore b/.prettierignore index 067fb25..2a3f5a7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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/ diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index a922e36..57e633e 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -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 | diff --git a/docs/project/backlog/WP-39-showcase-snippets-animations.md b/docs/project/backlog/WP-39-showcase-snippets-animations.md index e0f2a86..93d520d 100644 --- a/docs/project/backlog/WP-39-showcase-snippets-animations.md +++ b/docs/project/backlog/WP-39-showcase-snippets-animations.md @@ -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:` 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 `↳ ` 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. diff --git a/package.json b/package.json index 634095c..00a5415 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index b7dd3e6..e190ee3 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -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 diff --git a/scripts/gen-snippets.mjs b/scripts/gen-snippets.mjs new file mode 100644 index 0000000..80b87ed --- /dev/null +++ b/scripts/gen-snippets.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Extract `// #region showcase:` … `// #endregion showcase:` 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:. +export const SNIPPETS: Record = { +${body} +}; +`; +writeFileSync('src/app/showcase/snippets.generated.ts', out); +console.log( + `wrote src/app/showcase/snippets.generated.ts (${names.length} snippets: ${names.join(', ')})`, +); diff --git a/src/app/herregistratie/domain/intake.machine.ts b/src/app/herregistratie/domain/intake.machine.ts index 491983f..bb1320b 100644 --- a/src/app/herregistratie/domain/intake.machine.ts +++ b/src/app/herregistratie/domain/intake.machine.ts @@ -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>; diff --git a/src/app/registratie/domain/change-request.machine.ts b/src/app/registratie/domain/change-request.machine.ts index ad732bb..6fae12a 100644 --- a/src/app/registratie/domain/change-request.machine.ts +++ b/src/app/registratie/domain/change-request.machine.ts @@ -24,11 +24,13 @@ export type Errors = Partial>; * 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', diff --git a/src/app/registratie/domain/registration.ts b/src/app/registratie/domain/registration.ts index bc8cfe0..2dba1c3 100644 --- a/src/app/registratie/domain/registration.ts +++ b/src/app/registratie/domain/registration.ts @@ -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']; diff --git a/src/app/registratie/domain/value-objects/postcode.ts b/src/app/registratie/domain/value-objects/postcode.ts index 2264fce..ed7fec6 100644 --- a/src/app/registratie/domain/value-objects/postcode.ts +++ b/src/app/registratie/domain/value-objects/postcode.ts @@ -7,6 +7,7 @@ import { Brand, Result, ok, err } from '@shared/kernel/fp'; */ export type Postcode = Brand; +// #region showcase:parse export function parsePostcode(raw: string): Result { 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 { // 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 diff --git a/src/app/shared/application/remote-data.ts b/src/app/shared/application/remote-data.ts index 363fbea..45828d8 100644 --- a/src/app/shared/application/remote-data.ts +++ b/src/app/shared/application/remote-data.ts @@ -27,6 +27,7 @@ export function fromResource( return { tag: 'Loading' }; } +// #region showcase:fold /** Exhaustive fold: you must handle every case, checked at compile time. */ export function foldRemote( rd: RemoteData, @@ -42,9 +43,10 @@ export function foldRemote( 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 diff --git a/src/app/showcase/concepts.page.ts b/src/app/showcase/concepts.page.ts index 2ad55ba..6f65517 100644 --- a/src/app/showcase/concepts.page.ts +++ b/src/app/showcase/concepts.page.ts @@ -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 can be driven through every state without HTTP. */ function fakeResource(status: string, value?: T, error?: Error): Resource { @@ -145,6 +147,18 @@ function fakeResource(status: string, value?: T, error?: Error): Resource 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(status: string, value?: T, error?: Error): Resource

Fout — vlakke interface

-

+            

             

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

Goed — sum type

+
+

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

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat @@ -234,7 +252,10 @@ function fakeResource(status: string, value?: T, error?: Error): Resource

De exhaustieve fold

-

+            
+

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

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt. @@ -250,6 +271,10 @@ function fakeResource(status: string, value?: T, error?: Error): Resource

Smart constructor → Result

+
+

+              
↳ {{ src['parse'] }}
+
(status: string, value?: T, error?: Error): Resource @let r = parsed();
@if (r.ok) { -

ok

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

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

+
+

ok

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

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

+
} @else { -

err

-
{{ r.error }}
+
+

err

+
{{ r.error }}
+
}
@@ -284,13 +313,17 @@ function fakeResource(status: string, value?: T, error?: Error): Resource

Fout — losse booleans

-

+            

             

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

Goed — één tagged union

+
+

+              
↳ {{ src['machine'] }}
+
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) { {{ n }} @@ -315,6 +348,10 @@ function fakeResource(status: string, value?: T, error?: Error): Resource

Vaste stappen

+
+

+              
↳ {{ src['steps'] }}
+
@for (s of iw.steps; track s; let last = $last) { {{ s }} @@ -357,20 +394,30 @@ export class ConceptsPage { raw = signal(''); parsed = computed(() => parsePostcode(this.raw())); - unionBad = `interface Registration { - status: 'Geregistreerd' | 'Doorgehaald'; - herregistratieDatum: string; // altijd aanwezig 😬 -}`; + // Deliberately-wrong illustrations (no real source to link — they show the anti-pattern). + private readonly illustrations: Record = { + unionBad: `interface Registration { + status: 'Geregistreerd' | 'Doorgehaald'; + herregistratieDatum: string; // altijd aanwezig 😬 +}`, + machineBad: `submitting = signal(false); +submitted = signal(false); +errors = signal<...>({}); +// submitting === true && errors.size > 0 ? 🤷`, + }; - foldCode = `foldRemote(rd, { - loading: () => spinner, - empty: () => 'geen data', - failure: (e) => alert(e), - success: (v) => render(v), -}); // mist er één → compile-fout`; + /** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source + by gen:snippets — they can't drift), the illustrations are authored above. */ + protected readonly code: Record = Object.fromEntries( + Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]), + ); - machineBad = `submitting = signal(false); -submitted = signal(false); -errors = signal<...>({}); -// submitting === true && errors.size > 0 ? 🤷`; + /** The real file each linked snippet is extracted from (shown as a caption). */ + protected readonly src: Record = { + union: 'registratie/domain/registration.ts', + fold: 'shared/application/remote-data.ts', + parse: 'registratie/domain/value-objects/postcode.ts', + machine: 'registratie/domain/change-request.machine.ts', + steps: 'herregistratie/domain/intake.machine.ts', + }; } diff --git a/src/app/showcase/highlight-ts.spec.ts b/src/app/showcase/highlight-ts.spec.ts new file mode 100644 index 0000000..ae50c01 --- /dev/null +++ b/src/app/showcase/highlight-ts.spec.ts @@ -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('const'); + expect(out).toContain(`'hi'`); + expect(out).toContain('// note'); + }); + + it('escapes HTML metacharacters so the [innerHTML] sink is safe', () => { + const out = highlightTs(`type T = A & C;`); + expect(out).toContain('<B>'); + expect(out).toContain('&'); + expect(out).not.toContain(''); + }); + + it('treats the whole // tail as one comment (keywords after // are not re-highlighted)', () => { + const out = highlightTs(`x(); // return here`); + expect(out).toContain('// return here'); + expect(out).not.toContain('return'); + }); +}); diff --git a/src/app/showcase/highlight-ts.ts b/src/app/showcase/highlight-ts.ts new file mode 100644 index 0000000..77ad118 --- /dev/null +++ b/src/app/showcase/highlight-ts.ts @@ -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, '&').replace(//g, '>'); + +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 ? '' : `${line.slice(c)}`; + const lit = head + .replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => `${m}`) // strings + .replace(kw, '$1'); // keywords + return lit + tail; + }) + .join('\n'); +} diff --git a/src/app/showcase/snippets.generated.ts b/src/app/showcase/snippets.generated.ts new file mode 100644 index 0000000..dcbaac8 --- /dev/null +++ b/src/app/showcase/snippets.generated.ts @@ -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:. +export const SNIPPETS: Record = { + "fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote(\n rd: RemoteData,\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 {\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 };", +};