Skip to content

Cover letter overhaul: formatted body, editable signature block, personal sender, professional layout #1932

Description

@steilerDev

[product-owner]

As a homeowner sending a report to my bank, I want the cover letter to look like a proper business letter — formatted body text, a real signature block, and my own name and address as sender so that the document I hand over reads as professional correspondence rather than a dump of plain text.

Priority: Should Have
Parent Epic: none — part of the parent-less Bank Report Wizard cluster (#1876#1879, #1898#1901, #1923#1925).
Found during: user review of generated report PDFs (2026-08-01).

Problem

The cover letter is the first page a bank employee reads. Today it is five unstyled text blocks stacked with 20pt gaps, and it looks unfinished.

Actual

1. The body is a single unformatted text run.

client/src/lib/reportPdf/coverLetterPdf.ts L~57 emits the whole body as one pdfmake text node:

content.push({ text: coverLetter.body, style: 'normal', margin: [0, 0, 0, 20] });

and client/src/components/reports/ReportContentEditor.tsx L~117–130 edits it as a bare <textarea>. There is no way to produce a bold phrase, a paragraph break that survives rendering, or a bulleted list — which is exactly what a letter enumerating a claim needs. No markdown or rich-text library exists in client/package.json today (dependencies: @cornerstone/shared, @floating-ui/react, i18next, konva, pdf-lib, pdfmake, react, react-dom, react-i18next, react-konva, react-router-dom).

2. There is no signature field.

client/src/lib/reportContent/buildReportContent.ts L~262 derives the signature implicitly:

const signature = sender.split('\n')[0]?.trim() ?? '';

— i.e. the first line of the sender block, which is the household name. It is not exposed as an editable field in ReportContentEditor (sender, recipient, reference, subject and body are; signature is not). The PDF then prints that string 40pt below the body with no closing salutation and no signature space (coverLetterPdf.ts L~63–69).

This was accepted as a documented deviation during the #1909 review ("signature derived from sender"). The user has now seen the output and rejected it. That earlier acceptance is superseded.

3. The sender is the household, not the person.

buildReportContent.ts L~257–260:

if (household?.householdName) senderLines.push(household.householdName);
if (household?.householdAddress) senderLines.push(household.householdAddress);

Only HouseholdSettings.householdName + householdAddress (shared/src/types/settings.ts L8–9). The logged-in user's own name is never used, even though useAuth().user.displayName (UserResponse, via client/src/contexts/AuthContext.tsx) is available client-side. A bank letter is signed by a person.

4. Overall layout is not a letter.

Sender, recipient, date, reference, subject and body are six equally-weighted blocks separated by identical 20pt margins. There is no visual hierarchy: the subject does not read as a subject, the date does not sit where a date sits, and nothing distinguishes the letter head from the letter body.

5. The reset "X" is an oversized visual glitch.

client/src/components/EditableField/EditableField.module.css: .container is display: flex; flex-direction: column, and the reset button is a direct child of .container (outside .fieldWrapper), so it stretches to full container width. Combined with:

.resetButton { min-width: 44px; min-height: 44px; padding: var(--spacing-2-5); ... }
.resetButton svg { width: 100%; height: 100%; }

the SVG scales to fill the entire 44px-plus box, producing a huge "X" beneath the field the moment any field is edited. The 44px hit area is correct and must be kept; the glyph should not be 44px.

Acceptance Criteria

Body formatting — plain text with preserved line breaks

Scope ruling (user, 2026-08-02): "no full wysiwyg necessary - just a simple text body with line breaks". Plain text only. No markdown, no rich-text editor, no new client dependency. Bold phrases and bulleted lists are out of scope despite being cited as motivation in the Problem section above. See the scope-decision comment on this issue.

Premise correction: pdfmake already honours \n as a required line end (node_modules/pdfmake/js/TextBreaker.js L30–34, L53–58) — the existing sender block depends on it. The body's line-break round trip therefore already works and is merely unpinned. ACs 1.1–1.3 are regression guards, not a feature build; AC 1.6 is the only new behaviour in this section.

  • 1.1 Given a cover-letter body containing several paragraphs separated by blank lines, When the step-5 editor renders it, Then the textarea shows the paragraph structure the user typed, unaltered — no collapsing, trimming, or normalisation of internal whitespace on the round trip through the override store.
  • 1.2 Given the same multi-paragraph body, When the PDF is exported, Then every line break and blank line the user typed is present in the rendered PDF, in the same order and at the same positions — asserted against a real render (pdfmake's resolved layout), not merely against the string content of the emitted text node.
  • 1.3 Given a body containing characters that look like markup (**, - , #, <b>), When preview and PDF render, Then those characters appear literally and unchanged — the body is emitted as text and is never parsed as markup or HTML, and no markdown or rich-text dependency is added to client/package.json.
  • 1.4 Given the user is editing the body, When they look at the editor, Then the supported formatting is discoverableSTRUCK AS VACUOUS. There is no markup to discover. Replaced by: the body editor must not advertise, hint at, or imply formatting support, and the textarea must be tall enough to show a multi-paragraph letter's structure without the user scrolling to find it (currently rows={6}).
  • 1.5 VACUOUS BY CONSTRUCTION — retained as a negative constraint. No script execution or HTML injection is possible because the body is only ever rendered into a <textarea> value and a pdfmake text node. The criterion is therefore: no dangerouslySetInnerHTML, no HTML parsing, and no markup interpretation is introduced anywhere on the cover-letter body path.
  • 1.6 Given the AI enhancement path, When it returns a cover-letter body, Then that body contains no markup — the prompt (server/src/services/budgetExtraction/prompts.ts, the "Letter body" instruction, L~142, which today says nothing about output format) explicitly requires plain prose with paragraphs separated by blank lines and forbids markdown, bullet characters, and HTML — so no literal **, - , or tag characters reach the exported PDF. This is the load-bearing criterion in this section.

Signature

  • 2.1 Given step 5 with a cover letter enabled, When the editor renders, Then there is an explicit, editable signature field, presented like the other editable letter fields (visible label, edited indicator, per-field reset).
  • 2.2 Given the user has not edited the signature, When the letter is generated, Then the signature is pre-populated with a sensible default derived from the user's identity (see AC 3.1), not silently taken from the first line of the sender block.
  • 2.3 Given the user edits the signature, When the PDF is exported, Then the edited value appears in the PDF, and resetting the field restores the generated default.
  • 2.4 Given any cover letter, When the PDF is exported, Then the signature renders as a signature block — a closing element, blank space for a handwritten signature, and the signatory name — not as a bare line of text floating below the body.
  • 2.5 The closing/salutation text in the signature block is rendered in the report language, resolved through the report content model (reportT), never through the editor's interface t — per the artifact-content-vs-edit-affordance rule established in feat(reports): editable HTML report preview with on-demand PDF export #1909 and reinforced in feat(reports): report table cleanup — shared footnotes, deposit labels, claim metadata, total-only summary, usage area (#1923) #1924.
  • 2.6 Given the user has explicitly edited the signature, When they then also edit the sender, Then the sender edit does not silently overwrite the signature. applyOverrides.ts L66–68 currently recomputes signature from an overridden sender (result.coverLetter.sender.split('\n')[0]), and types.ts L44 documents signature as DERIVED; both must be reconciled with signature becoming a first-class editable field, and the realRender assertion that pins the old recompute ("overriding coverLetter.sender changes the rendered signature too", realRender.test.ts L997) must be updated to the new intended behaviour, not deleted. (Added during scope reconciliation: this coupling between §2 and §3 exists in the code today and would otherwise surface as a surprise at review.)

Sender

  • 3.1 Given a logged-in user with a display name and a household with a configured address, When a cover letter is generated, Then the sender block contains the user's display name and the household address.
  • 3.2 Given a user with no display name, or a household with no address, or both missing, When a cover letter is generated, Then the sender block degrades gracefully — it contains whatever identity information is available, with no blank lines, no placeholder text, and no crash.
  • 3.3 Given the generated sender, When the user edits the sender field, Then the edit is respected end-to-end into the PDF and can be reset (existing override behaviour preserved).
  • 3.4 Given the cover-letter availability rule, When a source has neither a contact address nor a reference, Then the cover letter remains unavailable with its existing disabled hint — this issue does not change when the cover letter is offered.

Letter layout

  • 4.1 Given a generated cover letter, When the PDF is exported, Then it is laid out as a business letter: a sender block, a recipient block, a date, an optional reference, a subject that is visually identifiable as the subject, the body, and the signature block — in a conventional order with deliberate (not uniform) spacing between them. Amended for the plain-text ruling: this AC now also owns paragraph separation within the body. With no formatting markup to carry paragraph semantics, a blank line in the body currently renders as an empty line at full body line-height; whether that is acceptable or whether the letter layout applies typographic paragraph spacing instead is a §4 layout decision (ux-designer, per AC 4.4) — it is no longer a §1 concern.
  • 4.2 Given the same letter, When the step-5 preview renders, Then the preview conveys the same structure and reading order as the PDF, so the user can predict the exported result.
  • 4.3 Given a cover letter of any length, When the PDF is exported, Then no letter element is clipped or overlaps another, and the existing page break between the cover letter and the report table is preserved.
  • 4.4 The letter layout is specified by ux-designer before implementation, and all CSS values use design tokens (stylelint clean).
  • 4.5 Given the report language differs from the interface language, When the letter renders in preview and PDF, Then every string that is part of the letter artifact (labels, closing, subject caption, date) is in the report language, and only edit affordances remain in the interface language.

Reset button

  • 5.1 Given any edited EditableField anywhere in the application, When the reset control renders, Then its glyph is proportionate to the surrounding text and does not visually dominate the field.
  • 5.2 Given the same control, When measured, Then its interactive target still meets the 44×44px minimum touch-target requirement.
  • 5.3 Given the same control, When it renders, Then it does not stretch to the full width of its container and is positioned as a per-field affordance adjacent to its field.
  • 5.4 Given keyboard navigation, When the reset control receives focus, Then a visible focus indicator meeting WCAG AA is present, in both light and dark mode.
  • 5.5 Given the existing accessible name and title (resetFieldAriaLabel with the field name interpolated) and the edited-state dot and screen-reader hint, When the control is changed, Then all of those behaviours are preserved unchanged.
  • 5.6 The fix applies to the shared EditableField component, so every consumer benefits — no per-call-site override.

Folded-in duplicate: #1925

Unaffected by the plain-text ruling. #1925 is about the date caption in the letter head (interface-language label beside a report-language value) and about caption chrome styling. Neither depends on the body-formatting model. All four ACs carry forward verbatim. 6.1's "per the resolution chosen in the new letter layout" resolves under §4 exactly as before.

  • 6.1 Given interface language de and report language en (and the mirror case), When the step-5 preview renders the cover letter, Then no single rendered line mixes the two languages — the date caption and the date value are consistent per the resolution chosen in the new letter layout.
  • 6.2 Given any interface/report language combination, When the cover letter preview renders, Then any non-editable caption is visually distinguishable as editor chrome, consistent with the other captions in the same panel.
  • 6.3 content.coverLetter.dateLine remains report-language-formatted via reportFormatters.formatDate — this must not regress.
  • 6.4 Locale parity holds for every cover-letter key touched: present in both en and de, or removed from both.

Tests

  • 7.1 Unit tests cover the line-break round trip for a multi-paragraph body (editor → override store → pdfmake node), the signature field's default / edit / reset cycle including the AC 2.6 sender-edit interaction, sender composition including every degraded case in AC 3.2, and the reset-button sizing/target constraints.
  • 7.2 A real-render assertion in client/src/lib/reportPdf/realRender.test.ts pins the exported letter's structure, its report-language strings, and — per AC 1.2 — that a multi-paragraph body's line breaks and blank lines survive into the rendered output. This pin is the guard against a future change reflowing the body into per-token inline runs (the technique used elsewhere in reportPdf/ for pdfmake's all-or-nothing wordBreak), which would silently destroy \n handling.
  • 7.3 E2E coverage exercises editing the signature and a multi-paragraph body in step 5 and exporting, at desktop and mobile viewports, in both light and dark mode.
  • 7.4 A test asserts the AC 1.6 prompt constraint — the cover-letter body instruction in prompts.ts requires plain prose and forbids markup — in the same style as the existing prompts.test.ts regression guards.

Notes

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions