feat(brief): WP-28 demo script, e2e spec, and story gap-fill

Closes phase 6 (Brief v2): a demo script mapping shipped scenarios to
URL+click paths (no Brief v2 PRD ever existed to translate one from —
written directly against the code instead), one e2e spec covering
compose→approve→send and admin republish→drafter-sees-it, and
Storybook state gaps (rejection diff, read-only viewer, org logo,
upload rejection) that prior WPs left uncovered. Flags passage-picker
as dead code, superseded by besluit-panel.

npm run e2e is not verified green in this sandbox — see WP-28's
Deviations section; the pre-existing, unmodified smoke.spec.ts fails
identically here, pointing at a sandbox rendering issue rather than a
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-27 20:01:51 +02:00
co-authored by Claude Sonnet 5
parent 94cd0b82c2
commit e7e2f070f9
11 changed files with 513 additions and 160 deletions
+149 -149
View File
@@ -28644,7 +28644,7 @@
},
{
"name": "PassagePickerComponent",
"id": "component-PassagePickerComponent-25156c4d1008c23952424dd87e6d92081346f0c822913b8ba0921f630399914d5c0ef8af5b5dfe67be1f8a9486b92e35c5115cf66f442cbb7e9a21a0351280aa",
"id": "component-PassagePickerComponent-ba7eccc65c72f5ec20ce8442c142a6c4eb63d430727d8e470a493162d579c1e8a0236b9927d38d75bea4a0246ff2f5afbf7ce996eaf3fd89f4fa0fc28a2c6857",
"file": "src/app/brief/ui/passage-picker/passage-picker.component.ts",
"encapsulation": [],
"entryComponents": [],
@@ -28669,7 +28669,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 77,
"line": 81,
"required": false
},
{
@@ -28680,7 +28680,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 79,
"line": 83,
"required": false
},
{
@@ -28691,7 +28691,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 78,
"line": 82,
"required": false
},
{
@@ -28702,7 +28702,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 81,
"line": 85,
"required": false
},
{
@@ -28713,7 +28713,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 74,
"line": 78,
"required": true
},
{
@@ -28724,7 +28724,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 80,
"line": 84,
"required": false
}
],
@@ -28737,7 +28737,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 75,
"line": 79,
"required": false
}
],
@@ -28751,7 +28751,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 83,
"line": 87,
"modifierKind": [
124
]
@@ -28765,7 +28765,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 94,
"line": 98,
"modifierKind": [
124
]
@@ -28779,7 +28779,7 @@
"indexKey": "",
"optional": false,
"description": "<p>Client-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see <code>textOf</code>).</p>\n",
"line": 87,
"line": 91,
"rawdescription": "\nClient-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see `textOf`).",
"modifierKind": [
124
@@ -28794,7 +28794,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 84,
"line": 88,
"modifierKind": [
124
]
@@ -28807,7 +28807,7 @@
"optional": false,
"returnType": "void",
"typeParameters": [],
"line": 100,
"line": 104,
"deprecated": false,
"deprecationMessage": "",
"modifierKind": [
@@ -28837,7 +28837,7 @@
"optional": false,
"returnType": "void",
"typeParameters": [],
"line": 96,
"line": 100,
"deprecated": false,
"deprecationMessage": "",
"modifierKind": [
@@ -28892,10 +28892,10 @@
"type": "component"
}
],
"description": "<p>Molecule: multi-select list of the section&#39;s library passages. One &quot;Voeg toe&quot;\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.</p>\n",
"rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.",
"description": "<p>Molecule: multi-select list of the section&#39;s library passages. One &quot;Voeg toe&quot;\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.</p>\n<p>Superseded by <code>besluit-panel</code> (WP-27&#39;s guided drafting): no consumer left in\n<code>src/app</code> outside its own story (WP-28 audit). Kept for now rather than deleted\nin-flight of an unrelated WP; a future cleanup can remove it.</p>\n",
"rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.\n\nSuperseded by `besluit-panel` (WP-27's guided drafting): no consumer left in\n`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted\nin-flight of an unrelated WP; a future cleanup can remove it.",
"type": "component",
"sourceCode": "import { Component, computed, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { textOf } from '@shared/kernel/rich-text';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n `,\n ],\n template: `\n <div class=\"search\">\n <app-text-input\n [placeholder]=\"searchLabel()\"\n [attr.aria-label]=\"searchLabel()\"\n [ngModel]=\"query()\"\n (ngModelChange)=\"query.set($event)\"\n />\n </div>\n <ul>\n @for (p of filtered(); track p.passageId) {\n <li>\n <app-checkbox\n [checkboxId]=\"'passage-' + p.passageId\"\n [label]=\"p.label\"\n [ngModel]=\"!!checked()[p.passageId]\"\n (ngModelChange)=\"set(p.passageId, $event)\"\n />\n <span class=\"scope\"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>\n </li>\n } @empty {\n <li class=\"empty\">{{ noMatchLabel() }}</li>\n }\n </ul>\n <app-button variant=\"secondary\" [disabled]=\"count() === 0\" (click)=\"add()\"\n >{{ addLabel() }} ({{ count() }})</app-button\n >\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required<readonly LibraryPassage[]>();\n insert = output<LibraryPassage[]>();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);\n noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);\n\n protected checked = signal<Record<string, boolean>>({});\n protected query = signal('');\n /** Client-side filter on label + rendered content text — the library is small, so no\n server search (WP-27). Placeholder keys are searchable too (see `textOf`). */\n protected filtered = computed(() => {\n const q = this.query().trim().toLowerCase();\n if (!q) return this.passages();\n return this.passages().filter(\n (p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),\n );\n });\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n",
"sourceCode": "import { Component, computed, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { textOf } from '@shared/kernel/rich-text';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order.\n\n Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in\n `src/app` outside its own story (WP-28 audit). Kept for now rather than deleted\n in-flight of an unrelated WP; a future cleanup can remove it. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n `,\n ],\n template: `\n <div class=\"search\">\n <app-text-input\n [placeholder]=\"searchLabel()\"\n [attr.aria-label]=\"searchLabel()\"\n [ngModel]=\"query()\"\n (ngModelChange)=\"query.set($event)\"\n />\n </div>\n <ul>\n @for (p of filtered(); track p.passageId) {\n <li>\n <app-checkbox\n [checkboxId]=\"'passage-' + p.passageId\"\n [label]=\"p.label\"\n [ngModel]=\"!!checked()[p.passageId]\"\n (ngModelChange)=\"set(p.passageId, $event)\"\n />\n <span class=\"scope\"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>\n </li>\n } @empty {\n <li class=\"empty\">{{ noMatchLabel() }}</li>\n }\n </ul>\n <app-button variant=\"secondary\" [disabled]=\"count() === 0\" (click)=\"add()\"\n >{{ addLabel() }} ({{ count() }})</app-button\n >\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required<readonly LibraryPassage[]>();\n insert = output<LibraryPassage[]>();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);\n noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);\n\n protected checked = signal<Record<string, boolean>>({});\n protected query = signal('');\n /** Client-side filter on label + rendered content text — the library is small, so no\n server search (WP-27). Placeholder keys are searchable too (see `textOf`). */\n protected filtered = computed(() => {\n const q = this.query().trim().toLowerCase();\n if (!q) return this.passages();\n return this.passages().filter(\n (p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),\n );\n });\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n",
"assetsDirs": [],
"styleUrlsData": "",
"stylesData": "\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n \n",
@@ -34438,6 +34438,26 @@
"rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.",
"description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "WizardState",
"defaultValue": "{\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "IntakeState",
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
@@ -34468,26 +34488,6 @@
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "WizardState",
"defaultValue": "{\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "IntakeState",
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
@@ -40357,6 +40357,94 @@
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "WizardState",
"jsdoctags": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "IntakeState",
"jsdoctags": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
@@ -40489,94 +40577,6 @@
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "WizardState",
"jsdoctags": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "IntakeState",
"jsdoctags": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/registratie/domain/change-request.machine.ts",
@@ -44420,30 +44420,6 @@
"defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}"
}
],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/brief/domain/org-template.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/org-template.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/herregistratie/domain/herregistratie.machine.ts": [
{
"name": "initial",
@@ -44492,6 +44468,30 @@
"description": "<p>The fixed step list. Number of steps never changes; questions reveal inline.</p>\n"
}
],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/brief/domain/org-template.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/org-template.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/registratie/domain/change-request.machine.ts": [
{
"name": "initial",