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.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<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) ---