refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)

204 WP-NN/RB-NN comments named a closed ticket instead of the code they
sit next to. git blame already records history and stays correct when
code moves; the comment does not. This sweep removes the reference and
keeps the sentence, across 95 files in apps/ and libs/ plus the
behaviour-spec generator's header text.

Eleven references stay: five story files justify an a11y disable per
the README's rule, and one line in a11y.mdx documents that convention.
Two sentences needed a rewrite, not a deletion, so the reference's
meaning survives its removal. behaviour-spec.mdx is regenerated, not
hand-edited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 21:23:07 +02:00
co-authored by Claude Sonnet 5
parent 3895588b9a
commit dd11eafe50
102 changed files with 361 additions and 197 deletions
+3 -3
View File
@@ -20,7 +20,7 @@ export const routes: Routes = [
}, },
{ {
path: 'aanvraag/:id', path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the // Same capability the werkvoorraad list itself is gated by — the
// detail page is reachable only from a row already filtered to that capability. // detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')], canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () => loadComponent: () =>
@@ -36,14 +36,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. // Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker';
/** /**
* Infrastructure: resolves the current medewerker identity into a `Principal` * Infrastructure: resolves the current medewerker identity into a `Principal`
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, * (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
* BSN check, no format to reject, so `authenticate()` takes no input and returns the * BSN check, no format to reject, so `authenticate()` takes no input and returns the
* `Principal` directly rather than a `Result` with an error variant that can never * `Principal` directly rather than a `Result` with an error variant that can never
@@ -2,7 +2,7 @@ import { Component, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
/** /**
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, * Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
* BSN, and this app has no password of its own to check either way. There is * BSN, and this app has no password of its own to check either way. There is
* nothing to compose beyond one button, which is itself evidence for the ADR — the * nothing to compose beyond one button, which is itself evidence for the ADR — the
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined; type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`. /** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */ Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BeoordelingStore { export class BeoordelingStore {
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined; type Err = Error | undefined;
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp /** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */ counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class WerkvoorraadStore { export class WerkvoorraadStore {
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item'; import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling'; import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling /** View-model mapping shared by the werkvoorraad list and the beoordeling
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in detail screen: type/status → labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two `werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */ status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
@@ -1,8 +1,8 @@
import { AanvraagType } from './werkvoorraad-item'; import { AanvraagType } from './werkvoorraad-item';
/** /**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) * A case's full status lifecycle as the beoordeling detail screen sees it —
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open" * wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept` * tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet). * — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/ */
@@ -1,6 +1,6 @@
import { Result, assertNever } from '@shared/kernel/fp'; import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the /** The three actions the beoordeling screen offers a behandelaar — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */ enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const; const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
@@ -1,5 +1,5 @@
/** /**
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view * A queue entry as the behandelportal sees it — the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular. * of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
* *
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's * The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
@@ -13,7 +13,7 @@ import {
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item'; import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/** /**
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its * Infrastructure adapter for the beoordeling detail read — the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated + * HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below. * mapped to domain by the parse* boundary below.
*/ */
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine'; import { Valid } from '@behandeling/domain/besluit.machine';
/** /**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single * Infrastructure adapter for recording a behandelaar's decision — the single
* place its HTTP lives. No return value: a successful call means the server accepted * place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the * the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority). * server, not this adapter, re-validates and is the authority).
@@ -8,7 +8,7 @@ import {
} from '@behandeling/domain/werkvoorraad-item'; } from '@behandeling/domain/werkvoorraad-item';
/** /**
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only * Infrastructure adapter for the behandelportal's queue read — the only
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response * place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary * is validated + mapped to the (narrower) queue domain shape by the parse* boundary
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not * below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling'; import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing /** Organism: the documents linked to an aanvraag — plain links to the existing
(pre-existing, unauthenticated — same as ssp's own document previews) content (pre-existing, unauthenticated — same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */ endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({ @Component({
@@ -14,8 +14,8 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component'; import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/** /**
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links * Page: one aanvraag's beoordeling detail. The werkvoorraad list links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
* the page never recomputes the lifecycle itself. On a recorded decision the form emits * the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status). * `decided`, and the page just reloads (the server is the authority on the new status).
*/ */
@@ -14,7 +14,7 @@ import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/b
import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/** /**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same * Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one * idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*` * signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the * command returning `Result`. The server re-validates the transition and is the
@@ -4,9 +4,9 @@ import { ApplicationLinkComponent } from '@shared/ui/application-link/applicatio
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition /** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
of the two existing shared/ui molecules, no new atom. Each row links to the of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */ beoordeling detail page. */
@Component({ @Component({
selector: 'app-werkvoorraad-list', selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent], imports: [ApplicationListComponent, ApplicationLinkComponent],
@@ -10,10 +10,10 @@ import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component'; import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
/** /**
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page. * Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's * Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening * AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
* a case's detail is out of scope here (WP-65). * a case's detail is out of scope here.
*/ */
@Component({ @Component({
selector: 'app-werkvoorraad-page', selector: 'app-werkvoorraad-page',
@@ -69,7 +69,7 @@ export class WerkvoorraadPage {
private loadRequested = false; private loadRequested = false;
constructor() { constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) — // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson). // same guard-against-the-loop idiom as AdminCasesPage.
effect(() => { effect(() => {
if (this.canBeoordelen() && !this.loadRequested) { if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS. /** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
WP-61's bootstrap trim, not revisited by this migration. */ the bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [ export const ADMIN_LINKS: readonly AdminLink[] = [
{ {
label: $localize`:@@header.nav.stamdata:Stamdata`, label: $localize`:@@header.nav.stamdata:Stamdata`,
+4 -4
View File
@@ -61,7 +61,7 @@ export const routes: Routes = [
}, },
{ {
path: 'brief/huisstijl', path: 'brief/huisstijl',
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default // Admin-only org-template editor: capabilityGuard denies-by-default
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
// via the OrgAdmin gate — the guard just avoids loading a page that would 403. // via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')], canActivate: [capabilityGuard('orgtemplate:edit')],
@@ -78,7 +78,7 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/zaken', path: 'beheer/zaken',
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default // Admin-only cases overview + delete: capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the // unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page // CasesAdmin gate — the guard just avoids loading a page that would 403. The page
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer. // lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
@@ -88,14 +88,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. // Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -7,7 +7,7 @@ import { Principal } from '../domain/principal';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DigidAdapter { export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity // Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP. // for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Principal>> { async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn); const r = parseBsn(bsn);
@@ -54,7 +54,7 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */ touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() { function fakeBlobPresenter() {
const opened: Blob[] = []; const opened: Blob[] = [];
@@ -158,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
}); });
}); });
// --- WP-27: undo/redo history + rejection diff --- // --- Undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock { function block(id: string, text: string): LetterBlock {
return { return {
@@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => {
describe('BriefStore.previewLetter', () => { describe('BriefStore.previewLetter', () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { it('opens the composed letter via BLOB_PRESENTER on success', async () => {
const { presenter, opened } = fakeBlobPresenter(); const { presenter, opened } = fakeBlobPresenter();
const store = setup( const store = setup(
{ {
@@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
}); });
}); });
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the // --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds // existing reset() command, exactly once. Today's backend never 404s yet;
// that); this fake adapter is what exercises the branch until then. --- // this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance (RB-22)', () => { describe('BriefStore.load — 404 tolerance', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } }; const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view }; const resetOk: Result<string, BriefView> = { ok: true, value: view };
@@ -55,8 +55,8 @@ export class BriefStore implements PendingSave {
/** Surfaced autosave state for the indicator + aria-live region. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of /** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded `Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would (they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */ changes. */
@@ -64,7 +64,7 @@ export class BriefStore implements PendingSave {
readonly canUndo = this.history.canUndo; readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo; readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The /** The letter as it stood when it was REJECTED, captured shell-side. The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
full page reload loses it — a real system would persist the rejected revision. */ full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null); private rejectionSnapshot = signal<Brief | null>(null);
@@ -81,7 +81,7 @@ export class BriefStore implements PendingSave {
); );
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0); readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data, /** The org template the letter renders with. Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts` not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */ stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null); readonly orgTemplate = signal<OrgTemplate | null>(null);
@@ -125,7 +125,7 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
}); });
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand /** True once a 404-triggered recovery has been attempted (CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound: half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */ `adapter.reset()` a second time, regardless of how many times `load()` runs. */
@@ -200,7 +200,7 @@ export class BriefStore implements PendingSave {
} }
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in // 600ms debounced autosave (the server is the store of record). Timer mechanics live in
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31). // the shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(), canSave: () => this.canEdit(),
flush: () => this.flushSave(), flush: () => this.flushSave(),
@@ -223,7 +223,7 @@ export class BriefStore implements PendingSave {
} }
} }
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */ /** Retry a failed autosave — reuses the existing flush path, no new state. */
retrySave() { retrySave() {
void this.flushSave(); void this.flushSave();
} }
@@ -311,7 +311,7 @@ export class BriefStore implements PendingSave {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break; break;
case 'rejected': case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the // Capture the letter as-rejected for the resubmission diff. This is the
// "before" snapshot the approver later compares against. // "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief); this.rejectionSnapshot.set(brief);
this.store.dispatch({ this.store.dispatch({
@@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
]; ];
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */ touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() { function fakeBlobPresenter() {
const opened: Blob[] = []; const opened: Blob[] = [];
@@ -70,10 +70,10 @@ function setup(
return TestBed.inject(OrgTemplateStore); return TestBed.inject(OrgTemplateStore);
} }
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw // --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- // window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
describe('OrgTemplateStore.proefbrief (RB-28)', () => { describe('OrgTemplateStore.proefbrief', () => {
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
// Given a loaded sub-org template. // Given a loaded sub-org template.
const { presenter, opened } = fakeBlobPresenter(); const { presenter, opened } = fakeBlobPresenter();
@@ -28,7 +28,7 @@ const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
/** /**
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the * Root singleton for the admin org-template editor. The Elm machine owns the
* editable draft; commands here do the debounced save, publish (impact-confirm), * editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft * logo upload reuses the shared upload transport; its completion mutates the draft
@@ -151,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
this.debouncedSave.schedule(); this.debouncedSave.schedule();
} }
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the // 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
// shared helper; `flushSave` below is the store-specific write + save-state. // shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null, canSave: () => this.loaded() !== null,
+1 -1
View File
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
/** /**
* The rejection diff as a PURE function over two immutable `Brief` values the whole * The rejection diff as a PURE function over two immutable `Brief` values the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter * teaching payload here: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping. * was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
* *
* Blocks are matched by `blockId` (stable `local-N`/seed ids): * Blocks are matched by `blockId` (stable `local-N`/seed ids):
@@ -3,7 +3,7 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from '
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/** /**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) * The admin org-template editor as one Elm-style machine (PRD Brief v2 §5)
* the same idiom as the wizards. The DRAFT org template is form state (edited in * the same idiom as the wizards. The DRAFT org template is form state (edited in
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`. * place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is * `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
@@ -1,9 +1,9 @@
/** /**
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis * The organization template (Brief v2 PRD §3): the SECOND template axis
* appearance/identity per sub-organization (letterhead, footer, signature, margins). * appearance/identity per sub-organization (letterhead, footer, signature, margins).
* Orthogonal to the case-type template (sections + placeholders); the two only meet * Orthogonal to the case-type template (sections + placeholders); the two only meet
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim, * at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26). * never edits it here (the admin editor does).
*/ */
export interface Margins { export interface Margins {
@@ -30,7 +30,7 @@ export interface OrgTemplate {
readonly version: number; readonly version: number;
} }
// --- admin editor (WP-26) --- // --- admin editor ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */ /** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion { export interface OrgTemplateVersion {
@@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* (ProblemDetails error string, plus the Idempotency-Key mint), then parses the * (ProblemDetails error string, plus the Idempotency-Key mint), then parses the
* returned brief. `load` (the only read) does its own try/catch instead of the * returned brief. `load` (the only read) does its own try/catch instead of the
* shared `runResult` fold, because it needs one extra bit `runResult` throws away: * shared `runResult` fold, because it needs one extra bit `runResult` throws away:
* whether the failure was an HTTP 404 (see `BriefLoadFailure` RB-22, CQ-007's * whether the failure was an HTTP 404 (see `BriefLoadFailure` CQ-007's
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the * expand half). Today's backend never 404s `GET /brief`, so the
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. * `notFound` branch is unreached until it does; this adapter is ready in advance.
*/ */
export interface BriefView { export interface BriefView {
@@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is
/** True when the thrown value carries an HTTP 404 status matches both the /** True when the thrown value carries an HTTP 404 status matches both the
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404 generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
RB-23 gives the endpoint a documented 404 response). */ the endpoint gets a documented 404 response). */
function isHttpNotFound(e: unknown): boolean { function isHttpNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
} }
@@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* to keep the NSwag-generated client JSON-only (same seam as uploads) so this is a * to keep the NSwag-generated client JSON-only (same seam as uploads) so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s * hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 without `X-Subject` this always previewed * here explicitly (without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under * dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev * `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) a production build sends neither header from this call (BIO-012). * (`app.config.ts`) a production build sends neither header from this call (BIO-012).
* *
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven * `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves * `Vary: Origin`, and its content changes at the SAME URL as the letter moves
* draft sent. Explicitly bypassing the HTTP cache is the correct default for any * draft sent. Explicitly bypassing the HTTP cache is the correct default for any
* mutable resource served under one unversioned URL independent of WP-74's * mutable resource served under one unversioned URL independent of the
* identity work, and not a complete fix by itself: see the KNOWN GAP note below. * identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
* *
* KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`, * KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* this repo's own e2e run against a real backend observed this endpoint's SENT * this repo's own e2e run against a real backend observed this endpoint's SENT
* response still carrying the draft watermark, even though (a) the outgoing request * response still carrying the draft watermark, even though (a) the outgoing request
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the * carried the correct `X-Subject`, and (b) `curl` against the same backend at the
@@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* did not change the outcome, so it is very unlikely a client-side caching artifact * did not change the outcome, so it is very unlikely a client-side caching artifact
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed * it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for * read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
* `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope * `DemoOwner`, which needs backend-side investigation (out of this file's scope
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared * see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
* `zorgverlener` identity until this is root-caused). * `zorgverlener` identity until this is root-caused).
*/ */
+2 -2
View File
@@ -167,7 +167,7 @@ export class BriefPage {
void this.store.resetDemo(); void this.store.resetDemo();
} }
/** Typed narrowing for the `<app-async>` loaded slot see WP-06: a structural /** Typed narrowing for the `<app-async>` loaded slot: a structural
directive's context can't inherit a generic from a sibling host input, so the directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */ Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => { protected readonly loaded = computed(() => {
@@ -179,7 +179,7 @@ export class BriefPage {
void this.store.load(); void this.store.load();
} }
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the /** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working rich-text editor or a form control, so the browser's own text undo keeps working
there our shell-level undo is for structural edits (add/remove/reorder blocks). */ there our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) { protected onKey(e: KeyboardEvent) {
@@ -53,9 +53,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
footer around the case-type template's sections. `editableRegions` picks who edits footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'` what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview), renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26). `'template'` reserves the org-identity regions for the admin editor.
Letter typography/geometry come from the shared `public/letter.css` contract Letter typography/geometry come from the shared `public/letter.css` contract
the same file the backend preview renderer inlines (WP-25). */ the same file the backend preview renderer inlines. */
@Component({ @Component({
selector: 'app-letter-canvas', selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent], imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
@@ -82,7 +82,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
color: var(--rhc-color-foreground-subtle); color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */ /* Rejection-diff badge: a small pill above a changed/added block. */
.diff-block.diff-changed { .diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500); border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm); padding-inline-start: var(--rhc-space-max-sm);
@@ -98,7 +98,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
background: var(--rhc-color-oranje-500); background: var(--rhc-color-oranje-500);
} }
.diff-badge.added { .diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */ /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
color: var(--rhc-color-wit); color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700); background: var(--rhc-color-groen-700);
} }
@@ -345,12 +345,12 @@ export class LetterCanvasComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver /** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */ view) or admin editor ('template'). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none'); editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */ /** Initial zoom; the in-canvas controls take over from here. */
zoom = input(1); zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when /** Blocks changed/added/removed since the letter was rejected; badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set they no longer `showDiff` is on. Removed blocks aren't in the map's rendered set they no longer
exist in the letter the composer surfaces them as a count. */ exist in the letter the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map()); blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
@@ -445,7 +445,7 @@ export class LetterCanvasComponent {
constructor() { constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly // ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative. // falls mid-line; the caption says "±" and the server preview is authoritative.
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark. // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX); const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] }, args: { editableRegions: 'none', diagnostics: [] },
}; };
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */ /** Admin editor focus: body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } }; export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } }; export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */ /** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
export const WithDiff: Story = { export const WithDiff: Story = {
args: { args: {
editableRegions: 'none', editableRegions: 'none',
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] }, args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
}; };
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload). // Inline SVG so the story needs no backend/upload round-trip (the logo upload).
const sampleLogo = const sampleLogo =
'data:image/svg+xml;utf8,' + 'data:image/svg+xml;utf8,' +
encodeURIComponent( encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
); );
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */ /** Published org logo: the letterhead shows it above the org name. */
export const MetLogo: Story = { export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo }, args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
}; };
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
canReject = input(false); canReject = input(false);
canSend = input(false); canSend = input(false);
busy = input(false); busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The /** Rejection diff: the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */ "Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map()); blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0); removedCount = input(0);
@@ -181,7 +181,7 @@ export const Sent: Story = {
}), }),
}; };
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed, /** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
added and removed since the last rejection. */ added and removed since the last rejection. */
export const RejectionDiff: Story = { export const RejectionDiff: Story = {
render: () => render: () =>
@@ -70,7 +70,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
}; };
/** /**
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's * Organism: the admin org-template editor. The mirror of the drafter's
* composer the letter canvas runs in `editableRegions='template'` so the * composer the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only * letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it. * sample. Margins, logo upload, version history and the publish bar sit around it.
@@ -87,12 +87,12 @@ const sampleLogo =
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
); );
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */ /** Published logo: the letterhead canvas shows it above the org name. */
export const MetLogo: Story = { export const MetLogo: Story = {
args: { logoUrl: sampleLogo }, args: { logoUrl: sampleLogo },
}; };
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) type/size caught /** Client-side upload rejection (existing `rejectReason`) type/size caught
before the file ever reaches the backend. */ before the file ever reaches the backend. */
export const LogoUploadFout: Story = { export const LogoUploadFout: Story = {
args: { args: {
@@ -7,7 +7,7 @@ import { AccessStore } from '@shared/application/access.store';
import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default /** Page: thin container for the admin org-template editor. Deny-by-default
capability gate (`orgtemplate:edit`) a denial alert for non-admins, the editor capability gate (`orgtemplate:edit`) a denial alert for non-admins, the editor
for admins. Loads once the capability resolves; wires store commands to the organism. */ for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({ @Component({
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
inserts ALL checked passages at once (a single message upstream) there is no inserts ALL checked passages at once (a single message upstream) there is no
single-insert path. Presentational: emits the chosen passages in list order. single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in Superseded by `besluit-panel`'s guided drafting: no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted `src/app` outside its own story. Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */ in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({ @Component({
selector: 'app-passage-picker', selector: 'app-passage-picker',
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
protected checked = signal<Record<string, boolean>>({}); protected checked = signal<Record<string, boolean>>({});
protected query = signal(''); protected query = signal('');
/** Client-side filter on label + rendered content text the library is small, so no /** Client-side filter on label + rendered content text the library is small, so no
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */ server search. Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => { protected filtered = computed(() => {
const q = this.query().trim().toLowerCase(); const q = this.query().trim().toLowerCase();
if (!q) return this.passages(); if (!q) return this.passages();
@@ -147,7 +147,7 @@ describe('intake acceptance journeys', () => {
}); });
}); });
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => { it('raising uren above the threshold after answering scholing drops both fields', () => {
// Given a journey that answered the scholing question while uren was low. // Given a journey that answered the scholing question while uren was low.
const atReview = givenIntake( const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -170,7 +170,7 @@ describe('intake acceptance journeys', () => {
); );
// Then the submission succeeds, and BOTH the stale answer and its punten are gone — // Then the submission succeeds, and BOTH the stale answer and its punten are gone —
// exactly the crafted-POST-shaped payload WP-69's server rule rejects. // exactly the crafted-POST-shaped payload the server rule rejects.
expect(done.tag).toBe('Submitted'); expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
@@ -171,7 +171,7 @@ describe('submit', () => {
expect(withScholing.data.punten).toBe(200); expect(withScholing.data.punten).toBe(200);
}); });
it('does not require punten for a hidden question (WP-69 §6)', () => { it('does not require punten for a hidden question', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either. // threshold — the template hides the question, so punten must not be required either.
const staleScholingNoPunten = givenIntake( const staleScholingNoPunten = givenIntake(
@@ -183,7 +183,7 @@ describe('submit', () => {
expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.aanvullendeScholing).toBeUndefined();
}); });
it('drops punten when raising uren hides the question (WP-69 §6)', () => { it('drops punten when raising uren hides the question', () => {
// Same stale answer, but this time punten was also filled in while uren was low. // Same stale answer, but this time punten was also filled in while uren was low.
const staleScholingWithPunten = givenIntake( const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -116,7 +116,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
// visible (lageUren) AND scholing was followed — matching the template's // visible (lageUren) AND scholing was followed — matching the template's
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then // `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
// raising uren above the threshold left an error on a field the template no longer // raising uren above the threshold left an error on a field the template no longer
// renders (WP-69 §6). // renders.
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') { if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? ''); const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error; if (!p.ok) errors.punten = p.error;
@@ -149,8 +149,8 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
const aanvullendeScholing = lageUren(a, scholingThreshold) const aanvullendeScholing = lageUren(a, scholingThreshold)
? a.scholingGevolgd === 'ja' ? a.scholingGevolgd === 'ja'
: undefined; : undefined;
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69 // Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the // a stale 'ja' left over from when uren was low, after uren was raised above the
// threshold, must not leak a punten value into the parsed, submitted ValidIntake. // threshold, must not leak a punten value into the parsed, submitted ValidIntake.
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined; const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
return ok({ return ok({
@@ -273,7 +273,7 @@ export class IntakeWizardComponent {
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, { private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
Submitting: async (s, store) => { Submitting: async (s, store) => {
this.profile.beginHerregistratie(); this.profile.beginHerregistratie();
// WP-69: the scholing answer rides along so the server can re-validate it as the // The scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by // authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field. // JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({ const r = await this.draftSync.submit({
@@ -46,8 +46,8 @@ describe('AanvragenStore', () => {
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
// RB-20: a failed cancel must not be silent — the row rolls back AND the store // A failed cancel must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back // surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the cancel fails', async () => { it('rolls back the removal and surfaces the error when the cancel fails', async () => {
const cancel = vi.fn().mockRejectedValue(new Error('boom')); const cancel = vi.fn().mockRejectedValue(new Error('boom'));
@@ -14,7 +14,7 @@ type Err = Error | undefined;
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept In behandeling Goedgekeurd is * so a page revisit reflects auto-approval (Concept In behandeling Goedgekeurd is
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
* surfaces `lastError` on failure (RB-20). * surfaces `lastError` on failure.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AanvragenStore { export class AanvragenStore {
@@ -23,7 +23,7 @@ export class AanvragenStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly aanvragen = this.state.asReadonly(); readonly aanvragen = this.state.asReadonly();
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by /** Set on a failed cancel: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */ then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null); private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly(); readonly lastError = this.error.asReadonly();
@@ -55,7 +55,7 @@ export class AanvragenStore {
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
No resync the delete succeeded, so the optimistic removal is authoritative. On No resync the delete succeeded, so the optimistic removal is authoritative. On
failure, roll back AND surface the error (RB-20) a silent reappearance leaves the failure, roll back AND surface the error a silent reappearance leaves the
user guessing why the block came back. */ user guessing why the block came back. */
async cancel(id: string) { async cancel(id: string) {
const before = this.state(); const before = this.state();
@@ -44,8 +44,8 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
}); });
// RB-20: a failed delete must not be silent — the row rolls back AND the store // A failed delete must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back // surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the delete fails', async () => { it('rolls back the removal and surfaces the error when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
@@ -7,11 +7,11 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
type Err = Error | undefined; type Err = Error | undefined;
/** /**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) the back-office * Admin view of ALL cases across owners (`cases:manage`) the back-office
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton * counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously * owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not the * failure. Admin delete removes any case (any owner, submitted or not the
* server enforces the capability). * server enforces the capability).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -21,7 +21,7 @@ export class AdminCasesStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly(); readonly cases = this.state.asReadonly();
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by /** Set on a failed delete: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */ then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null); private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly(); readonly lastError = this.error.asReadonly();
@@ -47,7 +47,7 @@ export class AdminCasesStore {
} }
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
AND surface it (RB-20) a silent reappearance leaves the admin guessing why. */ AND surface it a silent reappearance leaves the admin guessing why. */
async delete(id: string) { async delete(id: string) {
const before = this.state(); const before = this.state();
if (before.tag === 'Success') { if (before.tag === 'Success') {
@@ -96,7 +96,7 @@ describe('createDraftSync', () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => { it('recovers from a create conflict by adopting the existing Concept', async () => {
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409), // Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
// and ensureId adopts the existing Concept from the list instead of erroring. // and ensureId adopts the existing Concept from the list instead of erroring.
const create = vi.fn().mockRejectedValue({ status: 409 }); const create = vi.fn().mockRejectedValue({ status: 409 });
@@ -63,7 +63,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
if (id) return id; if (id) return id;
ensuring ??= adapter ensuring ??= adapter
.create(deps.type) .create(deps.type)
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate // One Concept per type is server-enforced. Within a tab the resumeGate
// already prevents a second create, but a cross-tab/stale race can still hit the // already prevents a second create, but a cross-tab/stale race can still hit the
// server's guard (409) — recover by adopting the existing Concept instead of // server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure. // erroring. Only recover when one actually exists; otherwise surface the failure.
@@ -3,7 +3,7 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
/** /**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter * before it can start writing (CQ-001). Free functions that take the adapter
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed. * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path; * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
* these two functions only read. * these two functions only read.
@@ -10,8 +10,8 @@
*/ */
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake'; export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse // Ingediend/MeerInfoGevraagd (ADR-0002) are widened into the union so the parse
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's // boundary + renderers are ready, but no backend path emits them yet — that's the
// behandelaar-facing transition endpoint. // behandelaar-facing transition endpoint.
export type AanvraagStatus = export type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number } | { tag: 'Concept'; stepIndex: number; stepCount: number }
@@ -29,7 +29,7 @@ export interface Aanvraag {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
submittedAt?: string; submittedAt?: string;
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36); /** The case owner (a BSN). Only populated by the admin cross-owner list;
the user's own list leaves it undefined. */ the user's own list leaves it undefined. */
owner?: string; owner?: string;
} }
@@ -5,7 +5,7 @@ import {
} from '@registratie/domain/value-objects/telefoonnummer'; } from '@registratie/domain/value-objects/telefoonnummer';
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of /** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
the form it is authoritative and shown read-only (WP-34); only the phone number the form it is authoritative and shown read-only; only the phone number
is editable here. */ is editable here. */
export interface Draft { export interface Draft {
telefoon: string; telefoon: string;
@@ -32,12 +32,12 @@ export class AanvragenAdapter {
return this.client.aanvragenAll(); return this.client.aanvragenAll();
} }
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ /** Admin: every case across all owners (`cases:manage`). Parsed at the boundary. */
listAll(): Promise<AanvraagSummaryDto[]> { listAll(): Promise<AanvraagSummaryDto[]> {
return this.client.casesAll(); return this.client.casesAll();
} }
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */ /** Admin: delete ANY case (any owner, submitted or not). */
deleteAny(id: string): Promise<void> { deleteAny(id: string): Promise<void> {
return this.client.cases(id); return this.client.cases(id);
} }
@@ -117,7 +117,7 @@ function parseCommon(dto: AanvraagSummaryDto): Result<string, Aanvraag> {
createdAt: dto.createdAt, createdAt: dto.createdAt,
updatedAt: dto.updatedAt, updatedAt: dto.updatedAt,
submittedAt: dto.submittedAt, submittedAt: dto.submittedAt,
owner: dto.owner, // only present on the admin cross-owner list (WP-36) owner: dto.owner, // only present on the admin cross-owner list
}); });
} }
@@ -6,7 +6,7 @@ import { Valid } from '@registratie/domain/change-request.machine';
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) * Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`)
* the single place the network client lives for contact changes, so the command * the single place the network client lives for contact changes, so the command
* and the UI never touch `ApiClient`. The BRP address is authoritative and not * and the UI never touch `ApiClient`. The BRP address is authoritative and not
* submitted (WP-34); only the phone number is. Returns the server reference; the * submitted; only the phone number is. Returns the server reference; the
* server re-validates and is the authority. * server re-validates and is the authority.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -13,7 +13,7 @@ import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvra
import { AdminCasesStore } from '@registratie/application/admin-cases.store'; import { AdminCasesStore } from '@registratie/application/admin-cases.store';
/** /**
* Admin page: every case across all owners, with an admin delete (WP-36). Lives in * Admin page: every case across all owners, with an admin delete. Lives in
* `registratie` (which owns the Aanvraag aggregate) the back-office counterpart of the * `registratie` (which owns the Aanvraag aggregate) the back-office counterpart of the
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default * user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins. * capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
@@ -109,7 +109,7 @@ export class AdminCasesPage {
private loadRequested = false; private loadRequested = false;
constructor() { constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise). // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
// Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson). // Depends only on canManage() + a plain flag — never the store model (the loop lesson).
effect(() => { effect(() => {
if (this.canManage() && !this.loadRequested) { if (this.canManage() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -20,7 +20,7 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
/** /**
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative * Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
* and shown READ-ONLY (WP-34) you change your address at the gemeente, not here so * and shown READ-ONLY you change your address at the gemeente, not here so
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in * only the phone number is editable. Uses the SAME idiom as the wizards: all state in
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a * one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
* `submit-*` command returning `Result`. The server re-validates. * `submit-*` command returning `Result`. The server re-validates.
@@ -136,7 +136,7 @@ export class DebugStateComponent {
pendingHerregistratie: this.profileStore?.pendingHerregistratie(), pendingHerregistratie: this.profileStore?.pendingHerregistratie(),
})); }));
// Dev switchers (WP-33): flip role/scenario without hand-editing the URL. Both are // Dev switchers flip role/scenario without hand-editing the URL. Both are
// read per-request in interceptors, so a reload re-runs them and re-fetches decisions. // read per-request in interceptors, so a reload re-runs them and re-fetches decisions.
protected readonly roles = ROLES; protected readonly roles = ROLES;
protected readonly scenarios = SCENARIOS; protected readonly scenarios = SCENARIOS;
@@ -158,7 +158,7 @@ export class DebugStateComponent {
this.applyAndReload(); this.applyAndReload();
} }
// Strip the dev params from the URL before reloading (WP-37) so a stale ?scenario=/?role= // Strip the dev params from the URL before reloading so a stale ?scenario=/?role=
// in the address bar can't override the value the switcher just stored (currentScenario/ // in the address bar can't override the value the switcher just stored (currentScenario/
// currentRole read the URL first) — otherwise a switch to "default"/"drafter" gets stuck. // currentRole read the URL first) — otherwise a switch to "default"/"drafter" gets stuck.
private applyAndReload(): void { private applyAndReload(): void {
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Tiny, dependency-free TS highlighter for the teaching showcase (WP-39). Escapes HTML, * Tiny, dependency-free TS highlighter for the teaching showcase. Escapes HTML,
* then wraps line-comments, strings, and a fixed keyword set in `.c`/`.s`/`.k` spans (the * 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 * 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 * snippets shown here; not a real tokenizer. Input is always our OWN source (extracted by
+2 -2
View File
@@ -10,8 +10,8 @@
url(../fonts|icons|images) refs resolve against the vendored folder at runtime. url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. --> Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" /> <link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- The letter-rendering contract (WP-24): shared verbatim with the backend's <!-- The letter-rendering contract: shared verbatim with the backend's
HTML preview renderer (WP-25 inlines this same file) — keep it self-contained. --> HTML preview renderer (the backend inlines this same file) — keep it self-contained. -->
<link rel="stylesheet" href="letter.css" /> <link rel="stylesheet" href="letter.css" />
</head> </head>
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents <!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
@@ -0,0 +1,164 @@
# RD-18 — Strip the `WP-`/`RB-` ticket references from `apps/` and `libs/`
Status: done
Source: PLAN.md Phase 2, item 4
## Why
204 `WP-NN`/`RB-NN` references sit in comments across 104 files in `apps/` and `libs/`. They
record which ticket introduced a line. `git blame` already records that, and it stays correct
when the code moves. The comment does not: it names a closed ticket, and the reader who chases
it learns nothing the surrounding sentence did not already say.
The sweep removes the reference and keeps the sentence. It changes no behaviour.
## Read first
- `libs/shared/docs/a11y.mdx` lines 14, 20, 28 and 68 — three references to strip and one to
keep, in one file. It is the clearest example of the rule in decision 2.
- `libs/shared/src/ui/task-list/task-list.stories.ts:17` — an exempt reference, and why.
- `scripts/gen-behaviour-spec.mjs:183` — the generated document's header text.
- The README's rule: "No ticket leaves a check disabled without an inline reason **and** a
reference to the ticket that removes it."
## Decisions (pre-made, don't relitigate)
1. **Strip `WP-NN` and `RB-NN`. Keep every `ADR-000x`.** ADR references point at documents
that exist and that the reader must read. There are 50 of them in scope; the count must not
move. `CD-` does not appear anywhere in this repo — do not go looking for it.
2. **Eleven references are exempt, in six files.** A reference survives when it names an
obligation that is not yet discharged, rather than recording history:
| File | Refs | Why exempt |
| ----------------------------------------------------------------- | ---- | ----------------------------------- |
| `registratie/ui/aanvraag-block/aanvraag-block.stories.ts` | 2 | justifies `a11y: { disable: true }` |
| `registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts` | 2 | justifies `a11y: { disable: true }` |
| `libs/shared/src/ui/choice-link/choice-link.stories.ts` | 2 | justifies `a11y: { disable: true }` |
| `libs/shared/src/ui/choice-list/choice-list.stories.ts` | 2 | justifies `a11y: { disable: true }` |
| `libs/shared/src/ui/task-list/task-list.stories.ts` | 2 | justifies `a11y: { disable: true }` |
| `libs/shared/docs/a11y.mdx` line 68 only | 1 | points at WP-13's marker convention |
The five story files each disable an accessibility check. The README forbids leaving a check
disabled without naming the ticket that removes it, so stripping WP-11 there would break a
rule this arc enforces. **Do not edit those five files at all.**
`a11y.mdx` is edited: lines 14, 20 and 28 are provenance and go; line 68 stays.
3. **Thirteen references live in `describe`/`it` titles.** Strip them there too. A spec title
is documentation the generator publishes, so the reference reaches
`libs/shared/docs/behaviour-spec.mdx` and shows up in the rendered behaviour spec.
4. **Never hand-edit `behaviour-spec.mdx`.** It is generated. Fix the spec titles, then run
`npm run gen:behaviour-spec`. Its 14 references disappear on their own — except one.
5. **The exception in decision 4 is the generated file's header.**
`behaviour-spec.mdx:4` says "the compile-time guarantees WP-70 bought". That sentence comes
from `scripts/gen-behaviour-spec.mjs:183`, not from a spec. Edit the generator. Fix the
duplicate of the same sentence in that file's own header comment at line 4 while you are
there. This is the one file outside `apps/`+`libs/` that this ticket touches.
6. **Two sentences need a rewrite, not a deletion.** Deleting the reference alone leaves them
meaningless:
- `subject.interceptor.spec.ts:45` — the title ends "(WP-33-style stickiness)". The
reference _is_ the adjective. Rewrite the parenthetical as "(sticky per tab)".
- `apps/ssp/src/index.html:14` — "(WP-25 inlines this same file)". The subject is the
backend renderer. Rewrite as "(the backend inlines this same file)".
7. **Keep the sentence readable, not merely shorter.** `passage-picker.component.ts:13-14`
reads "Superseded by `besluit-panel` (WP-27's guided drafting)" and "no consumer left …
(WP-28 audit)". Strip to "Superseded by `besluit-panel`'s guided drafting" and "no consumer
left … outside its own story". A stripped line must not leave an empty `()`, a stranded
"see", or a doubled space.
## Files
`apps/` and `libs/`, minus the five exempt story files, plus
`scripts/gen-behaviour-spec.mjs`. About 99 files change.
This is the one ticket type where an acceptance command **should** address a whole directory
rather than a file list: the sweep's contract is "no reference survives in `apps` or `libs`
except the listed eleven". Enumerating 99 files would restate the sweep, not check it.
## Steps
1. Strip the references outside the six files in decision 2. Work directory by directory so
the diff stays reviewable.
2. Apply the rewrites in decisions 6 and 7.
3. Edit `scripts/gen-behaviour-spec.mjs` per decision 5.
4. Run `npm run gen:behaviour-spec`.
5. `git add -A`, then run the acceptance commands.
6. Update this ticket's `Status:` to `done` and the README's RD-18 row to `done`.
7. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. `git grep -o … | wc -l` counts **occurrences**;
`git grep -c` would count lines and give a different, wrong number.
```bash
git grep -oE "\b(WP|RB)-[0-9]+" -- apps libs | wc -l # is 204 -> MUST be 11
```
Those eleven are exactly the exempt ones, all inside the six files of decision 2:
```bash
git grep -hoE "\b(WP|RB)-[0-9]+" -- \
apps/ssp/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts \
apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.stories.ts \
libs/shared/src/ui/choice-link/choice-link.stories.ts \
libs/shared/src/ui/choice-list/choice-list.stories.ts \
libs/shared/src/ui/task-list/task-list.stories.ts \
libs/shared/docs/a11y.mdx | wc -l # is 14 -> MUST be 11
```
The five story files are untouched, so their disables keep their justification:
```bash
git diff --name-only HEAD -- '*.stories.ts' | grep -cE "aanvraag-block|wat-moet-ik-regelen|choice-link|choice-list|task-list" # MUST be 0
```
The ADR references all survive, and the sweep leaves no damaged prose:
```bash
git grep -oE "ADR-[0-9]+" -- apps libs | wc -l # unchanged: 50
git grep -nE "^\s*(//|\*|1\.|\|).*\s\(\)" -- apps libs | wc -l # unchanged: 0
git grep -nE "^\s*(//|\*).*[a-z] [a-z]" -- apps libs | wc -l # unchanged: 0
```
```bash
npm run ci --full # exits 0
```
## Verification
**`--full` is required, although the README's Order table leaves that column blank for this
ticket.** The table is wrong here: the sweep edits four `.mdx` files (`a11y.mdx`,
`cibg-gaps.mdx`, `layers.mdx`, and the generated `behaviour-spec.mdx`), and the README's own
rule says an `.mdx` edit needs `--full`. Fix the column to `yes` in the same commit.
The behaviour-spec drift check inside `npm run ci` is what proves step 4 was run. If it fails,
you edited `behaviour-spec.mdx` by hand or skipped the regeneration.
## Out of scope
- `backend/` — 370 references across 86 files. RD-19 owns them.
- `docs/`, `.claude/`, `e2e/`, `scripts/` — except the one generator file in decision 5.
- The five exempt story files (decision 2). Fixing the markup that makes their a11y disables
necessary is WP-11's job, not this ticket's.
- Rewording a comment beyond what removing the reference requires. This ticket is a sweep, not
a documentation pass.
## Risks
- **`git grep`, never `grep -r`.** `grep -r` reaches gitignored build output and inflates every
count.
- **A reference inside a spec title changes a generated document.** Strip the title, then
regenerate. Editing the `.mdx` directly leaves the spec title unchanged, and the next
regeneration silently puts the reference back.
- **The generator's header is not a spec title** (decision 5). It is the one reference that
regeneration cannot remove, because it comes from `scripts/`.
- **Do not open the five exempt story files.** An edit there is the one way this ticket can
break a rule the README enforces.
- **204, not the 181 that PLAN records.** The arc's own commits added references since PLAN was
measured. Trust the command, not the prose.
+1 -1
View File
@@ -112,7 +112,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | done | | RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | done |
| RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a | | RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a |
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | done | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | done |
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo | | RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done |
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo | | RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo |
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo | | RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo | | RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo |
+1 -1
View File
@@ -6,7 +6,7 @@ import { AuditAdapter, parseAuditEntries } from '@beheer/infrastructure/audit.ad
type Err = Error | undefined; type Err = Error | undefined;
/** /**
* Admin view of the persisted authz/PII-reveal audit trail (WP-41/42). One root singleton * Admin view of the persisted authz/PII-reveal audit trail. One root singleton
* owning the list as a RemoteData signal, parsed at the trust boundary. Read-only. * owning the list as a RemoteData signal, parsed at the trust boundary. Read-only.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -44,7 +44,7 @@ function setup(blobPresenter?: BlobPresenter): StamdataStore {
return TestBed.inject(StamdataStore); return TestBed.inject(StamdataStore);
} }
describe('StamdataStore undo/redo (WP-32)', () => { describe('StamdataStore undo/redo', () => {
it('records a cell edit, undoes and redoes it', async () => { it('records a cell edit, undoes and redoes it', async () => {
const store = setup(); const store = setup();
await store.load(); await store.load();
@@ -82,10 +82,10 @@ describe('StamdataStore undo/redo (WP-32)', () => {
}); });
}); });
// --- RB-28 (TE-006): download() ends in BLOB_PRESENTER.download, not raw DOM calls, // --- TE-006: download() ends in BLOB_PRESENTER.download, not raw DOM calls,
// so the seam makes both the guard's branches and the success path assertable. --- // so the seam makes both the guard's branches and the success path assertable. ---
describe('StamdataStore.download (RB-28)', () => { describe('StamdataStore.download', () => {
it('does not call the presenter while the two-clause guard blocks (nothing dirty yet)', async () => { it('does not call the presenter while the two-clause guard blocks (nothing dirty yet)', async () => {
// Given a freshly loaded table with no edits — canDownload() is false. // Given a freshly loaded table with no edits — canDownload() is false.
const { presenter, downloaded } = fakeBlobPresenter(); const { presenter, downloaded } = fakeBlobPresenter();
@@ -99,7 +99,7 @@ export class StamdataStore {
this.previewDate.set(date); this.previewDate.set(date);
} }
/** Undo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via /** Undo/redo over the edited rows: the document snapshot is `rows`; restore via
the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */ the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */
private history = createHistory<readonly StamRow[]>(50); private history = createHistory<readonly StamRow[]>(50);
readonly canUndo = this.history.canUndo; readonly canUndo = this.history.canUndo;
+1 -1
View File
@@ -1,4 +1,4 @@
/** One authz/PII-reveal audit row as the FE sees it (WP-41 backend WP-42 view). Pure /** One authz/PII-reveal audit row as the FE sees it. Pure
type; data-minimised (no PII) by construction on the server. */ type; data-minimised (no PII) by construction on the server. */
export interface AuditEntry { export interface AuditEntry {
at: string; // ISO timestamp at: string; // ISO timestamp
@@ -5,8 +5,8 @@ import type { AuthzAuditDto } from '@shared/infrastructure/api-client';
import { AuditEntry } from '@beheer/domain/audit-entry'; import { AuditEntry } from '@beheer/domain/audit-entry';
/** /**
* Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`, * Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`).
* WP-41). The single place the ApiClient lives for audit; the store parses at the boundary. * The single place the ApiClient lives for audit; the store parses at the boundary.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuditAdapter { export class AuditAdapter {
+1 -1
View File
@@ -9,7 +9,7 @@ import { successOr } from '@shared/application/remote-data';
import { AuditStore } from '@beheer/application/audit.store'; import { AuditStore } from '@beheer/application/audit.store';
/** /**
* Admin page: the persisted authz/PII-reveal audit trail (WP-41/42) data-minimised, no PII. * Admin page: the persisted authz/PII-reveal audit trail data-minimised, no PII.
* Deny-by-default capability gate (`cases:manage`, reused for admin audit read). Read-only table. * Deny-by-default capability gate (`cases:manage`, reused for admin audit read). Read-only table.
*/ */
@Component({ @Component({
+1 -1
View File
@@ -7,7 +7,7 @@ import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { FeatureFlagStore } from '@shared/application/feature-flags.store';
/** /**
* Admin page: toggle runtime feature flags (WP-47). Deny-by-default capability gate * Admin page: toggle runtime feature flags. Deny-by-default capability gate
* (`flags:manage`). The catalog is server-owned (code); this only flips the on/off state, which * (`flags:manage`). The catalog is server-owned (code); this only flips the on/off state, which
* the whole app reads via the same `FeatureFlagStore`. * the whole app reads via the same `FeatureFlagStore`.
*/ */
@@ -242,13 +242,13 @@ export class StamdataTableEditorComponent {
protected expireLabel = $localize`:@@beheer.expire:Sluiten per vandaag`; protected expireLabel = $localize`:@@beheer.expire:Sluiten per vandaag`;
private removeConfirm = $localize`:@@beheer.removeConfirm:Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.`; private removeConfirm = $localize`:@@beheer.removeConfirm:Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.`;
/** Deletions can orphan a reference (the CI gate catches it); confirm first (WP-48). */ /** Deletions can orphan a reference (the CI gate catches it); confirm first. */
protected onRemove(index: number) { protected onRemove(index: number) {
if (confirm(this.removeConfirm)) this.rowRemoved.emit(index); if (confirm(this.removeConfirm)) this.rowRemoved.emit(index);
} }
/** Steer temporal tables toward expiring (close the validity per today) over hard delete /** Steer temporal tables toward expiring (close the validity per today) over hard delete
preserves history and can't orphan a reference that was valid earlier (WP-48). */ preserves history and can't orphan a reference that was valid earlier. */
protected onExpire(index: number) { protected onExpire(index: number) {
const col = this.table().columns.find((c) => /geldigtot/i.test(c.name)); const col = this.table().columns.find((c) => /geldigtot/i.test(c.name));
if (col) this.cellEdited.emit({ row: index, column: col.name, value: this.today }); if (col) this.cellEdited.emit({ row: index, column: col.name, value: this.today });
+2 -2
View File
@@ -79,7 +79,7 @@ export class StamdataPage {
constructor() { constructor() {
// Load once the capability resolves to `allowed` (a 403 GET would be wasted otherwise). // Load once the capability resolves to `allowed` (a 403 GET would be wasted otherwise).
// Depends only on canEdit() + a plain flag — never on the store model, so dispatching // Depends only on canEdit() + a plain flag — never on the store model, so dispatching
// `Loading` inside load() can't retrigger this effect (the WP-26 runaway-loop lesson). // `Loading` inside load() can't retrigger this effect (the runaway-loop lesson).
effect(() => { effect(() => {
if (this.canEdit() && !this.loadRequested) { if (this.canEdit() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -92,7 +92,7 @@ export class StamdataPage {
void this.store.load(); void this.store.load();
} }
/** Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid /** Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo. Ignored while focus is in a grid
cell input so the browser's native text-undo still works there (mirrors brief.page). */ cell input so the browser's native text-undo still works there (mirrors brief.page). */
protected onKeydown(e: KeyboardEvent) { protected onKeydown(e: KeyboardEvent) {
if (!this.canEdit() || !(e.ctrlKey || e.metaKey) || (e.key !== 'z' && e.key !== 'Z')) return; if (!this.canEdit() || !(e.ctrlKey || e.metaKey) || (e.key !== 'z' && e.key !== 'Z')) return;
+3 -3
View File
@@ -11,13 +11,13 @@ what the ones below/above it can't.
## The layers ## The layers
1. **Axe on every story** (WP-01) — `@storybook/addon-a11y` in the panel, plus 1. **Axe on every story** — `@storybook/addon-a11y` in the panel, plus
`@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`). `@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`).
Catches structural/contrast/ARIA-shape violations on every component, automatically, Catches structural/contrast/ARIA-shape violations on every component, automatically,
as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`, as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`,
only with an inline justification comment + a cross-reference to the WP that will fix only with an inline justification comment + a cross-reference to the WP that will fix
it (see e.g. `task-list.stories.ts`). it (see e.g. `task-list.stories.ts`).
2. **Template a11y lint** (WP-17) — `angular-eslint`'s `templateAccessibility` config 2. **Template a11y lint** — `angular-eslint`'s `templateAccessibility` config
(`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`, (`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`,
`interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline `interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline
template via `angular.processInlineTemplates` (this repo has no `.html` files — every template via `angular.processInlineTemplates` (this repo has no `.html` files — every
@@ -25,7 +25,7 @@ what the ones below/above it can't.
into a virtual file the template rules can lint). Catches missing alt text, unlabelled into a virtual file the template rules can lint). Catches missing alt text, unlabelled
controls, and interactive elements that can't be reached by keyboard — at lint time, controls, and interactive elements that can't be reached by keyboard — at lint time,
before a story even exists. before a story even exists.
3. **Play tests** (WP-16) — Storybook stories assert the wiring axe/lint can't see: 3. **Play tests** — Storybook stories assert the wiring axe/lint can't see:
`form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins `form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins
`-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for `-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for
errors vs `role="status"` for info/ok/warning. These run as part of the same errors vs `role="status"` for info/ok/warning. These run as part of the same
+14 -14
View File
@@ -1,7 +1,7 @@
{/* GENERATED by `npm run gen:behaviour-spec` (scripts/gen-behaviour-spec.mjs) — do not {/* GENERATED by `npm run gen:behaviour-spec` (scripts/gen-behaviour-spec.mjs) — do not
edit. Every bullet below is a real `it()` title or backend test method name, extracted edit. Every bullet below is a real `it()` title or backend test method name, extracted
verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string
matching undoes the compile-time guarantees WP-70 bought, and needs two frameworks for matching undoes the compile-time guarantees the TypeScript compiler bought, and needs two frameworks for
.NET+TS) — this page is the replacement: business-readable documentation generated FROM test .NET+TS) — this page is the replacement: business-readable documentation generated FROM test
names, so it can never drift from what the suite actually asserts. A test name changing (or a names, so it can never drift from what the suite actually asserts. A test name changing (or a
test being added/removed) is the only way this page changes; hand-editing it is pointless, test being added/removed) is the only way this page changes; hand-editing it is pointless,
@@ -108,13 +108,13 @@ classes.
### beheer ### beheer
#### StamdataStore undo/redo (WP-32) #### StamdataStore undo/redo
- records a cell edit, undoes and redoes it - records a cell edit, undoes and redoes it
- records addRow and undoes it - records addRow and undoes it
- clears history when switching table - clears history when switching table
#### StamdataStore.download (RB-28) #### StamdataStore.download
- does not call the presenter while the two-clause guard blocks (nothing dirty yet) - does not call the presenter while the two-clause guard blocks (nothing dirty yet)
- does not call the presenter while previewing a date, even with edits - does not call the presenter while previewing a date, even with edits
@@ -186,14 +186,14 @@ classes.
- flushes a pending debounced edit immediately and clears the pending flag - flushes a pending debounced edit immediately and clears the pending flag
- is a no-op when no edit is pending - is a no-op when no edit is pending
#### BriefStore.load — 404 tolerance (RB-22) #### BriefStore.load — 404 tolerance
- a 404 drives exactly one reset(), which populates the store - a 404 drives exactly one reset(), which populates the store
- a second 404 does not drive a second reset() - a second 404 does not drive a second reset()
#### BriefStore.previewLetter #### BriefStore.previewLetter
- opens the composed letter via BLOB_PRESENTER on success (RB-28) - opens the composed letter via BLOB_PRESENTER on success
- surfaces the error without opening a tab on failure - surfaces the error without opening a tab on failure
#### BriefStore.revealBigNummer (PRD-0002 §5c) #### BriefStore.revealBigNummer (PRD-0002 §5c)
@@ -206,7 +206,7 @@ classes.
- sends no X-Role/X-Subject headers outside isDevMode() - sends no X-Role/X-Subject headers outside isDevMode()
- sends X-Role (and X-Subject when known) under isDevMode() - sends X-Role (and X-Subject when known) under isDevMode()
#### OrgTemplateStore.proefbrief (RB-28) #### OrgTemplateStore.proefbrief
- opens the rendered proefbrief via BLOB_PRESENTER on success - opens the rendered proefbrief via BLOB_PRESENTER on success
- surfaces the error without opening a tab on failure - surfaces the error without opening a tab on failure
@@ -372,7 +372,7 @@ classes.
- low uren requires the scholing question, and punten only once scholing is followed - low uren requires the scholing question, and punten only once scholing is followed
- buitenland gewerkt requires land and hours abroad before advancing - buitenland gewerkt requires land and hours abroad before advancing
- gaNaarStap corrects an earlier answer without losing later ones - gaNaarStap corrects an earlier answer without losing later ones
- raising uren above the threshold after answering scholing drops both fields (WP-69 §6) - raising uren above the threshold after answering scholing drops both fields
- SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing - SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing
#### intake hasProgress #### intake hasProgress
@@ -419,8 +419,8 @@ classes.
- reaches Submitting ONLY with valid answers - reaches Submitting ONLY with valid answers
- punten is required only when aanvullende scholing was gevolgd - punten is required only when aanvullende scholing was gevolgd
- low hours requires the scholing answer before submit - low hours requires the scholing answer before submit
- does not require punten for a hidden question (WP-69 §6) - does not require punten for a hidden question
- drops punten when raising uren hides the question (WP-69 §6) - drops punten when raising uren hides the question
- resolve maps Submitting to Submitted on a successful submit - resolve maps Submitting to Submitted on a successful submit
- resolve maps Submitting to Failed on a failed submit - resolve maps Submitting to Failed on a failed submit
@@ -501,7 +501,7 @@ classes.
- resolves ok with the server response on success - resolves ok with the server response on success
- folds a rejected submit into a Result error, never throwing - folds a rejected submit into a Result error, never throwing
- recovers from a create conflict by adopting the existing Concept (WP-35) - recovers from a create conflict by adopting the existing Concept
#### createSubmitChangeRequest #### createSubmitChangeRequest
@@ -863,8 +863,8 @@ classes.
- parses a known capability list - parses a known capability list
- parses an empty list (drafter — no capabilities) - parses an empty list (drafter — no capabilities)
- recognizes the admin org-template capability (WP-23) - recognizes the admin org-template capability
- recognizes the behandelportal besluit capability (WP-66) - recognizes the behandelportal besluit capability
- drops unrecognized capability strings instead of rejecting the response - drops unrecognized capability strings instead of rejecting the response
- rejects malformed responses instead of trusting them - rejects malformed responses instead of trusting them
@@ -950,7 +950,7 @@ classes.
- falls back to default when nothing is set or the value is invalid - falls back to default when nothing is set or the value is invalid
- setScenario persists the chosen scenario - setScenario persists the chosen scenario
#### stripDevParams (WP-37) #### stripDevParams
- removes ?scenario and ?role so the stored dev value wins on reload - removes ?scenario and ?role so the stored dev value wins on reload
- keeps unrelated query params and the path/hash - keeps unrelated query params and the path/hash
@@ -959,7 +959,7 @@ classes.
#### subjectInterceptor #### subjectInterceptor
- stamps X-Subject on an /api/v1/ request once ?subject= has been seen - stamps X-Subject on an /api/v1/ request once ?subject= has been seen
- keeps stamping later requests on the same tab after the query param is gone (WP-33-style stickiness) - keeps stamping later requests on the same tab after the query param is gone (sticky per tab)
- leaves a non-API request untouched even when a subject is known - leaves a non-API request untouched even when a subject is known
- sends no header at all when no subject has ever been seen - sends no header at all when no subject has ever been seen
+2 -2
View File
@@ -25,7 +25,7 @@ placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` an
| --------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | --------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. | | `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. |
| `spinner` | Laadindicatie | No loading-spinner class in the vendored build. | | `spinner` | Laadindicatie | No loading-spinner class in the vendored build. |
| `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost` (WP-10). | | `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost`. |
| `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. | | `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. |
| `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. | | `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. |
| `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. | | `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. |
@@ -36,7 +36,7 @@ placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` an
Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles: Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles:
[...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite [...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite
renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked
onto them rather than marked (see WP-11's correction note). `task-list`, `application-list`, and onto them rather than marked. `task-list`, `application-list`, and
`choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name `choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name
it in their own header comment — no marker needed, they don't hand-roll surface CSS. it in their own header comment — no marker needed, they don't hand-roll surface CSS.
+1 -1
View File
@@ -95,7 +95,7 @@ context, so the per-context scoping rule never applies to it in the first place.
`npm run lint` (`eslint.config.mjs`) is a separate gate — mainly the `any`-free rule — `npm run lint` (`eslint.config.mjs`) is a separate gate — mainly the `any`-free rule —
and no longer carries the import-boundary rules above (moved to dependency-cruiser, and no longer carries the import-boundary rules above (moved to dependency-cruiser,
WP-38/WP-67, so they don't have to be hand-copied per context). so they don't have to be hand-copied per context).
## The English/Dutch seam ## The English/Dutch seam
@@ -16,7 +16,7 @@ export interface DebouncedSave {
} }
/** /**
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer * The debounced-autosave timer shared by the editor stores. It owns ONLY the timer
* bookkeeping; the actual write + save-state transitions live in the caller's `flush` * bookkeeping; the actual write + save-state transitions live in the caller's `flush`
* (store-specific it touches that store's SaveState + adapter). The handle is * (store-specific it touches that store's SaveState + adapter). The handle is
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates * nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
@@ -10,7 +10,7 @@ type Err = Error | undefined;
const SET_FAILED = $localize`:@@flags.set.failed:De functievlag kon niet worden opgeslagen.`; const SET_FAILED = $localize`:@@flags.set.failed:De functievlag kon niet worden opgeslagen.`;
/** /**
* Runtime feature-flag state (WP-47) one root singleton, mirroring `AccessStore`. Loads the * Runtime feature-flag state one root singleton, mirroring `AccessStore`. Loads the
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default: * resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is * false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
* server-owned; the FE only mirrors + renders it. * server-owned; the FE only mirrors + renders it.
+2 -2
View File
@@ -18,8 +18,8 @@ export interface History<T> {
* restore a returned snapshot by re-dispatching a `Seed`-style Msg this helper only * restore a returned snapshot by re-dispatching a `Seed`-style Msg this helper only
* shuffles references, it never mutates them, so the caller must hold copy-on-write state * shuffles references, it never mutates them, so the caller must hold copy-on-write state
* (every edit produces a fresh value). Both stacks are capped so a long session can't grow * (every edit produces a fresh value). Both stacks are capped so a long session can't grow
* unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata * unbounded. Extracted from BriefStore's undo/redo; reused by the stamdata
* editor (WP-32). * editor.
*/ */
export function createHistory<T>(cap = 50): History<T> { export function createHistory<T>(cap = 50): History<T> {
const past = signal<readonly T[]>([]); const past = signal<readonly T[]>([]);
+1 -1
View File
@@ -31,7 +31,7 @@ export function fromResource<T>(
* Project an Elm-machine's load lifecycle onto `RemoteData`, for the `<app-async>` seam. The * Project an Elm-machine's load lifecycle onto `RemoteData`, for the `<app-async>` seam. The
* machine keeps owning its own domain lifecycle (draft/submitted/); this is purely the * machine keeps owning its own domain lifecycle (draft/submitted/); this is purely the
* Loading/Failed/Loaded async mapping, which was byte-identical across BriefStore, * Loading/Failed/Loaded async mapping, which was byte-identical across BriefStore,
* OrgTemplateStore and StamdataStore (WP-31). A `RemoteData` constructor, not a sixth * OrgTemplateStore and StamdataStore. A `RemoteData` constructor, not a sixth
* encoding wrap the call in a `computed`. * encoding wrap the call in a `computed`.
*/ */
export function fromLoadLifecycle< export function fromLoadLifecycle<
+1 -1
View File
@@ -7,7 +7,7 @@ import { currentIdempotencyKey } from '@shared/infrastructure/api-client.provide
// So calling it twice inside the same `fn` tells us, behaviourally, whether a key was // So calling it twice inside the same `fn` tells us, behaviourally, whether a key was
// minted for this call: two reads agreeing means one pending key was reused; two reads // minted for this call: two reads agreeing means one pending key was reused; two reads
// disagreeing means there was no pending key at all — each call fell back to its own // disagreeing means there was no pending key at all — each call fell back to its own
// random one. This is the seam RB-17 exists to keep separated, so it is asserted // random one. This is a deliberate seam, kept separate so it can be asserted
// directly rather than via a mock (relative-import mocking is off-limits under this // directly rather than via a mock (relative-import mocking is off-limits under this
// repo's Angular/vitest setup — see role.interceptor.spec.ts). // repo's Angular/vitest setup — see role.interceptor.spec.ts).
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* The letter workflow's acting role: drafter or approver for the two-person * The letter workflow's acting role: drafter or approver for the two-person
* compose/review flow, admin for org-template management (WP-23, Brief v2). * compose/review flow, admin for org-template management (Brief v2).
* A pure domain type (no framework, no reading mechanism) the `?role=` reader and * A pure domain type (no framework, no reading mechanism) the `?role=` reader and
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store, * the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
* letter-composer) depend on this type, not on how the role is obtained. * letter-composer) depend on this type, not on how the role is obtained.
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { stripDevParams } from './dev-params'; import { stripDevParams } from './dev-params';
describe('stripDevParams (WP-37)', () => { describe('stripDevParams', () => {
it('removes ?scenario and ?role so the stored dev value wins on reload', () => { it('removes ?scenario and ?role so the stored dev value wins on reload', () => {
expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe( expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe(
'http://localhost:4200/dashboard', 'http://localhost:4200/dashboard',
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Remove the dev-only `?scenario=` and `?role=` params from a URL (WP-37). Once the * Remove the dev-only `?scenario=` and `?role=` params from a URL. Once the
* dev switcher (debug-state) has been used, sessionStorage is the authoritative source * dev switcher (debug-state) has been used, sessionStorage is the authoritative source
* for both `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param * for both `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param
* left in the address bar would override the switcher on reload (the "stuck on slow" * left in the address bar would override the switcher on reload (the "stuck on slow"
@@ -4,7 +4,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { FeatureFlag } from '@shared/domain/feature-flag'; import { FeatureFlag } from '@shared/domain/feature-flag';
/** /**
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating) * Infrastructure adapter for feature flags: `GET /flags` (resolved set, drives FE gating)
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the * and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
* store parses at the boundary. * store parses at the boundary.
*/ */
@@ -11,18 +11,18 @@ describe('parseMe (trust boundary)', () => {
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] }); expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
}); });
it('recognizes the admin org-template capability (WP-23)', () => { it('recognizes the admin org-template capability', () => {
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({ expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
ok: true, ok: true,
value: ['orgtemplate:edit'], value: ['orgtemplate:edit'],
}); });
}); });
// Regression: WP-66's `aanvraag:beoordelen` (behandelportal) shipped on the `Capability` // Regression: `aanvraag:beoordelen` (behandelportal) shipped on the `Capability`
// type but was never added to this trust-boundary's runtime KNOWN list, so a real // type but was never added to this trust-boundary's runtime KNOWN list, so a real
// behandelaar's `/me` response had the capability silently dropped and the werkvoorraad // behandelaar's `/me` response had the capability silently dropped and the werkvoorraad
// page always denied — every `Capability` union member belongs in KNOWN too. // page always denied — every `Capability` union member belongs in KNOWN too.
it('recognizes the behandelportal besluit capability (WP-66)', () => { it('recognizes the behandelportal besluit capability', () => {
expect(parseMe({ capabilities: ['aanvraag:beoordelen'] })).toEqual({ expect(parseMe({ capabilities: ['aanvraag:beoordelen'] })).toEqual({
ok: true, ok: true,
value: ['aanvraag:beoordelen'], value: ['aanvraag:beoordelen'],
@@ -41,7 +41,7 @@ describe('roleInterceptor', () => {
it.each([ it.each([
'/api/v1/brief', '/api/v1/brief',
'/api/v1/admin/org-template', '/api/v1/admin/org-template',
'/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role '/api/v1/stamdata', // the admin stamdata reads 403 without X-Role
'/api/v1/stamdata/professions?peildatum=1999-01-01', '/api/v1/stamdata/professions?peildatum=1999-01-01',
'/api/v1/me', '/api/v1/me',
])('stamps X-Role on the role-aware endpoint %s', (url) => { ])('stamps X-Role on the role-aware endpoint %s', (url) => {
@@ -4,9 +4,9 @@ import { currentRole } from './role';
/** /**
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role` * Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
* header so the backend can enforce the drafter/approver/admin rules. Only the * header so the backend can enforce the drafter/approver/admin rules. Only the
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set * brief, org-template, stamdata and /me endpoints carry it (the set was widened
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added * /me must see the role or `AccessStore` could never learn a capability; /stamdata was
* /stamdata, whose admin-only reads 403 without it); everything else is untouched. * added later, since its admin-only reads 403 without it); everything else is untouched.
* A new admin-gated endpoint MUST be added here or its page silently 403s. * A new admin-gated endpoint MUST be added here or its page silently 403s.
*/ */
const ROLE_AWARE = [ const ROLE_AWARE = [
+1 -1
View File
@@ -40,7 +40,7 @@ export function currentRole(): Role {
return isRole(stored) ? stored : 'drafter'; return isRole(stored) ? stored : 'drafter';
} }
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */ /** Dev switcher entry point: persist the chosen role for the tab. */
export function setRole(r: Role): void { export function setRole(r: Role): void {
sessionStorage.setItem(STORAGE_KEY, r); sessionStorage.setItem(STORAGE_KEY, r);
} }
+1 -1
View File
@@ -39,7 +39,7 @@ export function currentScenario(): Scenario {
return isScenario(stored) ? stored : 'default'; return isScenario(stored) ? stored : 'default';
} }
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */ /** Dev switcher entry point: persist the chosen scenario for the tab. */
export function setScenario(s: Scenario): void { export function setScenario(s: Scenario): void {
sessionStorage.setItem(STORAGE_KEY, s); sessionStorage.setItem(STORAGE_KEY, s);
} }
@@ -42,7 +42,7 @@ describe('subjectInterceptor', () => {
expect(forward('/api/v1/registratie/concept').headers.get('X-Subject')).toBe('111222333'); expect(forward('/api/v1/registratie/concept').headers.get('X-Subject')).toBe('111222333');
}); });
it('keeps stamping later requests on the same tab after the query param is gone (WP-33-style stickiness)', () => { it('keeps stamping later requests on the same tab after the query param is gone (sticky per tab)', () => {
window.history.replaceState({}, '', '/?subject=111222333'); window.history.replaceState({}, '', '/?subject=111222333');
forward('/api/v1/me'); forward('/api/v1/me');
window.history.replaceState({}, '', '/dashboard'); // navigation drops the query param window.history.replaceState({}, '', '/dashboard'); // navigation drops the query param
@@ -2,7 +2,7 @@ import { HttpInterceptorFn } from '@angular/common/http';
import { currentSubject } from './subject'; import { currentSubject } from './subject';
/** /**
* Dev-only (WP-74): stamps every API request with `X-Subject`, the BSN * Dev-only: stamps every API request with `X-Subject`, the BSN
* `StubIdentityProvider` (backend) resolves the caller's `ZorgverlenerCaller` from * `StubIdentityProvider` (backend) resolves the caller's `ZorgverlenerCaller` from
* every owner-keyed store (`ApplicationStore`, `DocumentStore`, `BriefStore`) reads * every owner-keyed store (`ApplicationStore`, `DocumentStore`, `BriefStore`) reads
* off that resolved identity, so this is the seam that lets e2e specs log in as * off that resolved identity, so this is the seam that lets e2e specs log in as
+1 -1
View File
@@ -8,7 +8,7 @@ import { isDevMode } from '@angular/core';
* comment) so `subject.interceptor.ts` can't reach it without a layering * comment) so `subject.interceptor.ts` can't reach it without a layering
* violation (`libs/shared` may not depend on an app-local `auth` context). Instead a * violation (`libs/shared` may not depend on an app-local `auth` context). Instead a
* `?subject=<bsn>` query param, seen once on any navigation, is remembered for the * `?subject=<bsn>` query param, seen once on any navigation, is remembered for the
* tab in sessionStorage the exact `?role=` trick `role.ts` already uses (WP-33). * tab in sessionStorage the exact `?role=` trick `role.ts` already uses.
* *
* Two consumers read this, both dev/e2e-only: `subject.interceptor.ts` (every * Two consumers read this, both dev/e2e-only: `subject.interceptor.ts` (every
* `HttpClient` request) and `letter-preview.adapter.ts` (`/brief/preview`'s * `HttpClient` request) and `letter-preview.adapter.ts` (`/brief/preview`'s
@@ -139,7 +139,7 @@ export class UploadAdapter {
}); });
xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`); xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`);
// WP-74: this XHR bypasses `HttpClient`'s `subjectInterceptor` (the same reason // This XHR bypasses `HttpClient`'s `subjectInterceptor` (the same reason
// `letter-preview.adapter.ts` sets `X-Role` explicitly) — without `X-Subject` a // `letter-preview.adapter.ts` sets `X-Role` explicitly) — without `X-Subject` a
// document always uploaded under `DocumentStore.DemoOwner` regardless of who was // document always uploaded under `DocumentStore.DemoOwner` regardless of who was
// actually logged in, so a submission attempted under any other BSN would find // actually logged in, so a submission attempted under any other BSN would find
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* PII masking pure functional core (WP-40). Data-minimisation helpers shared by the app * PII masking pure functional core. Data-minimisation helpers shared by the app
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework, * (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs). * no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
*/ */
@@ -9,7 +9,7 @@ const meta: Meta<ShellComponent> = {
title: 'Design System/Templates/Shell', title: 'Design System/Templates/Shell',
component: ShellComponent, component: ShellComponent,
// The persistent header injects AccessStore (for its capability-gated admin links) and // The persistent header injects AccessStore (for its capability-gated admin links) and
// FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no // FeatureFlagStore (for the Inschrijven nav gate); stub both so the story needs no
// HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible. // HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible.
decorators: [ decorators: [
applicationConfig({ applicationConfig({
@@ -4,7 +4,7 @@ import { Capability } from '@shared/domain/capability';
export interface HeaderNavItem { export interface HeaderNavItem {
readonly label: string; readonly label: string;
readonly to: string; readonly to: string;
/** Hidden when this feature flag is off (e.g. WP-47's Inschrijven gate). Omit for an /** Hidden when this feature flag is off (e.g. the Inschrijven gate). Omit for an
always-visible item. */ always-visible item. */
readonly flag?: string; readonly flag?: string;
} }
@@ -98,7 +98,7 @@ export class SiteHeaderComponent {
private rawNavItems = inject(HEADER_NAV_ITEMS); private rawNavItems = inject(HEADER_NAV_ITEMS);
private rawAdminLinks = inject(HEADER_ADMIN_LINKS); private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
/** Hides an item whose `flag` is off (e.g. the SSP's Inschrijven gate, WP-47) which /** Hides an item whose `flag` is off (e.g. the SSP's Inschrijven gate) which
items exist, and which carry a flag, is entirely up to the app that provided them. */ items exist, and which carry a flag, is entirely up to the app that provided them. */
protected readonly navItems = computed(() => protected readonly navItems = computed(() =>
this.rawNavItems.filter((i) => !i.flag || this.flags.enabled(i.flag)), this.rawNavItems.filter((i) => !i.flag || this.flags.enabled(i.flag)),
@@ -8,7 +8,7 @@ import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
import { SiteHeaderComponent } from './site-header.component'; import { SiteHeaderComponent } from './site-header.component';
// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore // The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient. // (for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient.
// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. Nav/admin // `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. Nav/admin
// links are app-provided (HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS) — this story supplies a // links are app-provided (HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS) — this story supplies a
// representative sample rather than importing a real app's config, keeping the story // representative sample rather than importing a real app's config, keeping the story
+1 -1
View File
@@ -13,7 +13,7 @@ const meta: Meta<AlertComponent> = {
export default meta; export default meta;
type Story = StoryObj<AlertComponent>; type Story = StoryObj<AlertComponent>;
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't. // role assertions guard the polite/assertive split: errors interrupt, others don't.
export const Info: Story = { export const Info: Story = {
args: { type: 'info' }, args: { type: 'info' },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -6,7 +6,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
`.block-wrapper` panel with a `<dl>` of projected `<app-data-row>`s. Use `stacked` `.block-wrapper` panel with a `<dl>` of projected `<app-data-row>`s. Use `stacked`
(`.data-block--stacked`) when labels/values are long and should stack. This is the (`.data-block--stacked`) when labels/values are long and should stack. This is the
single data surface (a generic white `app-card` used to exist but was unused and single data surface (a generic white `app-card` used to exist but was unused and
removed see WP-12); the datablock carries its own surface, so it is not nested in removed); the datablock carries its own surface, so it is not nested in
another one. When there is no visible `heading`, pass an `ariaLabel` so the definition another one. When there is no visible `heading`, pass an `ariaLabel` so the definition
list is announced. */ list is announced. */
@Component({ @Component({
@@ -24,8 +24,8 @@ type Story = StoryObj<FormFieldComponent>;
export const Default: Story = { export const Default: Story = {
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true }, args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
// Composition contract: fieldId must equal the input's id — enforced here, not by DI // Composition contract: fieldId must equal the input's id — enforced here, not by DI.
// (see WP-16). Catches drift in the description→aria-describedby wiring. // Catches drift in the description→aria-describedby wiring.
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const input = canvas.getByRole('textbox'); const input = canvas.getByRole('textbox');
@@ -3,7 +3,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
/** /**
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, ) with an optional, audited * Atom: a possibly-masked sensitive value (BSN, BIG-nummer, ) with an optional, audited
* reveal affordance (WP-40). The value arrives masked from the server (data-minimisation) * reveal affordance. The value arrives masked from the server (data-minimisation)
* and is swapped for the full value on reveal; the reveal button shows only when the value * and is swapped for the full value on reveal; the reveal button shows only when the value
* is still masked AND the caller says the principal may reveal it. Centralises the * is still masked AND the caller says the principal may reveal it. Centralises the
* masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the * masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the

Some files were not shown because too many files have changed in this diff Show More