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>
442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
/* 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());
|
||
}
|
||
}
|