From 7a8eab917b2f907c9cc1864496b3073410f4d32c Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 5 Sep 2026 00:25:35 +0200 Subject: [PATCH] refactor: inline letter-canvas's 19 pure labels, extract letter-line (RD-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty of letter-canvas's twenty-eight input()s are $localize labels that no caller ever binds. Inline nineteen of them as template i18n, using the same ids and source text so messages.en.xlf does not change. recipientText stays an input() because its message embeds a literal \n, which as template text becomes a different source string to Angular's extractor. Extract letter-line.component.ts for the #line template plus the label/auto/state/sample helpers it needs, replacing letter-canvas's three ngTemplateOutlet incantations with one tag each. Its helpers are exported pure functions with a spec, no TestBed. The file stays over the 250-line budget (77 lines of CSS plus one letter's markup), so the eslint-disable max-lines directive stays too, with its reason rewritten to say so plainly — the only such disable left in the repo. Co-Authored-By: Claude Sonnet 5 --- .../letter-canvas/letter-canvas.component.ts | 189 ++++++++---------- .../ui/letter-canvas/letter-line.component.ts | 104 ++++++++++ .../ui/letter-canvas/letter-line.spec.ts | 107 ++++++++++ .../readable-codebase/RD-26-letter-canvas.md | 171 ++++++++++++++++ docs/project/readable-codebase/README.md | 2 +- libs/shared/docs/behaviour-spec.mdx | 33 ++- 6 files changed, 498 insertions(+), 108 deletions(-) create mode 100644 apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts create mode 100644 apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts create mode 100644 docs/project/readable-codebase/RD-26-letter-canvas.md diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts index bebded2..279a404 100644 --- a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts @@ -1,4 +1,6 @@ -/* eslint-disable max-lines */ // 77 lines are CSS, the rest is one letter — RD-26 rewrites this reason, keeps the disable +/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into +// letterhead/body/signature/footer makes "what does the letter look like" a five-file +// question for no behavioural seam. Deliberate, not deferred. import { Component, DestroyRef, @@ -12,9 +14,7 @@ import { signal, viewChild, } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; import { ButtonComponent } from '@shared/ui/button/button.component'; -import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component'; import { formatDatumNl } from '@shared/kernel/datum'; import { Paragraph } from '@shared/kernel/rich-text'; import { Brief, LetterBlock } from '@brief/domain/brief'; @@ -22,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { Diagnostic } from '@brief/domain/placeholders'; import { BlockDiffKind } from '@brief/domain/brief-diff'; +import { LetterLineComponent } from './letter-line.component'; /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ type PreviewSegment = { @@ -40,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] { return out; } -// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side. -const SAMPLE_VALUES: Record = { - naam_zorgverlener: 'J. Jansen', - big_nummer: '12345678901', -}; - /** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */ const A4_HEIGHT_PX = (297 * 96) / 25.4; @@ -55,10 +50,12 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; renders everything read-only (approver/locked, absorbs the old letter-preview), `'template'` reserves the org-identity regions for the admin editor. Letter typography/geometry come from the shared `public/letter.css` contract — - the same file the backend preview renderer inlines. */ + the same file the backend preview renderer inlines. Each rendered line is its own + `app-letter-line` (RD-26), replacing the outlet-template indirection that stood + in for it. */ @Component({ selector: 'app-letter-canvas', - imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent], + imports: [ButtonComponent, LetterLineComponent], styles: [ ` :host { @@ -137,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; `, ], template: ` - - @for (node of nodes; track $index) { - @switch (node.type) { - @case ('text') { - {{ node.text }} - } - @case ('lineBreak') { -
- } - @case ('placeholder') { - @if (showSample() && autoFor(node.key)) { - {{ sampleFor(node.key) }} - } @else { - - } - } - } - } -
- @if (editableRegions() !== 'template') {
-
+
@@ -175,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; + - {{ - zoomResetLabel() - }} + 100%
@if (editableRegions() === 'none') { - {{ showSample() ? hideSampleLabel() : showSampleLabel() }} + @if (showSample()) { + Testwaarden verbergen + } @else { + Voorbeeld met testwaarden + } }
@@ -201,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; (robijn footer background) — the letter surface must stay letter.css-only. -->
@if (logoUrl()) { - + } @if (editing()) { } @else { @@ -224,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
{{ recipientText() }}
-
{{ referenceLabel() }}
+
Ons kenmerk
{{ brief().briefId }}
-
{{ dateLabel() }}
+
Datum
{{ letterDate }}
@@ -242,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
@if (diffKind) { - {{ - diffLabel(diffKind) - }} + + @if (diffKind === 'added') { + nieuw + } @else { + gewijzigd sinds afwijzing + } + } @for (seg of segmentsOf(block); track $index) { @if (seg.list === 'bullet') {
    @for (para of seg.items; track $index) {
  • -
  • } @@ -262,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
      @for (para of seg.items; track $index) {
    1. -
    2. }
    } @else {

    -

    } @@ -289,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; } @else { @@ -317,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; class="tmpl-textarea footer-contact" rows="2" [value]="orgTemplate().footerContact" - [attr.aria-label]="footerContactLabel()" + aria-label="Contactgegevens (voettekst)" + i18n-aria-label="@@brief.canvas.footerContact" (input)="emitEdit('footerContact', $event)" > } @else { @@ -334,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; @for (top of pageBreaks(); track $index) { }
@@ -360,30 +373,13 @@ export class LetterCanvasComponent { /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */ templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); - showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`); - hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); - pageBreakCaption = input( - $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`, - ); + /** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2): + its message embeds a literal `\n`. As template text that `\n` becomes a source + line break, a different string to Angular's extractor — so inlining it would + change the extracted source text, unlike the other 19 labels this ticket inlines. */ recipientText = input( $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`, ); - referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`); - dateLabel = input($localize`:@@brief.canvas.date:Datum`); - logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`); - orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`); - returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`); - signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`); - signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`); - signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`); - footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`); - footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`); - zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`); - zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`); - zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`); - zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`); - addedLabel = input($localize`:@@brief.diff.added:nieuw`); - changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`); protected showSample = signal(false); protected letterDate = formatDatumNl(new Date()); @@ -395,9 +391,6 @@ export class LetterCanvasComponent { // clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks. this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10); } - protected diffLabel = (kind: BlockDiffKind) => - kind === 'added' ? this.addedLabel() : this.changedLabel(); - /** Admin edit-in-place: the org-identity regions render as controls. */ protected editing = computed(() => this.editableRegions() === 'template'); @@ -418,25 +411,9 @@ export class LetterCanvasComponent { }; }); - // --- read-only rendering helpers (migrated from the superseded letter-preview) --- - - private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p]))); - private worst = computed(() => { - const m = new Map(); - for (const d of this.diagnostics()) { - if (!d.placeholderKey) continue; - if (d.severity === 'error') m.set(d.placeholderKey, 'error'); - else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning'); - } - return m; - }); + // --- read-only rendering helper (migrated from the superseded letter-preview) --- protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs); - protected labelFor = (key: string) => this.defs().get(key)?.label ?? key; - protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false; - protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok'; - protected sampleFor = (key: string) => - SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key)); // --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) --- diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts new file mode 100644 index 0000000..d583fd6 --- /dev/null +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts @@ -0,0 +1,104 @@ +import { Component, computed, input } from '@angular/core'; +import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component'; +import { RichTextNode } from '@shared/kernel/rich-text'; +import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders'; + +// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side. +const SAMPLE_VALUES: Record = { + naam_zorgverlener: 'J. Jansen', + big_nummer: '12345678901', +}; + +/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */ +export function placeholderDefs( + placeholders: readonly PlaceholderDef[], +): Map { + return new Map(placeholders.map((p) => [p.key, p])); +} + +/** The worst (error over warning) diagnostic severity per placeholder key. */ +export function worstSeverities( + diagnostics: readonly Diagnostic[], +): Map { + const m = new Map(); + for (const d of diagnostics) { + if (!d.placeholderKey) continue; + if (d.severity === 'error') m.set(d.placeholderKey, 'error'); + else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning'); + } + return m; +} + +export function resolveLabel(defs: ReadonlyMap, key: string): string { + return defs.get(key)?.label ?? key; +} + +export function resolveAuto(defs: ReadonlyMap, key: string): boolean { + return defs.get(key)?.autoResolvable ?? false; +} + +export function resolveState( + worst: ReadonlyMap, + key: string, +): 'ok' | 'warning' | 'error' { + return worst.get(key) ?? 'ok'; +} + +/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned + sample, the caller's sample date for `datum`, or the field's own label. */ +export function resolveSample( + defs: ReadonlyMap, + sampleDate: string, + key: string, +): string { + return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key)); +} + +/** Organism: one rendered line of letter content — text runs, line breaks and + placeholder chips (an auto-resolvable one swaps to a sample value when + `showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line` + template plus the label/auto/state/sample helpers it needs, so the canvas's + three `ngTemplateOutlet` incantations become one tag each. */ +@Component({ + selector: 'app-letter-line', + imports: [PlaceholderChipComponent], + template: ` + @for (node of nodes(); track $index) { + @switch (node.type) { + @case ('text') { + {{ node.text }} + } + @case ('lineBreak') { +
+ } + @case ('placeholder') { + @if (showSample() && autoFor(node.key)) { + {{ sampleFor(node.key) }} + } @else { + + } + } + } + } + `, +}) +export class LetterLineComponent { + nodes = input.required(); + showSample = input(false); + placeholders = input([]); + diagnostics = input([]); + /** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */ + sampleDate = input(''); + + private defs = computed(() => placeholderDefs(this.placeholders())); + private worst = computed(() => worstSeverities(this.diagnostics())); + + protected labelFor = (key: string) => resolveLabel(this.defs(), key); + protected autoFor = (key: string) => resolveAuto(this.defs(), key); + protected stateFor = (key: string) => resolveState(this.worst(), key); + protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key); +} diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts new file mode 100644 index 0000000..ccaf29a --- /dev/null +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders'; +import { + placeholderDefs, + resolveAuto, + resolveLabel, + resolveSample, + resolveState, + worstSeverities, +} from './letter-line.component'; + +const DEFS: readonly PlaceholderDef[] = [ + { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true }, + { key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }, +]; + +const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 }; + +function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic { + return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION }; +} + +describe('placeholderDefs', () => { + it('keys the placeholder list by its key', () => { + const defs = placeholderDefs(DEFS); + expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener'); + expect(defs.get('unknown')).toBeUndefined(); + }); +}); + +describe('worstSeverities', () => { + it('ignores a diagnostic with no placeholder key', () => { + const worst = worstSeverities([ + { severity: 'error', code: 'malformed', message: 'x', location: LOCATION }, + ]); + expect(worst.size).toBe(0); + }); + + it('keeps error over a warning already recorded for the same key', () => { + const worst = worstSeverities([ + diagnostic('naam_zorgverlener', 'warning'), + diagnostic('naam_zorgverlener', 'error'), + ]); + expect(worst.get('naam_zorgverlener')).toBe('error'); + }); + + it('does not let a later warning downgrade an error', () => { + const worst = worstSeverities([ + diagnostic('naam_zorgverlener', 'error'), + diagnostic('naam_zorgverlener', 'warning'), + ]); + expect(worst.get('naam_zorgverlener')).toBe('error'); + }); +}); + +describe('resolveLabel', () => { + it('returns the field label for a known key', () => { + expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit'); + }); + + it('falls back to the bare key when the field is unknown', () => { + expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown'); + }); +}); + +describe('resolveAuto', () => { + it('reads autoResolvable off the field', () => { + const defs = placeholderDefs(DEFS); + expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true); + expect(resolveAuto(defs, 'reden_besluit')).toBe(false); + }); + + it('defaults to false for an unknown key', () => { + expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false); + }); +}); + +describe('resolveState', () => { + it('defaults to ok when the key has no diagnostic', () => { + expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok'); + }); + + it('surfaces the worst recorded severity', () => { + const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]); + expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning'); + }); +}); + +describe('resolveSample', () => { + it('prefers the canned sample value over the label', () => { + expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe( + 'J. Jansen', + ); + }); + + it('resolves datum to the caller-supplied sample date', () => { + expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe( + '4 september 2026', + ); + }); + + it('falls back to the field label for anything else', () => { + expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe( + 'Reden besluit', + ); + }); +}); diff --git a/docs/project/readable-codebase/RD-26-letter-canvas.md b/docs/project/readable-codebase/RD-26-letter-canvas.md new file mode 100644 index 0000000..1f371a3 --- /dev/null +++ b/docs/project/readable-codebase/RD-26-letter-canvas.md @@ -0,0 +1,171 @@ +# RD-26 — `letter-canvas`: inline the labels, extract `letter-line`, keep the disable + +Status: done +Source: PLAN.md 3d, order step 8 + +## Why + +`letter-canvas.component.ts` is 418 effective lines against a limit of 250. Unlike the other +six offenders, **it is not badly structured** — 77 lines are CSS and the rest is one letter. +Splitting it into letterhead, body, signature and footer would make "what does the letter look +like" a five-file question and buy no behavioural seam. + +Two things do pad it without earning their place: + +- **20 of its 28 `input()`s are pure `$localize` labels, and no caller binds a single one.** + Verified against all three call sites: `behandel-scherm`, `letter-composer` and + `org-template-editor` bind only data (`brief`, `orgTemplate`, `logoUrl`, `editableRegions`, + `diagnostics`, `blockDiffs`, `showDiff`). +- Six lines of `ngTemplateOutlet` ceremony around one `#line` template. + +The CLAUDE.md rule those labels were built for — "Shared/English components must not hardcode +Dutch — expose copy as `input()`s" — governs `libs/shared`. This is a Dutch domain component in +`brief/ui/`. The rule does not apply here. + +## Read first + +- `letter-canvas.component.ts:1` — the disable, whose reason this ticket rewrites. +- `letter-canvas.component.ts:140` — the `#line` template, and lines 255-276, its three + `ngTemplateOutlet` uses. +- `letter-canvas.component.ts:345-372` — the inputs: 8 data, 20 labels. +- PLAN.md 3d and 3e. + +## Decisions (pre-made, don't relitigate) + +1. **Inline 19 of the 20 label inputs as `i18n` in the template.** Same id, same source text, so + **neither `messages.en.xlf` changes**. A label read as `{{ foo() }}` becomes its literal text + plus `i18n="@@id"`; one feeding an attribute becomes `i18n-="@@id"` (`logoAlt` is the + `alt` case). + + The template is inline in this same file, so the 20 `@@brief.canvas.*` ids stay in the file — + they move from a TS declaration into an `i18n` attribute. The id count does not change. + +2. **`recipientText` stays in TS. It is the one exception, and it is not the parameterised case + RD-25 hit.** Its message embeds a newline escape: + + ```ts + $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`; + ``` + + In TS that `\n` is one character of the message. Written as template text it becomes a source + line break, which Angular's extractor treats differently — so "same source text", the + property that makes decision 1 free, does not hold for this one. Leave it as an `input()` and + put a one-line comment on it saying why. Verified: no other label interpolates or escapes + anything. + +3. **Do not collapse the labels into a config object or an injection token.** + `HEADER_NAV_ITEMS` and `DEBUG_PANEL` exist because two apps genuinely differ. Here nothing + differs, so a token would add a provider and an indirection to solve a problem nobody has. + +4. **Extract `letter-line.component.ts` beside the canvas, with a spec.** It takes the `#line` + template plus the sample and diff helpers it needs, and replaces the three `ngTemplateOutlet` + incantations with three one-line tags. It is the only part of this file with logic worth + testing, so it gets a `*.spec.ts` — a pure spec over the helpers, no TestBed. + + Drop the `NgTemplateOutlet` import from the canvas once the last outlet is gone. + +5. **Keep `/* eslint-disable max-lines */`, and rewrite its reason.** This is the one ticket in + the arc that keeps a disable. After RD-21 through RD-25 removed theirs, **this is the only + one left in the repository** — verified. The new reason must state the honest fact rather + than promising a future removal: + + ```ts + /* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into + // letterhead/body/signature/footer makes "what does the letter look like" a five-file + // question for no behavioural seam. Deliberate, not deferred. + ``` + + The README requires a disable to name the ticket that removes it. This one names no ticket + **because none will**, and the reason says so in those words. `reportUnusedDisableDirectives` + still keeps it honest: if the file ever drops under 250, lint fails on the unused directive. + +6. **No new stories.** `letter-canvas.stories.ts` renders the canvas, and `letter-line` is + exercised through it. + +## Files + +- `apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts` (new) +- `apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts` (new) +- `apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts` + +## Steps + +1. Extract `letter-line.component.ts` and its spec (decision 4). +2. Replace the three `ngTemplateOutlet` uses and drop the `NgTemplateOutlet` import. +3. Inline the 19 labels (decision 1), leaving `recipientText` alone (decision 2). +4. Rewrite the disable's reason (decision 5). Do not delete the directive. +5. Run `npm run gen:behaviour-spec` — the new spec adds titles. +6. `git add -A`, then run the acceptance commands. +7. Update this ticket's `Status:` to `done` and the README's RD-26 row to `done`. +8. Commit all of it together. + +## Acceptance criteria + +Measured against the tree before handover. Run after `git add -A`. + +```bash +P=apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts +git grep -c "= input" -- $P # is 28 -> MUST be 9 (8 data + recipientText) +grep -c 'localize' $P # is 20 -> MUST be 1 (recipientText only) +``` + +The ids stayed in the file and the translation seam did not move (decision 1): + +```bash +grep -o "@@[a-zA-Z0-9_.]*" $P | sort -u | wc -l # is 20 -> MUST still be 20 +git status --short -- '*.xlf' | wc -l # MUST be 0 +``` + +The outlet ceremony is gone and the child exists (decision 4): + +```bash +D=apps/ssp/src/app/brief/ui/letter-canvas +git grep -c "ngTemplateOutlet" -- $P # is 6 -> MUST be 0 +git grep -c "NgTemplateOutlet" -- $P # is 2 -> MUST be 0 +git ls-files $D/letter-line.component.ts $D/letter-line.spec.ts | wc -l # is 0 -> MUST be 2 +``` + +The disable survives, with a new reason, and is the only one in the repository (decision 5): + +```bash +git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST still be 1 +git grep -c "eslint-disable max-lines" -- apps libs | wc -l # MUST be 1 (one file matches) +git grep -c "RD-26 rewrites this reason" -- $P # is 1 -> MUST be 0 +``` + +```bash +npm run ci --full # exits 0 +``` + +## Verification + +**`npm run lint` is what proves decision 5 both ways.** If the extraction takes the file under +250 lines, the kept directive becomes unused and lint fails — which would mean the disable +should go after all. Report that rather than deleting it silently: it changes the arc's +conclusion that one file legitimately stays over budget. + +`ng build --localize` inside the gate is the check on decisions 1 and 2. **If you find yourself +editing a `.xlf`, you have changed an id or a source string — undo it instead.** + +**`--full` is required** (the Order table says so): `letter-canvas.stories.ts` renders this +component, and the axe pass over it is what proves the inlined `i18n` markup kept its `alt` +text and its labels. + +## Out of scope + +- Splitting the letter into region components. PLAN 3d rejects it explicitly. +- The 77 lines of CSS. +- `recipientText` (decision 2). +- A config object or token for the labels (decision 3). + +## Risks + +- **`recipientText`'s `\n` is the trap** (decision 2). Inlining it is the one change here that + can silently alter an extracted source string, and the `.xlf` files are hand-maintained. +- **Keep the directive** (decision 5). Every other ticket in Phase 3 deleted one; this ticket is + the exception, and deleting it here would fail `max-lines` instead. +- **A label bound by no caller is still a public input.** Removing it is safe only because all + three call sites were checked. Do not extend the same reasoning to the 8 data inputs. +- **`logoAlt` feeds an attribute**, so it needs `i18n-alt`, not `i18n`. An `i18n` attribute on + the element localises its content, not its `alt`, and the a11y check will not catch the + difference because the text is still present. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 398aeda..5d9ddb7 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -120,7 +120,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | done | | RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | done | | RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | done | -| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo | +| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | done | | RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo | | RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo | | RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | todo | diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index c2b6254..a967796 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 543 frontend behaviours across +**is** the suite, reshaped for a business reader. 556 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -337,6 +337,10 @@ classes. - preserves library order (= reading order) - never offers non-kern passages +#### placeholderDefs + +- keys the placeholder list by its key + #### proefbriefErrorMessage (TE-002 trust boundary) - surfaces the ProblemDetails detail when present @@ -348,6 +352,33 @@ classes. - derives reason checkboxes (code + label) from the negatief reason passages - positief has no reason-specific redenen +#### resolveAuto + +- reads autoResolvable off the field +- defaults to false for an unknown key + +#### resolveLabel + +- returns the field label for a known key +- falls back to the bare key when the field is unknown + +#### resolveSample + +- prefers the canned sample value over the label +- resolves datum to the caller-supplied sample date +- falls back to the field label for anything else + +#### resolveState + +- defaults to ok when the key has no diagnostic +- surfaces the worst recorded severity + +#### worstSeverities + +- ignores a diagnostic with no placeholder key +- keeps error over a warning already recorded for the same key +- does not let a later warning downgrade an error + ### herregistratie #### IntakeWizardComponent