refactor: inline letter-canvas's 19 pure labels, extract letter-line (RD-26)

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 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-05 00:25:35 +02:00
co-authored by Claude Sonnet 5
parent bcac3789d3
commit 7a8eab917b
6 changed files with 498 additions and 108 deletions
@@ -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<string, string> = {
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: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() !== 'template') {
<div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
<div
class="zoom"
role="group"
aria-label="Zoomniveau"
i18n-aria-label="@@brief.canvas.zoom"
>
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()"
aria-label="Uitzoomen"
i18n-aria-label="@@brief.canvas.zoomOut"
(click)="zoomBy(-0.1)"
></app-button
>
@@ -175,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()"
aria-label="Inzoomen"
i18n-aria-label="@@brief.canvas.zoomIn"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
zoomResetLabel()
}}</app-button>
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
>100%</app-button
>
</div>
@if (editableRegions() === 'none') {
<app-button
@@ -189,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
@if (showSample()) {
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
} @else {
<ng-container i18n="@@brief.preview.showSample"
>Voorbeeld met testwaarden</ng-container
>
}
</app-button>
}
</div>
@@ -201,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead">
@if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
<img
class="org-logo"
[src]="logoUrl()"
alt="Logo van de organisatie"
i18n-alt="@@brief.canvas.logoAlt"
/>
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
aria-label="Organisatienaam"
i18n-aria-label="@@brief.canvas.orgName"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
aria-label="Retouradres"
i18n-aria-label="@@brief.canvas.returnAddress"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
@@ -224,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt>{{ referenceLabel() }}</dt>
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt>{{ dateLabel() }}</dt>
<dt i18n="@@brief.canvas.date">Datum</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
@@ -242,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
<span class="diff-badge" [class.added]="diffKind === 'added'">
@if (diffKind === 'added') {
<ng-container i18n="@@brief.diff.added">nieuw</ng-container>
} @else {
<ng-container i18n="@@brief.diff.changed"
>gewijzigd sinds afwijzing</ng-container
>
}
</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
@@ -262,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
<app-letter-line
[nodes]="seg.items[0].nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</p>
}
@@ -289,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
aria-label="Afsluiting"
i18n-aria-label="@@brief.canvas.signatureClosing"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
aria-label="Naam ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureName"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
aria-label="Functie ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureRole"
(input)="emitEdit('signatureRole', $event)"
/>
} @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)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
aria-label="Juridische voettekst"
i18n-aria-label="@@brief.canvas.footerLegal"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
@@ -334,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span>
<span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
</div>
}
</div>
@@ -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.51.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<string, 'error' | 'warning'>();
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) ---
@@ -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<string, string> = {
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<string, PlaceholderDef> {
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<string, 'error' | 'warning'> {
const m = new Map<string, 'error' | 'warning'>();
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<string, PlaceholderDef>, key: string): string {
return defs.get(key)?.label ?? key;
}
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
return defs.get(key)?.autoResolvable ?? false;
}
export function resolveState(
worst: ReadonlyMap<string, 'error' | 'warning'>,
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<string, PlaceholderDef>,
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') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
`,
})
export class LetterLineComponent {
nodes = input.required<readonly RichTextNode[]>();
showSample = input(false);
placeholders = input<readonly PlaceholderDef[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
/** 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);
}
@@ -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',
);
});
});
@@ -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-<attr>="@@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.
+1 -1
View File
@@ -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 |
+32 -1
View File
@@ -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