From 9d58f597eab8cce1dff2cdfc647d16ed607c09f4 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 3 Jul 2026 22:42:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(fp):=20WP-13=20=E2=80=94=20CIBG-gap=20regi?= =?UTF-8?q?ster=20+=20hygiene=20+=20MDX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark every hand-rolled shared/ui surface with a `// CIBG-GAP EXTENSION:` comment + `cibgGap` story parameter (skeleton, spinner, rich-text-editor, wizard-shell's error summary, application-link's non-navigating row, debug-state, status-badge, card, placeholder-chip) so deviations from the CIBG design system are auditable. Add the register MDX (Foundations/CIBG Gap Register), cross-linked from ADR-0003. Delete the near-identity upload-status-banner wrapper; its one consumer now uses directly (a story added to keep the info-banner state covered). Co-Authored-By: Claude Sonnet 5 --- docs/architecture/0003-cibg-huisstijl.md | 4 + docs/backlog/README.md | 4 +- docs/backlog/WP-13-cibg-gap-register.md | 22 +- documentation.json | 3201 ++++++++--------- .../wizard-shell/wizard-shell.component.ts | 4 + .../wizard-shell/wizard-shell.stories.ts | 9 + .../application-link.component.ts | 3 + .../application-link.stories.ts | 9 + src/app/shared/ui/card/card.component.ts | 3 + src/app/shared/ui/card/card.stories.ts | 4 + .../ui/debug-state/debug-state.component.ts | 2 + .../ui/debug-state/debug-state.stories.ts | 4 + .../placeholder-chip.component.ts | 2 + .../placeholder-chip.stories.ts | 4 + .../rich-text-editor.component.ts | 4 + .../rich-text-editor.stories.ts | 4 + .../shared/ui/skeleton/skeleton.component.ts | 2 + .../shared/ui/skeleton/skeleton.stories.ts | 4 + .../shared/ui/spinner/spinner.component.ts | 2 + src/app/shared/ui/spinner/spinner.stories.ts | 4 + .../ui/status-badge/status-badge.component.ts | 2 + .../ui/status-badge/status-badge.stories.ts | 4 + .../document-upload.component.ts | 8 +- .../document-upload.stories.ts | 4 + .../upload-status-banner.component.ts | 25 - .../upload-status-banner.stories.ts | 21 - src/docs/cibg-gaps.mdx | 52 + 27 files changed, 1676 insertions(+), 1735 deletions(-) delete mode 100644 src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts delete mode 100644 src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts create mode 100644 src/docs/cibg-gaps.mdx diff --git a/docs/architecture/0003-cibg-huisstijl.md b/docs/architecture/0003-cibg-huisstijl.md index 9d81115..78edb54 100644 --- a/docs/architecture/0003-cibg-huisstijl.md +++ b/docs/architecture/0003-cibg-huisstijl.md @@ -48,3 +48,7 @@ layer — not a palette swap. alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we intentionally dropped, so we accept the warning. - Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope. +- Hand-rolled components (point 4) are tracked in the **CIBG gap register** + (`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the + design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently + drifting. diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 16a4ad8..9cee490 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -49,10 +49,10 @@ for its existing violations, so every WP ends green. | [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | | [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | -| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | +| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | | [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | | [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | -| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo | +| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | | [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo | | [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo | | [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | diff --git a/docs/backlog/WP-13-cibg-gap-register.md b/docs/backlog/WP-13-cibg-gap-register.md index bfbbc3c..853bb7f 100644 --- a/docs/backlog/WP-13-cibg-gap-register.md +++ b/docs/backlog/WP-13-cibg-gap-register.md @@ -1,8 +1,20 @@ # WP-13 — CIBG-gap register + hygiene + MDX -Status: todo +Status: done () Phase: 2 — CIBG fidelity +> **Deviation:** WP-11/12 ran first but left no markers (deferred to this WP, as their own +> files note), so this WP defines the marker format fresh per its own Decisions block — +> not adopted from 11/12. The Decisions block's `task-list → Actieblok` mapping is stale: +> no `.actieblok`/`actie` class exists in the vendored CSS, and `task-list`'s own header +> comment already (accurately) documents it as composing `choice-list`'s Keuzelijst +> pattern rather than a distinct Actieblok one — left as-is rather than forced to claim a +> nonexistent mapping. `application-link`'s `.static-row` (flagged as a marked-gap +> candidate in this file's own correction note) got the marker too. The optional +> `check:cibg-gaps` script (step 4) is skipped: the register is nine rows, reviewed at PR +> time same as any other doc — a CI script to diff it against code markers is complexity +> the size of the problem doesn't warrant (noted, not built). + > **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite > had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload"). > The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`, @@ -72,11 +84,11 @@ Components to mark (closest CIBG concept in parens): ## Acceptance criteria -- [ ] Every component with hand-rolled surface CSS either wraps vendored classes or +- [x] Every component with hand-rolled surface CSS either wraps vendored classes or carries the marker (spot-check with a grep for `styles: [` vs markers). -- [ ] Register MDX complete, linked from ADR-0003. -- [ ] `upload-status-banner` gone; consumer green; no story coverage lost. -- [ ] List trio documented. +- [x] Register MDX complete, linked from ADR-0003. +- [x] `upload-status-banner` gone; consumer green; no story coverage lost. +- [x] List trio documented. ## Verification diff --git a/documentation.json b/documentation.json index dea4214..47dbfc6 100644 --- a/documentation.json +++ b/documentation.json @@ -2082,7 +2082,54 @@ }, { "name": "Draft", - "id": "interface-Draft-51c29a3fca3c5bd3eba53c9bc57714c79817ae8c86b1ed9c1cd76652d31b45ee7c635d35f7eb0a3a7e5bf9ea7c5da5c066cee3908c8fe4b9fe053586f834b133", + "id": "interface-Draft-3f75a0ff51d12ccab50bfa13789cace778d5acff9f4feaa1607cdf1acb4a5ab4fcc687512424cb2252ec07568f6aea07a0b4f04469cbeed733c28654443ffd9e", + "file": "src/app/herregistratie/domain/herregistratie.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\nimport {\n UploadState,\n UploadMsg,\n initialUpload,\n reduceUpload,\n requiredCategoriesSatisfied,\n deliveryRefs,\n} from '@shared/upload/upload.machine';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n uren: string;\n jaren: string;\n punten: string;\n}\n\nexport type StepErrors = Partial>;\n\n/** What we have AFTER parsing — branded/typed, guaranteed valid. */\nexport interface Valid {\n uren: Uren;\n jaren: number;\n punten: number;\n documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;\n}\n\n/**\n * The whole wizard as one tagged union. `step` and `errors` exist ONLY while\n * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.\n * So \"submitting while a field is invalid\" or \"showing the success screen with\n * errors set\" are unrepresentable — the bug class is gone by construction.\n */\nexport type WizardState =\n | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: WizardState = {\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n};\n\n/** Has the user meaningfully started, so it's worth persisting as a Concept? */\nexport function hasProgress(s: Extract): boolean {\n return (\n s.step > 1 ||\n !!s.draft.uren ||\n !!s.draft.jaren ||\n !!s.draft.punten ||\n deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)\n );\n}\n\n/** Parse every field; on success hand back a Valid, else the per-field errors. */\nfunction validate(draft: Draft, upload: UploadState): Result {\n const uren = parseUren(draft.uren);\n const jaren = parseUren(draft.jaren);\n const punten = parseUren(draft.punten);\n const errors: StepErrors = {};\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n if (!punten.ok) errors.punten = punten.error;\n if (!requiredCategoriesSatisfied(upload)) {\n errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies \"per post nasturen\").`;\n }\n if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {\n return {\n ok: true,\n value: {\n uren: uren.value,\n jaren: jaren.value,\n punten: punten.value,\n documents: deliveryRefs(upload),\n },\n };\n }\n return { ok: false, error: errors };\n}\n\n/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */\nexport function next(s: WizardState): WizardState {\n if (s.tag !== 'Editing') return s;\n const errors: StepErrors = {};\n if (s.step === 1) {\n const uren = parseUren(s.draft.uren);\n const jaren = parseUren(s.draft.jaren);\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };\n }\n if (s.step === 2) {\n const punten = parseUren(s.draft.punten);\n if (!punten.ok) errors.punten = punten.error;\n return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };\n }\n return s;\n}\n\nexport function back(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step === 1) return s;\n return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };\n}\n\n/** Jump back to an earlier step to correct data (controle → step N). Forward\n jumps are not allowed (would skip validation). */\nexport function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {\n if (s.tag !== 'Editing' || step >= s.step) return s;\n return { ...s, step, errors: {} };\n}\n\n/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */\nexport function submit(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step !== 3) return s;\n const result = validate(s.draft, s.upload);\n return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };\n}\n\n/** Route an upload sub-message through the pure upload reducer (Editing only). */\nexport function upload(s: WizardState, msg: UploadMsg): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, upload: reduceUpload(s.upload, msg) };\n}\n\n/** Resolve the async submit. Only meaningful while Submitting. */\nexport function resolve(s: WizardState, r: Result): WizardState {\n if (s.tag !== 'Submitting') return s;\n return r.ok\n ? { tag: 'Submitted', data: s.data }\n : { tag: 'Failed', data: s.data, error: r.error };\n}\n\n/** Update one draft field while editing; ignored in any other state. */\nexport function setField(s: WizardState, key: keyof Draft, value: string): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, draft: { ...s.draft, [key]: value } };\n}\n\n/**\n * Every event that can happen to the wizard, as one message type. The component\n * sends a WizardMsg; `reduce` decides the next state. This is the Elm\n * Model+Msg+update pattern: ONE pure function describes all state changes.\n */\nexport type WizardMsg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'GaNaarStap'; step: 1 | 2 | 3 }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Upload'; msg: UploadMsg }\n | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)\n\nexport function reduce(s: WizardState, m: WizardMsg): WizardState {\n switch (m.tag) {\n case 'SetField':\n return setField(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'GaNaarStap':\n return gaNaarStap(s, m.step);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Upload':\n return upload(s, m.msg);\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "properties": [ + { + "name": "jaren", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 15 + }, + { + "name": "punten", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 16 + }, + { + "name": "uren", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 14 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

What the user is typing (raw, possibly invalid).

\n", + "rawdescription": "\nWhat the user is typing (raw, possibly invalid).", + "methods": [], + "extends": [] + }, + { + "name": "Draft", + "id": "interface-Draft-51c29a3fca3c5bd3eba53c9bc57714c79817ae8c86b1ed9c1cd76652d31b45ee7c635d35f7eb0a3a7e5bf9ea7c5da5c066cee3908c8fe4b9fe053586f834b133-1", "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -2125,11 +2172,14 @@ "description": "

What the user is typing (raw, possibly invalid).

\n", "rawdescription": "\nWhat the user is typing (raw, possibly invalid).", "methods": [], - "extends": [] + "extends": [], + "isDuplicate": true, + "duplicateId": 1, + "duplicateName": "Draft-1" }, { "name": "Draft", - "id": "interface-Draft-9669bb22f8d4cc591a3bd64d9fc83275b70ad9e7275d59b4d1d394db7252e8820908b20c593f0b3a16c415c40ad1232ed7d04ba684d0dee9d72bc1ae820d06b6-1", + "id": "interface-Draft-9669bb22f8d4cc591a3bd64d9fc83275b70ad9e7275d59b4d1d394db7252e8820908b20c593f0b3a16c415c40ad1232ed7d04ba684d0dee9d72bc1ae820d06b6-2", "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -2254,56 +2304,6 @@ "methods": [], "extends": [], "isDuplicate": true, - "duplicateId": 1, - "duplicateName": "Draft-1" - }, - { - "name": "Draft", - "id": "interface-Draft-3f75a0ff51d12ccab50bfa13789cace778d5acff9f4feaa1607cdf1acb4a5ab4fcc687512424cb2252ec07568f6aea07a0b4f04469cbeed733c28654443ffd9e-2", - "file": "src/app/herregistratie/domain/herregistratie.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "interface", - "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\nimport {\n UploadState,\n UploadMsg,\n initialUpload,\n reduceUpload,\n requiredCategoriesSatisfied,\n deliveryRefs,\n} from '@shared/upload/upload.machine';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n uren: string;\n jaren: string;\n punten: string;\n}\n\nexport type StepErrors = Partial>;\n\n/** What we have AFTER parsing — branded/typed, guaranteed valid. */\nexport interface Valid {\n uren: Uren;\n jaren: number;\n punten: number;\n documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;\n}\n\n/**\n * The whole wizard as one tagged union. `step` and `errors` exist ONLY while\n * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.\n * So \"submitting while a field is invalid\" or \"showing the success screen with\n * errors set\" are unrepresentable — the bug class is gone by construction.\n */\nexport type WizardState =\n | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: WizardState = {\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n};\n\n/** Has the user meaningfully started, so it's worth persisting as a Concept? */\nexport function hasProgress(s: Extract): boolean {\n return (\n s.step > 1 ||\n !!s.draft.uren ||\n !!s.draft.jaren ||\n !!s.draft.punten ||\n deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)\n );\n}\n\n/** Parse every field; on success hand back a Valid, else the per-field errors. */\nfunction validate(draft: Draft, upload: UploadState): Result {\n const uren = parseUren(draft.uren);\n const jaren = parseUren(draft.jaren);\n const punten = parseUren(draft.punten);\n const errors: StepErrors = {};\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n if (!punten.ok) errors.punten = punten.error;\n if (!requiredCategoriesSatisfied(upload)) {\n errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies \"per post nasturen\").`;\n }\n if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {\n return {\n ok: true,\n value: {\n uren: uren.value,\n jaren: jaren.value,\n punten: punten.value,\n documents: deliveryRefs(upload),\n },\n };\n }\n return { ok: false, error: errors };\n}\n\n/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */\nexport function next(s: WizardState): WizardState {\n if (s.tag !== 'Editing') return s;\n const errors: StepErrors = {};\n if (s.step === 1) {\n const uren = parseUren(s.draft.uren);\n const jaren = parseUren(s.draft.jaren);\n if (!uren.ok) errors.uren = uren.error;\n if (!jaren.ok) errors.jaren = jaren.error;\n return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };\n }\n if (s.step === 2) {\n const punten = parseUren(s.draft.punten);\n if (!punten.ok) errors.punten = punten.error;\n return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };\n }\n return s;\n}\n\nexport function back(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step === 1) return s;\n return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };\n}\n\n/** Jump back to an earlier step to correct data (controle → step N). Forward\n jumps are not allowed (would skip validation). */\nexport function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {\n if (s.tag !== 'Editing' || step >= s.step) return s;\n return { ...s, step, errors: {} };\n}\n\n/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */\nexport function submit(s: WizardState): WizardState {\n if (s.tag !== 'Editing' || s.step !== 3) return s;\n const result = validate(s.draft, s.upload);\n return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };\n}\n\n/** Route an upload sub-message through the pure upload reducer (Editing only). */\nexport function upload(s: WizardState, msg: UploadMsg): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, upload: reduceUpload(s.upload, msg) };\n}\n\n/** Resolve the async submit. Only meaningful while Submitting. */\nexport function resolve(s: WizardState, r: Result): WizardState {\n if (s.tag !== 'Submitting') return s;\n return r.ok\n ? { tag: 'Submitted', data: s.data }\n : { tag: 'Failed', data: s.data, error: r.error };\n}\n\n/** Update one draft field while editing; ignored in any other state. */\nexport function setField(s: WizardState, key: keyof Draft, value: string): WizardState {\n if (s.tag !== 'Editing') return s;\n return { ...s, draft: { ...s.draft, [key]: value } };\n}\n\n/**\n * Every event that can happen to the wizard, as one message type. The component\n * sends a WizardMsg; `reduce` decides the next state. This is the Elm\n * Model+Msg+update pattern: ONE pure function describes all state changes.\n */\nexport type WizardMsg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'GaNaarStap'; step: 1 | 2 | 3 }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Upload'; msg: UploadMsg }\n | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)\n\nexport function reduce(s: WizardState, m: WizardMsg): WizardState {\n switch (m.tag) {\n case 'SetField':\n return setField(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'GaNaarStap':\n return gaNaarStap(s, m.step);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Upload':\n return upload(s, m.msg);\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", - "properties": [ - { - "name": "jaren", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 15 - }, - { - "name": "punten", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 16 - }, - { - "name": "uren", - "deprecated": false, - "deprecationMessage": "", - "type": "string", - "indexKey": "", - "optional": false, - "description": "", - "line": 14 - } - ], - "indexSignatures": [], - "kind": 172, - "description": "

What the user is typing (raw, possibly invalid).

\n", - "rawdescription": "\nWhat the user is typing (raw, possibly invalid).", - "methods": [], - "extends": [], - "isDuplicate": true, "duplicateId": 2, "duplicateName": "Draft-2" }, @@ -3949,12 +3949,12 @@ }, { "name": "PlaceholderOption", - "id": "interface-PlaceholderOption-16efb1db84d6d2b05eab88e5b28311d531702eb0dc436f253d64a3d2b97ffef684b970354106e73042f9e90cacec7c3fc5c0ecb7fcadaf4d8a01c5d89fe8c617", + "id": "interface-PlaceholderOption-ae1eb3c082be754cbf825b0f61066cb33d1de86481fad44f304003f9e17ee9f32627b3b1952e6d4e581ab6ccbc5ae3d23080c63fcbf281e797dfba04413e6ca8", "file": "src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';\nimport { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';\nimport { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';\n\n/** A menu entry for the insert-placeholder control — a plain {key,label}, so the\n editor stays domain-free (it never sees the brief's PlaceholderDef). */\nexport interface PlaceholderOption {\n readonly key: string;\n readonly label: string;\n // Auto-resolvable fields are filled server-side at send; manual fields need a value.\n // Drives the chip's styling so the two read apart at a glance.\n readonly autoResolvable?: boolean;\n}\n\n/**\n * Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.\n *\n * It is the single quarantined boundary to the imperative `contenteditable` DOM:\n * `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.\n * Placeholders render as non-editable chips and can only be inserted from the menu\n * (valid keys only) — never typed as raw braces. Swapping in a real editor library\n * later (TipTap) means replacing only this component; nothing else sees the DOM.\n */\n@Component({\n selector: 'app-rich-text-editor',\n styles: [\n `\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n `,\n ],\n template: `\n @if (editable()) {\n
\n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
\n }\n \n `,\n})\nexport class RichTextEditorComponent {\n content = input(emptyBlock());\n placeholders = input([]);\n editable = input(true);\n contentChanged = output();\n\n // Localizable-by-default copy (shared-UI convention).\n fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);\n toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);\n boldLabel = input($localize`:@@richTextEditor.bold:Vet`);\n italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);\n underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);\n insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);\n insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);\n bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);\n numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);\n\n private editorEl = viewChild>('editor');\n private lastEmitted = '';\n\n private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;\n private autoFor = (key: string) =>\n this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;\n\n constructor() {\n // Render when content arrives/changes from OUTSIDE. Skip our own emitted value\n // flowing back (structural compare) so the caret isn't reset while typing.\n effect(() => {\n const content = this.content();\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const serialized = JSON.stringify(content);\n if (serialized === this.lastEmitted) return;\n renderInto(el, content, this.labelFor, this.autoFor);\n this.lastEmitted = serialized;\n });\n }\n\n protected emit() {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const block = readBlock(el);\n this.lastEmitted = JSON.stringify(block);\n this.contentChanged.emit(block);\n }\n\n protected format(cmd: 'bold' | 'italic' | 'underline') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n // ponytail: execCommand is deprecated but universally supported and zero-dependency;\n // if a browser drops it, this component is the one place to swap in a range-based impl.\n el.ownerDocument.execCommand(cmd);\n this.emit();\n }\n\n /** Bullet / numbered lists via execCommand — same deprecated-but-universal path as\n bold/italic (ponytail-noted on `format`); readBlock reads the resulting