Files
atomic-design-poc/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts
T
ehoandClaude Sonnet 5 43dc3210cd refactor: move libs/shared/src/ui/ into atoms/molecules/organisms (RD-27)
The folder now equals the layer, as CLAUDE.md decision 2 requires. 33
directories move by git mv (25 flat, plus upload/'s 8 subfolders split
across all three layers). 28 distinct @shared/ui/* specifiers rewrite
across 73 files, longest-first. Five relative imports inside upload/
become @shared/ui aliases because their sibling now lives in a
different layer; two stay relative because both ends stay in the same
layer. Four .mdx docs get their seven broken story imports fixed;
atomic-design.mdx's page-shell import is untouched, because layout/
does not move.

No component, template, story title, or layer-tag comment changes.
That is RD-28's job.

Verified against the ticket's acceptance commands: the 26 flat
directories become exactly 3 layer folders with the counts the ticket
names, only three @shared/ui/* prefixes remain (atoms, molecules,
organisms), the .mdx import count holds at 7, and the relative-import
count inside ui/ drops from 7 to 2 as decision 4 requires. The
@shared/ui/ occurrence count moves from 200 to 205: decision 4
mandates turning 5 of those 7 relative imports into @shared/ui/*
aliases, which decision 3's "200 before, 200 after" check does not
account for. The 5-occurrence gap is exactly the 5 conversions decision
4 names, not a lost or duplicated specifier.

npm run ci --full passes: lint, typecheck, dep:check, format, tokens,
seam, both apps' + both libraries' tests, both apps' localized build,
audit, backend tests, all three generated-artifact drift checks, and
both Storybook instances' build + axe-core a11y suite (67+45 suites,
198+112 tests, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:14:10 +02:00

442 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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,
ElementRef,
computed,
effect,
inject,
input,
linkedSignal,
output,
signal,
viewChild,
} from '@angular/core';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
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 = {
readonly list: 'bullet' | 'number' | null;
readonly items: readonly Paragraph[];
};
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
for (const para of paras) {
const kind = para.list ?? null;
const last = out[out.length - 1];
if (kind && last && last.list === kind) last.items.push(para);
else out.push({ list: kind, items: [para] });
}
return out;
}
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4;
/** Organism: the letter as one surface — the org template's letterhead, signature and
footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
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. 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: [ButtonComponent, LetterLineComponent],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-sm);
margin-block-end: var(--rhc-space-max-sm);
}
.zoom {
display: flex;
align-items: center;
gap: var(--rhc-space-max-sm);
}
.zoom-pct {
min-width: 3.5ch;
text-align: center;
color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums;
}
/* Rejection-diff badge: a small pill above a changed/added block. */
.diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm);
}
.diff-badge {
display: inline-block;
margin-block-end: 1mm;
padding: 0 1.5mm;
border-radius: var(--rhc-border-radius-sm);
font-size: 7.5pt;
/* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */
color: var(--rhc-color-foreground-default);
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700);
}
/* Portal-side chrome around the letter surface (not part of the contract file). */
.surface {
background: var(--rhc-color-grijs-100);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-lg);
overflow-x: auto;
}
.surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
}
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input,
.tmpl-textarea {
font: inherit;
color: inherit;
width: 100%;
box-sizing: border-box;
background: var(--rhc-color-geel-100);
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
border-radius: var(--rhc-border-radius-sm);
padding: 0.5mm 1mm;
}
.tmpl-textarea {
resize: vertical;
}
.org-logo {
max-height: 20mm;
max-width: 60mm;
margin-block-end: 3mm;
}
`,
],
template: `
@if (editableRegions() !== 'template') {
<div class="toolbar">
<div
class="zoom"
role="group"
aria-label="Zoomniveau"
i18n-aria-label="@@brief.canvas.zoom"
>
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
aria-label="Uitzoomen"
i18n-aria-label="@@brief.canvas.zoomOut"
(click)="zoomBy(-0.1)"
>−</app-button
>
<span class="zoom-pct">{{ zoomPct() }}</span>
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
aria-label="Inzoomen"
i18n-aria-label="@@brief.canvas.zoomIn"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
>100%</app-button
>
</div>
@if (editableRegions() === 'none') {
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
@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>
}
<div class="surface">
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead">
@if (logoUrl()) {
<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"
aria-label="Organisatienaam"
i18n-aria-label="@@brief.canvas.orgName"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
aria-label="Retouradres"
i18n-aria-label="@@brief.canvas.returnAddress"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
}
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt i18n="@@brief.canvas.date">Datum</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
</div>
<div class="letter__body">
@for (section of brief().sections; track section.sectionKey) {
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@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'">
@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>
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
</ul>
} @else if (seg.list === 'number') {
<ol>
@for (para of seg.items; track $index) {
<li>
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
</ol>
} @else {
<p>
<app-letter-line
[nodes]="seg.items[0].nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</p>
}
}
</div>
}
</section>
}
</div>
<div class="letter__signature">
@if (editing()) {
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
aria-label="Afsluiting"
i18n-aria-label="@@brief.canvas.signatureClosing"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
aria-label="Naam ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureName"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
aria-label="Functie ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureRole"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
<p>{{ orgTemplate().signatureClosing }}</p>
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
<p>{{ orgTemplate().signatureRole }}</p>
}
</div>
<div class="letter__footer">
@if (editing()) {
<textarea
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
aria-label="Contactgegevens (voettekst)"
i18n-aria-label="@@brief.canvas.footerContact"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
aria-label="Juridische voettekst"
i18n-aria-label="@@brief.canvas.footerLegal"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
}
</div>
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
</div>
}
</div>
</div>
`,
})
export class LetterCanvasComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template'). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here. */
zoom = input(1);
/** 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
exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
showDiff = input(false);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
/** 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)`,
);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
protected zoomLevel = linkedSignal(() => this.zoom());
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
protected zoomBy(delta: number) {
// 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);
}
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
protected emitEdit(field: OrgTemplateTextField, event: Event) {
this.templateEdit.emit({
field,
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
});
}
protected marginStyle = computed(() => {
const m = this.orgTemplate().margins;
return {
'--letter-margin-top': `${m.topMm}mm`,
'--letter-margin-right': `${m.rightMm}mm`,
'--letter-margin-bottom': `${m.bottomMm}mm`,
'--letter-margin-left': `${m.leftMm}mm`,
};
});
// --- read-only rendering helper (migrated from the superseded letter-preview) ---
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
private page = viewChild<ElementRef<HTMLElement>>('page');
protected pageBreaks = signal<readonly number[]>([]);
constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and the server preview is authoritative.
const observer = new ResizeObserver(([entry]) => {
// ~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);
this.pageBreaks.set(
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
);
});
effect((onCleanup) => {
const el = this.page()?.nativeElement;
if (!el) return;
observer.observe(el);
onCleanup(() => observer.unobserve(el));
});
inject(DestroyRef).onDestroy(() => observer.disconnect());
}
}