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