diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md
index c2037cf37..ddaab131a 100644
--- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md
+++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md
@@ -31,6 +31,7 @@
- [issue-1959-inline-meta-and-labels.md](issue-1959-inline-meta-and-labels.md) — #1959 reversed #1923's †/‡ footnotes → inline `(partial)`/`(less deposit)` and merged area+attachments into one `.usageMetaText` line; POM renames, rewritten scenarios 2/17/18/20 + AI 8, attachment-tier facts, untested column toggles.
- [claim-deposit-scope-1922.md](claim-deposit-scope-1922.md) — PR #1922 invoice/deposit claim-scope split: `handleMarkClaimed`'s two-array submit, server-truth success-banner counts, the three "deposit surfaces the invoice" shapes, `claimNothingClaimable` guard.
+- [issue-1973-column-visibility-pdf.md](issue-1973-column-visibility-pdf.md) — column-visibility toggles wired to the PDF (supersedes #1966): reducer reset facts (use-case change resets `hiddenColumns`, `SET_ATTACH_DOCUMENTS` doesn't), Scenarios 28-33, size-diff PDF-consequence proof pattern, `goBack()` viewport-independent multi-step walk, new `columnToggleGroup`/`usageHiddenAttachmentsWarning` POM locators.
## Open follow-ups to flag to orchestrator
diff --git a/.claude/agent-memory/e2e-test-engineer/issue-1973-column-visibility-pdf.md b/.claude/agent-memory/e2e-test-engineer/issue-1973-column-visibility-pdf.md
new file mode 100644
index 000000000..0235d92f3
--- /dev/null
+++ b/.claude/agent-memory/e2e-test-engineer/issue-1973-column-visibility-pdf.md
@@ -0,0 +1,121 @@
+---
+name: issue-1973-column-visibility-pdf
+description: Issue #1973 E2E coverage — column-visibility toggles wired through to the generated PDF, superseding #1966's DOM-only coverage. New POM locators, 6 rewritten/added scenarios, viewport-scope reasoning.
+metadata:
+ type: project
+---
+
+## What changed in production (frontend-developer, this issue)
+
+`hiddenColumns` moved from `ReportContentEditor` local `useState` into `ReportWizardPage`'s
+wizard-reducer `ContentTier` (`wizardReducer.ts`), threaded through to
+`generatePdfFromContent`/`overviewPdf.ts`. Single source of truth for the base column set per use
+case and the locked column lives in `client/src/lib/reportContent/columns.ts`
+(`isColumnLocked`/`visibleReportColumns`/`REQUIRED_REPORT_COLUMN = 'allocatedAmount'`), consumed
+by both the editor UI and the PDF geometry engine (AC 2.1).
+
+Key reducer facts (`client/src/pages/ReportWizardPage/wizardReducer.ts`):
+- `SELECT_USE_CASE` spreads `freshContentTier()` → `hiddenColumns` resets to `new Set()` on every
+ use-case change (AC 5.1). This is the SAME mechanism that already clears `overrides`/`aiContent`.
+- `DISCARD_EDITS` explicitly PRESERVES `hiddenColumns` (`hiddenColumns: state.hiddenColumns`
+ overridden back in after the `freshContentTier()` spread) — column visibility is a presentation
+ choice, not a "content edit" that the discard-confirm modal guards.
+- `hiddenColumns` is never persisted (no preference endpoint involved at all) — AC 5.2/5.3 fall
+ out for free: a full page reload wipes the in-memory reducer state entirely, same as every other
+ wizard-run-scoped field.
+- `SET_ATTACH_DOCUMENTS` only touches `SettingsTier` — going back to Settings and toggling
+ attachDocuments does NOT reset `hiddenColumns`, so a single seeded fixture can walk through all
+ 4 combinations of the AC 6.2 warning-banner matrix without re-navigating from scratch.
+
+Both the desktop `
` and the mobile `.mobileCardList` gate on the exact same `show(col)`
+derivation in `ReportContentEditor.tsx` — confirmed by grep, no `@media` rule anywhere touches
+`.columnToggles`/`.columnToggleGroup`. The ux-designer's finding that the toggle group itself has
+**no responsive hiding** is correct and verified independently here.
+
+## E2E work done
+
+Rewrote the pre-existing "Scenario 24, #1966" describe block in
+`e2e/tests/budget/reportWizardEditableContent.spec.ts` (renamed **Scenario 28** — the file already
+had an unrelated, unrenumbered "Scenario 24" collision for the signature-reset test at the OLD
+line ~2236; both used the literal string "Scenario 24" and I did not touch the signature one) and
+added 5 new scenarios (29-33), picking fresh numbers past the file's existing max (27, the
+lang-attribute scenarios) rather than reusing/renumbering anything else in the file.
+
+- **Scenario 28** (desktop only, by documented exclusion): DOM-level baseline carried forward
+ verbatim per the spec's explicit instruction — checkbox presence/count, ``+` ` removal
+ via `getByRole('columnheader'/'cell')` (requires real table semantics, absent from the mobile
+ card list), no-PATCH assertion (AC 7.1, AC 5.2).
+- **Scenario 29** (desktop only — download mechanism isn't viewport-dependent): THE scenario that
+ closes the #1966 gap (AC 1.2/7.2). Size-diff, not byte-parsing: seed Usage with ~40 sentences of
+ real text, download baseline (all columns), hide Usage, download again, assert
+ `hiddenSize < baselineSize`. Deliberately NOT a bare `>1000 bytes` check (that's Scenario 8's
+ weaker shape, which would pass identically whether or not the toggle reached generation) — a
+ code comment at the assertion says so explicitly per the spec's instruction, to survive a future
+ "simplify this" pass.
+- **Scenario 30** (`@responsive`, all 3 viewports): AC 2.2 locked checkbox — `toBeDisabled()`,
+ non-empty resolvable `aria-describedby` target, and `uncheck({force: true})` (bypasses
+ Playwright's actionability check, which would otherwise refuse to interact with a disabled
+ element outright) still leaves it checked afterward — a genuine behavioral proof, not a
+ restatement of `toBeDisabled()`.
+- **Scenario 31** (`@responsive`, all 3 viewports): AC 6.2 warning banner, all 4 combinations of
+ (Usage hidden/visible) × (attachDocuments on/off) in ONE test/ONE fixture, using the
+ `SET_ATTACH_DOCUMENTS`-doesn't-reset-`hiddenColumns` fact above to avoid re-seeding.
+- **Scenario 32** (`@responsive`, all 3 viewports): AC 5.1 use-case reset — hide a column on
+ `claim` (6 checkboxes), walk back to step 1 via 4× `goBack()` (viewport-independent — see
+ below), switch to `budget-overview`, walk forward, assert 7 checkboxes ALL checked (not just
+ "no longer hidden" — proves the new use case's own base-set SIZE, not just a stale 6).
+- **Scenario 33** (`@responsive`, all 3 viewports): AC 5.3 reload reset — reach step 5 via the
+ `?sourceId=` deep-link pattern (`wizard.goto(sourceId)`, mirroring `reportWizard.spec.ts`
+ Scenario 7), hide a column, `page.reload()` (URL still carries the query param), re-walk the
+ deep-link flow, assert the full 6-checkbox base set restored.
+
+## Viewport-scope decision (AC 7.3)
+
+Scenario 28 stays desktop-only WITH a documented reason (ARIA table-role dependency) — this is the
+one exclusion AC 7.3 explicitly allows ("a code comment states which are excluded and why").
+Scenarios 30-33 (checkbox-state assertions that don't depend on table semantics at all — the
+toggle group renders identically at every viewport) run at all 3 configured viewports via
+`{ tag: '@responsive' }`, with NO per-viewport branching needed in the assertion bodies (verified
+via `--list`: 53 tests total in the file across [desktop]/[tablet]/[mobile], vs. 34 before this
+issue). Scenario 29 (PDF download) stays desktop-only — the download mechanism itself isn't
+viewport-dependent, only the DOM-interaction scenarios are; this matches the E2E spec's explicit
+carve-out.
+
+## POM additions (`e2e/pages/ReportWizardPage.ts`)
+
+- `columnToggleGroup` = `page.getByRole('group', { name: 'Show/hide columns' })` — was previously
+ inlined at every call site in the old #1966 test; factored out since it's now reused across 6
+ scenarios.
+- `usageHiddenAttachmentsWarning` = `page.locator('[class*="bannerWarning"]')` — scoped by
+ CSS-module class (verified via grep: `bannerWarning` is used by exactly ONE component in the
+ whole client tree, `ReportContentEditor.tsx`), not by text, so it's stable against copy edits
+ and unambiguous against the page's other `role="status"` regions (`Toast`, several
+ `srOnly`/loading indicators — confirmed via grep there are ~15 other `role="status"` elements
+ across the app, several of which could plausibly be present on this same page).
+
+## Reusable navigation fact confirmed this session
+
+`goBack()` (`page.locator('[class*="buttonRow"] [class*="btnSecondary"]').first()`, re-queried
+lazily at each `.click()`) is safe to call repeatedly to walk backward through MULTIPLE steps
+(verified: only one `buttonRow` is ever mounted at a time across all 5 wizard steps) — 4 calls
+walk step 5 → step 1. This is viewport-independent, unlike `goToStep()` which clicks the
+desktop-only stepper widget (CSS-hidden below 768px) and would fail at the `mobile` project.
+Prefer `goBack()` over `goToStep()` for any `@responsive`-tagged test that needs to navigate
+backward.
+
+## Prior-CI triage performed for this session (see also `known-flakes-and-regressions.md`)
+
+Checked recent beta-merged PRs' full E2E results (`gh pr view --json statusCheckRollup`,
+since `gh run list --branch beta --workflow "Quality Gates"` only surfaces promotion-PR runs, not
+individual story/bugfix PRs — those run as `pull_request` checks on the PR's own head branch, not
+a push to `beta`). Found ONE red shard in the last ~15 merges: PR #2007 ("refactor(reports):
+remove TFunction from reportPdf/*", merged 2026-08-05), shard 10/16, both attempt+retry failed on
+`invoices/invoice-vendor-change.spec.ts:129` [tablet] "Changing the vendor and saving updates the
+detail page and vendor list" with `TimeoutError: locator.waitFor: Timeout 10000ms exceeded`.
+Confirmed via `gh pr view 2007 --json files` that PR #2007's diff touches ONLY
+`reportContent`/`reportPdf`/`ReportWizardPage.tsx` files — nothing under `invoices/` or
+`vendors/` — so this is unrelated to that PR's own change and NOT caused by #1973's work either
+(different domain entirely). Not yet triaged to root cause (single occurrence so far, not
+established as a recurring flake) — flagging here for whoever next touches
+`invoice-vendor-change.spec.ts` or investigates a shard-10 tablet failure. Did not attempt a fix
+(out of scope for #1973, and a single occurrence isn't enough evidence to diagnose confidently).
diff --git a/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md b/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md
index 902bb9655..13412ac99 100644
--- a/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md
+++ b/.claude/agent-memory/e2e-test-engineer/known-flakes-and-regressions.md
@@ -7,6 +7,7 @@ metadata:
## Currently open / unresolved
+- **PR #2007 shard 10/16 (2026-08-05, merged to beta despite this)**: `invoices/invoice-vendor-change.spec.ts:129` [tablet] "Changing the vendor and saving updates the detail page and vendor list" — `TimeoutError: locator.waitFor: Timeout 10000ms exceeded` on both attempt and retry. Confirmed via `gh pr view 2007 --json files` that #2007's entire diff is `reportContent`/`reportPdf`/`ReportWizardPage.tsx` — nothing under `invoices/`/`vendors/` — so this is unrelated to that PR and not a regression it introduced. Single occurrence so far (not yet confirmed as a recurring flake vs. one-off CI resource contention) — no root-cause investigation done yet. Flag for whoever next touches this spec file or hits a shard-10/tablet failure.
- `i18n-categories.spec.ts` "German locale: Manage trades tab shows 'Sanitär'..." — intermittent, locale doesn't initialize before English page renders. Pre-existing (seen before PR #1186 too).
- **Latent cross-file hazard (unfixed, needs owner):** `e2e/tests/navigation/dashboard.spec.ts`'s file-level `beforeEach` PATCHes `locale='en'` on the **shared admin** user for all ~30 of its tests (added defensively so an i18n test's leftover `de` wouldn't break German card headings). `i18n.spec.ts`'s file-scoped serial mode does NOT protect against this — it only serializes within its own file. They happen to land in different shards today (verified: shard 4 contains `i18n*.spec.ts` but not `dashboard.spec.ts`), so it is currently latent. If E2E shard redistribution ever co-locates them, every i18n German assertion becomes racy again and the real fix is dedicated users in `i18n.spec.ts` (`i18n-categories.spec.ts` pattern).
- `budget-overview-print.spec.ts` "Dark mode: print resets CSS variables" — HARD FAIL, production bug #1451 (`:global(@media print)` dropped by bundler).
diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md
index 1cbc88a80..f74da89ba 100644
--- a/.claude/agent-memory/product-architect/MEMORY.md
+++ b/.claude/agent-memory/product-architect/MEMORY.md
@@ -6,7 +6,7 @@
- [Dual-rail aggregation](dual-rail-aggregation.md) — Rail A/B tagged-deposit invariants (#1891/PR #1894), residual-denominator rule, isSplit UNION
- [Source-report split inference](source-report-split-inference.md) — budgetLines[]/deposits[] are this-source-scoped, so †/‡ classification is a proxy; proposed `splitKind`; pdfmake `'2*'` width trap; wiki + shared type JSDoc both fixed (API-Contract #1914, sourceReport.ts #1917/PR #1994)
- [Story reviews](story-reviews.md) — per-story and per-PR review log
-- [Client PDF pipeline](client-pdf-pipeline.md) — ADR-034 report PDF generation, reportContent content/layout split (#1900), `dontBreakRows` silent-drop rule, document-level deduplicated legend (#1965) — ADR-034 B4 rule + legend addendum landed in PR #1979; per-locale header character budget + "no interface `t` in header/footer" (#1937/#1938, PR #1982); pdfmake `Content` is unspreadable (TS2698) but `Object.assign` needs no cast, and per-item `wordBreak`/newline-only-run facts (#1968, PR #2002). **ADR-034 debt fully PAID 2026-08-04 (#1914)**: width rule #1 (`max(horizontalRatio) <= 1`, not `_minWidth`), module table, override keys, dontBreakRows/height-bound section, injection-only locale contract. `merge.ts` footer/header interface-`t` defect **CLOSED** (footer PR #2000/#1993, header #1938); `TFunction` removed from all of `reportPdf/*` in #2001/PR #2007, so the locale contract is now compiler-enforced there and `buildReportContent.ts` is the single legitimate `TFunction` entry point — ADR-034 lines 82/178/186-188/200/219 all state the old weaker contract and needed a wiki pass (flagged in the PR #2007 review). **ADR-034 rule #1 is WRONG A SECOND TIME (proven in the PR #2008 review, 2026-08-05): `horizontalRatio` is a cell-origin bound, not a content-extent check — it is unconditionally `<= 1` on the all-fixed-width production table and cannot detect token overflow. The check that works is per-cell `_minWidth <= _calcWidth` (the ADR's `wordBreak` false-positive rationale for banning `_minWidth` is empirically false). **Third correction PAID 2026-08-05** (wiki `da1324b`): rule #1 leads with per-cell `_minWidth`, `maxHorizontalRatio` re-scoped to table-box positioning with a vacuity warning, `_minWidth` ban split table-level vs per-cell, 3rd Deviation Log row added. Semantics verified by mutation test in the PR #2008 round-2 review; **two follow-ups still open on the page — every quoted figure (69.28/33.54/266.16pt) is stale, and the rule says "every cell" while the test covers only Usage.**
+- [Client PDF pipeline](client-pdf-pipeline.md) — ADR-034 report PDF generation, reportContent content/layout split (#1900), `dontBreakRows` silent-drop rule, document-level deduplicated legend (#1965) — ADR-034 B4 rule + legend addendum landed in PR #1979; per-locale header character budget + "no interface `t` in header/footer" (#1937/#1938, PR #1982); pdfmake `Content` is unspreadable (TS2698) but `Object.assign` needs no cast, and per-item `wordBreak`/newline-only-run facts (#1968, PR #2002). **ADR-034 debt fully PAID 2026-08-04 (#1914)**: width rule #1 (`max(horizontalRatio) <= 1`, not `_minWidth`), module table, override keys, dontBreakRows/height-bound section, injection-only locale contract. `merge.ts` footer/header interface-`t` defect **CLOSED** (footer PR #2000/#1993, header #1938); `TFunction` removed from all of `reportPdf/*` in #2001/PR #2007, so the locale contract is now compiler-enforced there and `buildReportContent.ts` is the single legitimate `TFunction` entry point — ADR-034 lines 82/178/186-188/200/219 all state the old weaker contract and needed a wiki pass (flagged in the PR #2007 review). **ADR-034 rule #1 is WRONG A SECOND TIME (proven in the PR #2008 review, 2026-08-05): `horizontalRatio` is a cell-origin bound, not a content-extent check — it is unconditionally `<= 1` on the all-fixed-width production table and cannot detect token overflow. The check that works is per-cell `_minWidth <= _calcWidth` (the ADR's `wordBreak` false-positive rationale for banning `_minWidth` is empirically false). **Third correction PAID 2026-08-05** (wiki `da1324b`): rule #1 leads with per-cell `_minWidth`, `maxHorizontalRatio` re-scoped to table-box positioning with a vacuity warning, `_minWidth` ban split table-level vs per-cell, 3rd Deviation Log row added. Semantics verified by mutation test in the PR #2008 round-2 review; **two follow-ups still open on the page — every quoted figure (69.28/33.54/266.16pt) is stale, and the rule says "every cell" while the test covers only Usage.** **#1973/PR #2010 (2026-08-05, CHANGES REQUESTED): the 2-hardcoded-shapes era is over — 96 legal column subsets via a single-absorber `computeColumnWidths`, `reportContent/columns.ts` as the AC 2.1 single derivation; `USAGE_WIDTH_7COL` 138.28pt is the NARROWEST Usage width so the 650 chunk budget needed no re-measurement (one-sided clamp). ADR-034's "Geometry constraint that blocks a feature" (line 153) was a false prohibition, plus line 144's constant reference and line 155's "hangs" claim (it throws). **All three PAID by me 2026-08-05, wiki `eb24774`**: section replaced with "Column geometry is a computed engine, not two pinned shapes", `columns.ts` added to the module structure, Deviation Log row. Same commit documented the wizard **tier-factory convention** in Architecture.md (never documented anywhere before, born #1947 after #1943/#1946) incl. the "second `DISCARD_EDITS` opt-out must trigger a tier split" limit.**
- [Diary drafts pattern](diary-drafts-pattern.md) — ADR-022 draft lifecycle via status column on parent table
- [EPIC-03 refinement](epic03-refinement.md) — 40 consolidated refinement items
- [EPIC-04 household items](epic04-household-items.md) · [EPIC-05 budget](epic05-budget.md) · [EPIC-17 i18n](epic17-i18n.md) · [EPIC-18 areas & trades](epic18-areas-trades.md)
diff --git a/.claude/agent-memory/product-architect/client-pdf-pipeline.md b/.claude/agent-memory/product-architect/client-pdf-pipeline.md
index c04c0e669..6b2fb74a6 100644
--- a/.claude/agent-memory/product-architect/client-pdf-pipeline.md
+++ b/.claude/agent-memory/product-architect/client-pdf-pipeline.md
@@ -523,3 +523,64 @@ figure that still appears at ADR-034 lines 105, 120, 129, 138 and in the Deviati
Narrowest text column is **Vendor at 45pt** (`VENDOR_WIDTH`, `overviewPdf.ts:26`,
`VENDOR_SAFE_TOKEN_CHARS` = 5) — it is the binding constraint for the `wordBreak` rule, not Usage,
and as of PR #2008 it has no `_minWidth` coverage.
+
+## #1973 / PR #2010 — column-visibility geometry engine (reviewed 2026-08-05, CHANGES REQUESTED)
+
+The two hardcoded table shapes are gone. `overviewPdf.ts` now renders any of **96 legal column
+subsets** (64 budget-overview + 32 claim; `allocatedAmount` locked, R1), driven by
+`client/src/lib/reportContent/columns.ts` — the AC 2.1 single derivation consumed by *both*
+`overviewPdf.ts` and `ReportContentEditor.tsx`.
+
+**Width mechanism (endorsed): single absorber.** `computeColumnWidths(visible)` picks
+`usage` → else `vendor` → else `null`; the absorber takes `usableColumnWidth(n) − fixedSum`, every
+other column keeps its pinned constant. This discharges R7/AC 3.1–3.5 *algebraically*
+(`total = printableWidth()` exactly with an absorber, strictly less without) instead of by 96
+assertions. `'*'` was correctly rejected (#1929 `columnCalculator.js` case-1); proportional
+slack-sharing would violate R7's "don't stretch a numeric table across 515pt".
+
+**Key derived fact: `USAGE_WIDTH_7COL` (138.28pt) is the NARROWEST Usage width across all 96
+subsets.** Removing any column both shrinks `fixedSum` (≥ 40pt) and grows `usableColumnWidth`
+(+8.5pt), so Usage is strictly monotone-decreasing in column count. That is why
+`MAX_SAFE_USAGE_CHUNK_CHARS` (650, measured at 138.28pt) needed **no re-measurement** — hiding
+columns only makes it more conservative. `usageChunkCharsForWidth` is a deliberate one-sided clamp
+(`min(650, floor(650 · w/138.28))`): scales down for a future *added* column, never up.
+
+**Standing review lesson — a wiki page can state a PROHIBITION that a later PR deletes.**
+ADR-034's "Geometry constraint that blocks a feature" (line 153) said the PDF column count is
+fixed at 6 or 7 and that wiring the toggles through "is a re-measurement story, not a UI change."
+PR #2010 falsified every clause and touched no wiki file. **When reviewing a PR that removes a
+constraint, grep the wiki for the constraint's own statement, not just for the API/schema surface
+the PR touches** — a stale prohibition actively steers the next agent away from work that already
+shipped. Also stale on that page and folded into the same ask: line 144 quotes
+`packUsageCellRows(segments, MAX_SAFE_USAGE_CHUNK_CHARS)` (now per-subset `usageChunkChars`), and
+line 155's "Related sharp edge" says `packUsageCellRows` **hangs** on `maxChars <= 0` — it throws
+(`overviewPdf.ts:338`), and its own prediction ("it matters the moment it becomes computed") went
+live with this PR.
+
+**Summary-label three-tier fallback (R2)** — tier 1 last visible leading column, tier 2
+`invoiceAmount`, tier 3 a stack block below the table. Real subset counts are **88 / 4 / 4**; the
+issue body and the test title both say "92" for tier 1 (they merged tiers 1+2). Tier 3's block is
+**not width-constrained** — two implicit `'*'` columns across 515.28pt while the
+`{allocatedAmount}`-alone table is only 84pt, so the total floats 431pt from its column (2 of 96
+subsets; flagged M3).
+
+**ADR-034 debt for #1973 is PAID (wiki `eb24774`, 2026-08-05, by me during PR #2010 review.)** The old
+"Geometry constraint that blocks a feature" section is replaced by **"Column geometry is a computed
+engine, not two pinned shapes"**, which now carries the narrowest-Usage-width proof, the absorber
+rationale (and why `'*'` / proportional slack-sharing were rejected), the one-sided clamp, the
+residual "adding a column IS still a re-measurement story" constraint, and the forcing-function
+audit. `reportContent/columns.ts` is in the module structure. Same commit added Architecture.md's
+**"Multi-step wizard state: tier factories"** subsection — the `freshXTier()`-spread convention had
+never been documented anywhere despite being the fix for #1943/#1946/#1947.
+
+**Still open on ADR-034 after this pass** (all pre-existing, none introduced by #1973): every quoted
+`69.28`/`33.54`/`266.16pt` figure is stale (lines ~105/120/129/138), and rule #1 says "every cell"
+while `realRender.test.ts` covers only Usage — Vendor at 45pt is the binding column.
+
+**Exhaustiveness audit pattern for a keyed geometry engine.** Compile-error-enforced here:
+`PINNED_WIDTHS: Record`, `HEADER_LABEL: Record`,
+`buildBodyCell`'s `default`-less switch. **Not** enforced: `OVERVIEW_COLUMNS`/`CLAIM_COLUMNS` in
+`columns.ts`, `LEADING_COLUMNS`/`RIGHT_ALIGNED_COLUMNS`, and the absorber ternary — all hand-typed
+lists over the same union, and the tests that "count" them pin the literals 7/6, so a new key is
+silently absent everywhere. Recommended fix shape: derive the canonical order from an exhaustive
+`Record` and define the base sets as filters over it.
diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md
index abe21f03f..b4594e259 100644
--- a/.claude/agent-memory/product-architect/recurring-patterns.md
+++ b/.claude/agent-memory/product-architect/recurring-patterns.md
@@ -1296,3 +1296,33 @@ where DE is the documented binding locale (#1937). Usage at 138-187pt is the _wi
**How to apply:** when a test lands against a universally-quantified documented rule, check the
quantifier. Picking the column the issue happened to mention is not the same as picking the binding
one — and when the render already happened, iterating all cells is nearly free.
+
+## A wiki page can state a PROHIBITION that the PR under review deletes
+
+**Why:** #1973/PR #2010 generalised `overviewPdf.ts` to 96 column subsets. ADR-034's "Geometry
+constraint that blocks a feature" said the PDF column count is fixed at 6 or 7 and that wiring the
+wizard's toggles through "is a re-measurement story, not a UI change." The PR touched no wiki file,
+so the merged state would have documented the shipped feature as impossible — actively steering the
+next agent away from it. Same page, same round: a quoted constant reference (`MAX_SAFE_USAGE_CHUNK_CHARS`
+→ per-subset `usageChunkChars`) and a "this function **hangs** on `maxChars <= 0`" claim that a prior
+fix had already turned into a throw.
+
+**How to apply:** on any PR that *removes* a limitation, grep the wiki for the limitation's own
+statement — not just for the API/schema surface the diff touches. Constraint prose lives in ADR
+Consequences and "sharp edge" sections that no schema/contract diff would ever point you at. Bonus
+tell: if the issue body cites a wiki constraint as its motivation, that exact paragraph is the one
+the PR must rewrite.
+
+## Exhaustiveness audit for a keyed engine: list which links are compile-enforced and which are not
+
+**Why:** #1973's geometry engine keys everything off a `ReportColumnKey` union. Three links force a
+new key at compile time (`Record`, `Record`, a
+`default`-less switch with a declared return type). Four do not — the base-set arrays in `columns.ts`,
+`LEADING_COLUMNS`, `RIGHT_ALIGNED_COLUMNS`, and the absorber-priority ternary — and the tests that
+look like they'd catch it pin literal counts (`toHaveLength(7)`) derived from the test's own hand-typed
+array, so they can't.
+
+**How to apply:** when reviewing a union-keyed engine, enumerate every site that consumes the union and
+classify each as forcing or non-forcing; report the non-forcing ones even when they are correct today.
+The cheap fix is almost always to derive the hand-typed list from an exhaustive `Record` and
+filter, which converts a silent omission into a compile error.
diff --git a/.claude/agent-memory/product-owner/MEMORY.md b/.claude/agent-memory/product-owner/MEMORY.md
index 4cf80f37b..c90014d75 100644
--- a/.claude/agent-memory/product-owner/MEMORY.md
+++ b/.claude/agent-memory/product-owner/MEMORY.md
@@ -46,7 +46,7 @@ Full detail in [standalone-bugs-and-stories.md](standalone-bugs-and-stories.md)
- **PR #2004 (#1888 + #1910)** — **APPROVED, M1 closed round 5** (`6a3eb7ec`, 2026-08-05): `lang={lang}` on the `` at `ReportContentEditor.tsx:245`, verified by local mutation + revert (`"de"`→`null`). Also mutation-checked the unrequested `uiLang` wiring test — **a test guarding an _optional_ prop needs its own mutation check, since its failure mode stays type-legal and green everywhere else**. Exhaustiveness re-check found nothing further; stating the round-4 enumeration as exhaustive is what turned round 5 into a one-item confirmation. Earlier: **APPROVED round 4** (`03a30990`): mechanism (b) taken verbatim (wrapper tags restored + `EditableField.uiLang` on reset button/sr-only hint). All 5 canonical #1910 ACs met; one Medium MUST FIX left non-blocking (**column-toggle labels** render `content.labels.*` untagged — `.columnToggleGroup` is a _sibling_ of `.tableWrapper`; fix on the ``, not `.columnToggles`). Capped at Medium because **I missed it in my own round-2 and round-3 enumerations** — a finding's severity is capped by my own enumeration failure. Ended the mirror-image cycle by **stating the enumeration as exhaustive**. Told them explicitly NOT to remove the now-redundant `` (E2E 25/26/27 target it). Round-2 H2 mirror: stale _comment_ over fresh `expect` = flag, not block. #1888 re-verified by diffing its file against the accepted commit (empty). Coordinator AC numbering wrong a **5th** time. Prior rounds: #1910 REJECTED **three times**: round 1 on AC3 (`lang` blanket-tagged on `.container`, only ``s counter-tagged → `EditableField` chrome announced in report language, correct before the PR); round 2 on the "Option A surgical tagging" fix (`64c07b8a`) — **H2 AC5 red** (E2E Scenario 25 still asserts `container` lang, only the _comment_ was updated; Shard 2/16 test #168 confirms), **H3 AC1 regression** (`coverLetter.dateLine`/`closing` report-language spans lost coverage when the blanket tag narrowed), **H4 AC3 residue** (`.tableWrapper`/`.mobileCardList` still enclose the reset button + sr-only edited hint). Patterns: **AC enumerating element classes → tick each one off**; **override-by-inheritance fix → "what else inherits from the node you tagged?"**; **behaviour-inverting fix → grep for the OLD assertion, not the old comment**; **blanket→surgical refactor → audit what the blanket was silently covering**; **price intrinsic tensions differently from oversights** (offered a documented deviation for the one-element-one-lang `aria-label` conflict). Round 3 (`04e4ae0c`): H2/H3/H4/M1/L1 all verified fixed (**shard-diff across the PR's own commits** is the cheap proof — Shard 2/16 red→green), rejected again on **H5**: emptying `.tableWrapper`/`.mobileCardList` and reconciling only `` leaves the desktop ` ` and the **entire** `.mobileCardList` untagged, and CSS hides `.table` at ≤767px → **zero `lang` on the mobile viewport**, AC1 unmet for a whole viewport + net regression vs the prior commit. Lessons: **when demanding a tag be removed, name the replacement coverage in the same breath**; **retract your own misread takeaway** (round 2's `uiLang` deletion was not a ruling against targeted counter-tagging); `npm run lint` has **no Prettier** and CI has no `format:check`, so formatting drift merges silently; `gh pr review` can't request changes on a human-authored PR → verdict goes in a comment. Detail in [bank-report-wizard.md](bank-report-wizard.md) §§"PR #2004 review" + "round 2" + "round 3".
- **Bank Report Wizard mini-epic** (no parent epic) — all rulings, contract facts, per-PR review outcomes and filed follow-ups in [bank-report-wizard.md](bank-report-wizard.md). Shipped: #1876→#1877→#1878→#1879, Round 2 #1898–#1901, Round 3 #1929–#1933 (all merged; #1929 took 4 rounds, #1925 closed as duplicate). **Open**: #1888 indicator, #1891 (2 wiki MUST FIX), #1895→#1896/#1897 claim close-out, #1910 `lang` attr, #1917 consolidated follow-ups (incl. `KI` glossary entry + `computeIncludedTotal` extraction), #1937/#1938 PDF header bugs, #1939 geometry hygiene, #1940/#1941/#1950, #1946 in-flight AI generation (Must Have), #1947 `useReducer`, #1952/#1953, #1965–#1972 (PR #1959 sweep), **#1973** column visibility (Should Have, Todo, blocked-by #1965). #1931 merged but **not Done** — ACs 3.2/3.3 need live-LLM UAT.
- **Reusable rulings from this cluster** (detail in [bank-report-wizard.md](bank-report-wizard.md), patterns in [pr-review-patterns.md](pr-review-patterns.md)): **merge is a code gate, Done is an acceptance gate** (unverifiable AC _with_ a substitute assertion = documented deviation; _without_ one → UAT, reopen on failure); **a finding that defeats the PR's own AC belongs in that PR, not a follow-up**; **closed/released ACs get a dated supersession comment, never a rewrite**; **ACs that misdescribe reality fail correct implementations at UAT** (seen 3×: #1943 AC4, #1933 AC2.1/2.7, my own #1925/#1932 transcription); **comment keeps the rationale, issue owns the guard**; bounded-and-quantified earns a tracked owner, unbounded-and-estimated gets documentation only.
-- **#1973 column visibility wired through to the PDF** (user-story, Should Have, Todo, 2026-08-03, **blocked-by #1965**) — user reversed #1959's preview-only hint. **My proposed "at least one of Vendor/Invoice #" floor was rejected as an invented compliance rule**; only Allocated Amount is mandatory (survived because its justification is _structural_ — summary amounts + #1959 inline labels live in that cell — not purposive). 96 legal subsets (overview 2^6=64, claim 2^5=32), floor 1 column. Rulings: legend stays **unconditional** (AC 6.1 forbids `if invoiceAmount hidden`) because `(less deposit)` was insufficient regardless of adjacency; base set **IS** the ceiling for a **data** reason (`status: isOverview ? status : null`) — corrected the coordinator's "arbitrary means no ceiling" reading; per-session state, not `useColumnPreferences`; narrower-than-page table when neither Usage nor Vendor visible. **#1966 CLOSED as superseded** (board Wont-Do) — its AC1 would pass while the PDF still contained every column. Recommended **after** the #1958 promotion. **Rev 3 (spec reconciliation)**: adopted the dev-team-lead's **three-tier summary-label fallback** over my "same cell" ruling (92 subsets last-leading-column → Invoice Amount → separate block beneath table for 4; tier 3 _increases_ preview parity, `ReportContentEditor.tsx:442-445`); added **AC 3.7 one-sided chunk-budget clamp** (650 scales _down_ never _up_ — the hazard is a future _added_ column narrowing Usage, not this change); 72 subsets = `printableWidth()`, 24 narrower (84.00–315.00pt). **Process failure: I rewrote the body but reported only the rulings, so two agents spec'd from a stale rev 1 and re-filed an already-fixed contradiction — always say "body rewritten, numbering reassigned".** Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1973 column visibility" + §"rev 3".
+- **#1973 column visibility wired through to the PDF** (user-story, Should Have, Todo, 2026-08-03, **blocked-by #1965**) — user reversed #1959's preview-only hint. **My proposed "at least one of Vendor/Invoice #" floor was rejected as an invented compliance rule**; only Allocated Amount is mandatory (survived because its justification is _structural_ — summary amounts + #1959 inline labels live in that cell — not purposive). 96 legal subsets (overview 2^6=64, claim 2^5=32), floor 1 column. Rulings: legend stays **unconditional** (AC 6.1 forbids `if invoiceAmount hidden`) because `(less deposit)` was insufficient regardless of adjacency; base set **IS** the ceiling for a **data** reason (`status: isOverview ? status : null`) — corrected the coordinator's "arbitrary means no ceiling" reading; per-session state, not `useColumnPreferences`; narrower-than-page table when neither Usage nor Vendor visible. **#1966 CLOSED as superseded** (board Wont-Do) — its AC1 would pass while the PDF still contained every column. Recommended **after** the #1958 promotion. **Rev 3 (spec reconciliation)**: adopted the dev-team-lead's **three-tier summary-label fallback** over my "same cell" ruling (92 subsets last-leading-column → Invoice Amount → separate block beneath table for 4; tier 3 _increases_ preview parity, `ReportContentEditor.tsx:442-445`); added **AC 3.7 one-sided chunk-budget clamp** (650 scales _down_ never _up_ — the hazard is a future _added_ column narrowing Usage, not this change); 72 subsets = `printableWidth()`, 24 narrower (84.00–315.00pt). **Process failure: I rewrote the body but reported only the rulings, so two agents spec'd from a stale rev 1 and re-filed an already-fixed contradiction — always say "body rewritten, numbering reassigned".** **PR #2010 APPROVED round 1** (`b5b03bec`, 2026-08-05, verdict in a comment — self-authored PR): 28/33 ACs met, 5 Medium partials (all the same shape — an AC's *second clause* dropped while the first was met: 4.1 header text, 4.2 continuation rows, 4.5 all-96 label, 3.3 vendor-absorber, 7.4 PR-body reporting), 0 functional gaps, all 16 E2E shards green. **Two AC errors were mine, corrected on the issue: AC 4.6's "92" → Tier 1 is 88 (Tier 2 = 4, folded in by 96−4); AC 5.3's "leaving and re-entering the step" over-reached R5 and would have mandated a third #1943/#1946 silent-state-loss.** **#1973 → UAT, stays In Progress** (R7 narrow table + R2 tier-3 block are visual judgments). **Follow-ups filed 2026-08-05, all Backlog + blocked-by #1973: #2011** (bug, tier-3 block not width-constrained — R7's "stretched numeric table" failure relocated below the table; **live UAT-rejection candidate, so width-rejection→#2011 vs design-rejection→reopen #1973 is stated up front**), **#2012** (tech-debt, the 3 "correct by construction, therefore untested" ACs + stale `92` in a test title), **#2013** (bug, German mixed quotes, both instances), **#2014** (tech-debt, `ContentTier` DISCARD_EDITS rule in the reducer). Standing ruling: **a green PR is not reopened to absorb non-blocking findings — file, don't expand.** Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1973 column visibility" + §"rev 3" + §"PR #2010" + §"#2010 follow-ups".
## Requirements Coverage
diff --git a/.claude/agent-memory/product-owner/bank-report-wizard.md b/.claude/agent-memory/product-owner/bank-report-wizard.md
index 301976b93..57fa090f6 100644
--- a/.claude/agent-memory/product-owner/bank-report-wizard.md
+++ b/.claude/agent-memory/product-owner/bank-report-wizard.md
@@ -762,3 +762,100 @@ record a deviation on AC1 — the one criterion whose stated measurement exists
Architect's Lows still open: `collectAllStrings` forked 3× (`:884`/`:1151`/`:3207`); helper comment header
still says *"content overflowed the page horizontally"* (the framing the ADR corrects) and cites
`src/DocumentContext.js:528` vs the ADR's `DocumentContext.js:490`.
+
+---
+
+## PR #2010 — #1973 column visibility wired through to the PDF — APPROVED round 1 (2026-08-05, `b5b03bec`)
+
+Verdict in a **comment** (PR authored by `steilerDev` = the token identity, so `gh pr review --approve`
+is rejected as self-approval — same constraint as #2004/#2008). 33 criteria across 8 groups walked
+individually: **28 satisfied, 5 Medium partials, 0 functional gaps.** All 16 E2E shards + `E2E Gates`
+green on the head SHA.
+
+**What landed well, and is worth copying into future AC design:**
+
+- **A sanity test on the enumerator itself.** `allLegalHiddenSets()` bitmasks over the free-column list;
+ a separate test asserts it yields 64/32 and never puts `allocatedAmount` in a hidden set. Without that,
+ a silently-shrunk enumerator takes every downstream 96-subset assertion with it and nothing goes red.
+ Every subset loop also asserts `checked === 96/72/24` rather than trusting the iteration.
+- **A positive control on a negative assertion.** E2E AC 5.2 fires a real PATCH via `page.evaluate()`,
+ asserts the interceptor caught it, resets the counter, *then* asserts the toggles produce zero. This is
+ the general fix for "assertions that pass on nothing" — demand it whenever an AC is "X never happens".
+- **A forced click as behavioural proof of `disabled`.** `uncheck({ force: true }).catch(() => {})` then
+ re-assert `toBeChecked()` — genuinely different from restating `toBeDisabled()`.
+- **A size-diff against the test's own baseline**, not a bare `> 1000 bytes`, as the E2E proxy for
+ "the toggle reached PDF generation". The bare-size shape is what let #1966 pass while preview-only.
+
+**Five Medium partials — all the same shape: an AC's second clause dropped while the first was met.**
+M1 AC 4.1 (cell *count* asserted, header *text in order* not). M2 AC 4.2 (continuation rows explicitly
+excluded from the 96-loop by fixture choice — the test comment says so). M3 AC 4.5 (96-loop checks
+`amountText` only, never the label — the exact half I'd flagged as most likely to be quietly unmet).
+M4 AC 3.3 (`expect(absorber).not.toBeNull()` would pass for *any* absorber; AC 3.5's loop `continue`s past
+the absorber, so a wrong one is doubly unchecked). M5 AC 7.4 (PR body still says results "will be appended").
+**Lesson: when an AC is a compound sentence, tick each clause off separately — the first clause getting a
+test is what makes the second one invisible.**
+
+**Verified by hand what the missing assertion would have covered** rather than just reporting the hole:
+worst-case vendor-as-absorber is the 6-column overview subset, `515.28 − 51.5 − 272 = 191.78pt`, never
+below the 45pt pin — so M4 is a coverage hole, not a defect, and gets capped at Medium on that basis.
+`tableOffsetsTotal(n) = n*8.5 + 0.5`; `printableWidth() = 515.28`.
+
+**Two AC-text errors were mine** (posted as a dated correction comment on #1973, body left intact):
+1. **AC 4.6's "92 subsets" is wrong — Tier 1 is 88.** 92 = 96−4, which folded Tier 2 into Tier 1. Tier 2 is
+ `{alloc, invoiceAmount}` and `{alloc, invoiceAmount, usage}` × 2 use cases = 4. 88+4+4 = 96. The tests
+ asserted the partition *behaviourally* (exact row arrays per tier) and never the count, which is why a
+ correct implementation didn't fail — **assert partitions behaviourally, not by cardinality.**
+2. **AC 5.3's "leaving and re-entering the step" over-reached R5.** R5 says the selection dies with the
+ *run*; `overrides` survive in-run step navigation, so `hiddenColumns` should too. The AC as written would
+ have mandated a *third* instance of the #1943/#1946 silent-state-loss class. Implementation resets on
+ reload + use-case change, preserves across step nav and `DISCARD_EDITS`, documents why. Corrected reading
+ published; AC satisfied under it.
+
+**R6 non-smuggling confirmed three ways** (worth reusing as a checklist for "did the fix quietly grant the
+thing I declined?"): the enabling file (`buildReportContent.ts`) is absent from the diff entirely; the data
+is structurally absent (`CLAIM_COLUMNS` has no `status`); and a test passes `hiddenColumns` containing
+`'status'` to a claim editor and still finds no checkbox.
+
+**Merge gate vs Done gate applied:** every AC is machine-checked (incl. the degenerate 1-column case through
+*real unmocked pdfmake* in `realRender.test.ts`), so nothing blocks merge. But R7's narrower-than-page table
+(84.00pt single column on a 515.28pt page) and R2's Tier-3 summary block are *visual* judgments no assertion
+can make — **#1973 → UAT, stays In Progress, not Done-on-merge. If UAT rejects either, reopen #1973; the
+ruling would be what was wrong, not the implementation.**
+
+**L2 for the user:** `de` warning renders `„Verwendung"` — German opening low-9 quote closed by ASCII `"`.
+Translator documented it as matching `selectForMergeAriaLabel`, honestly — but `de/budget.json` has exactly
+**2** `„` and **0** `“`, so the "convention" is one prior instance. Flagged, not blocked; worth a follow-up
+fixing both. **When an agent cites a codebase convention, count the instances.**
+
+### #2010 follow-ups filed (2026-08-05) — #2011 / #2012 / #2013 / #2014
+
+Coordinator ruling: **a green PR is not reopened to absorb non-blocking findings.** All four filed as
+issues, Backlog, blocked-by #1973. Reusable: this is the standing disposition for architect/PO Mediums
+raised on an already-green PR — file, don't expand.
+
+- **#2011** (`bug`, Should Have) — architect M3: tier-3 summary block laid out against `printableWidth()`
+ instead of the table's own width. `{allocatedAmount}` alone → total ~431pt from an 84pt table. **R7's
+ own "a stretched numeric table looks broken" failure mode, relocated below the table.**
+ **Why it slipped, and the rule that generalises: R7 and R2 were written separately, in different
+ revisions, against different problems — neither anticipated that the same degenerate subsets fire both.
+ When a ruling introduces a new element OUTSIDE an existing element's geometry, state its width
+ relationship to that element explicitly. Two rulings that each constrain a different element can
+ produce an unstated third outcome wherever their triggers coincide.**
+- **#2012** (`tech-debt`, Should Have) — M1+M2+M4 bundled, plus the stale `92 subsets` surviving in the
+ test title at `overviewPdf.test.ts:2026` (folded in, not filed separately — one line, same file).
+ Framed around the shared shape: **"correct by construction, therefore untested" is a claim about
+ today's code shape, and its whole value is that it stops holding silently.**
+- **#2013** (`bug`, Should Have) — German mixed quotes, **both** instances (user ruled: fix, but not on
+ that PR). AC 2 asks for a repo-wide `„`-count == `“`-count check so the mixed form can't re-accumulate.
+- **#2014** (`tech-debt`, Could Have) — state the `ContentTier` `DISCARD_EDITS` preserve-vs-discard rule
+ in `wizardReducer.ts`. Architect put the architecture in the wiki ("Multi-step wizard state: tier
+ factories") but was authorised only one production-comment edit. **Inverse of "comment keeps the
+ rationale, issue owns the guard": here the guard already exists and what's missing is the rule telling
+ the next person the guard was a decision, not an accident.**
+
+**#1973 disposition recorded on the issue:** stays **In Progress pending UAT** on merge, not Done —
+R7's narrow table and R2's tier-3 block are visual judgments. **Separated #2011 from a #1973 reopen
+explicitly, because #2011 is a live candidate for exactly that UAT rejection:** a **width** rejection is
+#2011; a **design** rejection ("the total should not have left the table at all, at any width") reopens
+#1973, because then the *ruling* was wrong. Worth reusing whenever a follow-up issue overlaps the same
+surface a parent issue is going to UAT on — say which rejection routes where, before UAT runs.
diff --git a/.claude/agent-memory/translator/MEMORY.md b/.claude/agent-memory/translator/MEMORY.md
index 87be77e38..7532a8b93 100644
--- a/.claude/agent-memory/translator/MEMORY.md
+++ b/.claude/agent-memory/translator/MEMORY.md
@@ -102,6 +102,10 @@ New `sourceReports.expand.*` (chevron-expand sub-tables for budget lines + depos
- [NBSP for inline labels](nbsp-inline-labels.md) — multi-word inline PDF labels need U+00A0, not a regular space, or pdfmake wraps mid-bracket; includes the ad-hoc real-render/pdftoppm verification recipe
- [Abschlag glossary short-form](abschlag-glossary-shortform.md) — PO-approved `Abschlag` short form recorded IN glossary.json (75pt column has no room for any qualifier); `split`'s 3 German forms deliberately un-pinned
+## Inline Label Quoting (Issue #1973, 2026-08-05)
+
+- [Inline label quoting](inline-label-quoting-1973.md) — „...\" (curly open, straight close) is the established codebase convention for naming a UI label inline in a sentence; cross-check the referenced label's own translation, don't re-translate the noun independently
+
## Audit Protocol History
- [Audit pitfalls](audit-pitfalls.md) — incident history behind the mandatory 4-step full-coverage audit protocol: a parity-only audit missed 13 code-referenced keys (Area UI raw-key bug); loose substring greps flagged 52 false positives
diff --git a/.claude/agent-memory/translator/inline-label-quoting-1973.md b/.claude/agent-memory/translator/inline-label-quoting-1973.md
new file mode 100644
index 000000000..27a1a489b
--- /dev/null
+++ b/.claude/agent-memory/translator/inline-label-quoting-1973.md
@@ -0,0 +1,14 @@
+---
+name: inline-label-quoting-1973
+description: Convention for quoting a UI column/label name inline inside a German sentence (warning/hint text)
+metadata:
+ type: project
+---
+
+When a hint/warning sentence needs to name a specific column header or UI label inline (e.g. "hiding Usage removes..."), the established `de/budget.json` precedent (`selectForMergeAriaLabel`: `"„{{description}}\" zum Zusammenführen auswählen"`) uses an opening German curly quote `„` paired with a **straight** closing quote `"` — not the typographically-correct closing `“`. This is inconsistent typography but is the codebase's existing convention; match it rather than "fixing" it to `„...“`, to avoid a gratuitous style drift in an otherwise-unrelated change.
+
+Applied in Issue #1973 (`sourceReports.editable.usageHiddenAttachmentsWarning`): named the "Usage" column inline as `„Verwendung"`, matching the exact German column-header translation at `sourceReports.table.usage` (confirmed via the EN/DE table headers, not guessed) — the warning must name a column the user can actually find, so always cross-check the referenced column's own header translation before writing the sentence, don't independently translate the noun.
+
+Also confirmed during this pass: `sourceReports.editable.columnVisibilityHint` deletion (superseded by these two new keys, AC 1.3) was already clean in `de/budget.json` before I started — zero dangling references anywhere in `client/src` — and a full flattened-key parity diff across the entire `budget` namespace (not just `sourceReports.editable`) came back 0/0 in both directions, so no pre-existing drift to report for this namespace at this time.
+
+See [[history-2026-h1]] for the general dash-substitution rule (spaced en dash `–` for English's em dash `—`) applied again here in `allocatedAmountRequiredHint`.
diff --git a/.claude/agent-memory/ux-designer/feature-spec-history.md b/.claude/agent-memory/ux-designer/feature-spec-history.md
index 651e7c792..7b55acb1f 100644
--- a/.claude/agent-memory/ux-designer/feature-spec-history.md
+++ b/.claude/agent-memory/ux-designer/feature-spec-history.md
@@ -19,10 +19,10 @@ Replaces Step 5's live PDF iframe with editable HTML (cover letter fields + repo
Four-defect bug issue on `ReportInvoiceList.tsx`/`.module.css` (Bank Report Wizard Step 3), same cluster as #1900/#1901.
- **New shared component spec'd**: `IconLinkButton` (`client/src/components/IconLinkButton/`) — icon-only link, `to`/`ariaLabel`/`icon`/`tooltip`/`newTab` props, renders react-router `Link` (supports `target="_blank"` natively — Link skips SPA interception whenever `target !== '_self'`), bakes `rel="noopener noreferrer"` in automatically when `newTab`. Justified by three pre-existing one-off external-link icon buttons (`DocumentCard.tsx` `.openInPaperlessButton`, `LinkedDocumentCard.tsx` `.openLink`, `SourceBudgetLinePanel.tsx` inline) that were never consolidated — check this component before hand-rolling a fourth.
-- **Nested-label click-forwarding footgun, confirmed by reading `TriStateCheckbox.tsx`**: it renders its own internal ` `; several list rows across the codebase then wrap it *again* in an outer `` alongside other row content (e.g. `ReportInvoiceList.tsx`'s `.checkboxWithContent` wraps `TriStateCheckbox` + `.vendorInfo`). Browsers forward any click inside that outer label's box to the associated checkbox. **Never place a new interactive affordance (link/button) inside such a label** — even with `stopPropagation`, it's fragile. Give it its own sibling grid cell/DOM position outside the label instead; that's a structural guarantee, not a defensive one. Apply this check to any future "row has a checkbox and I need to add another clickable thing" spec.
+- **Nested-label click-forwarding footgun, confirmed by reading `TriStateCheckbox.tsx`**: it renders its own internal ` `; several list rows across the codebase then wrap it _again_ in an outer `` alongside other row content (e.g. `ReportInvoiceList.tsx`'s `.checkboxWithContent` wraps `TriStateCheckbox` + `.vendorInfo`). Browsers forward any click inside that outer label's box to the associated checkbox. **Never place a new interactive affordance (link/button) inside such a label** — even with `stopPropagation`, it's fragile. Give it its own sibling grid cell/DOM position outside the label instead; that's a structural guarantee, not a defensive one. Apply this check to any future "row has a checkbox and I need to add another clickable thing" spec.
- **Touch-target technique reused, not reinvented**: `.expandButton`'s existing `padding: 10px; margin: -10px` trick (visual box stays small — 24×24 — while the hit target reaches 44×44 via negative-margin expansion into the row's own gutter/padding) is the house style for "small icon in a dense row, real touch target" in this codebase. Prefer it over a literally-44px visible box, which looks oversized next to other 16px row icons.
-- **"Mobile card" ≠ assume it exists — verify.** The issue's ACs referenced "the mobile card" for invoice rows; `ReportInvoiceList`'s top-level `.invoiceRow`/`.listHeader` turned out to be a single CSS Grid at all viewports (no separate mobile-card JSX branch — `mobileCardList`/`mobileCard` classes in this file only exist for the *nested* budget-lines/deposits sub-tables). Corrected this in the spec rather than designing a card layout that would have been an out-of-scope restructuring; touch-target sizing was applied unconditionally (not gated behind a mobile media query) since there's no separate mobile layout to gate it in.
-- **`display: flex` on a `` breaks baseline alignment with sibling cells, independent of font-size** — a column-direction flex container's baseline is *synthesized* per spec rather than derived from real text-baseline metrics, so it doesn't line up with sibling ` `s using default `vertical-align: baseline`. Fix: `vertical-align: top` on the shared `.table td` rule (kills reliance on baseline entirely) + drop `display:flex` from the desktop cell variant, replacing `gap` with `> div + div { margin-top: ... }` for plain block-stacked spacing. Where the *same class* is reused as a mobile `` (not a `
`), keep the flex+gap version but move it under the mobile media query — safe specifically when the two usages are already mutually exclusive via existing `display:none` breakpoints (verify that before assuming it's safe to fork the class by viewport).
+- **"Mobile card" ≠ assume it exists — verify.** The issue's ACs referenced "the mobile card" for invoice rows; `ReportInvoiceList`'s top-level `.invoiceRow`/`.listHeader` turned out to be a single CSS Grid at all viewports (no separate mobile-card JSX branch — `mobileCardList`/`mobileCard` classes in this file only exist for the _nested_ budget-lines/deposits sub-tables). Corrected this in the spec rather than designing a card layout that would have been an out-of-scope restructuring; touch-target sizing was applied unconditionally (not gated behind a mobile media query) since there's no separate mobile layout to gate it in.
+- **`display: flex` on a ` ` breaks baseline alignment with sibling cells, independent of font-size** — a column-direction flex container's baseline is _synthesized_ per spec rather than derived from real text-baseline metrics, so it doesn't line up with sibling ` `s using default `vertical-align: baseline`. Fix: `vertical-align: top` on the shared `.table td` rule (kills reliance on baseline entirely) + drop `display:flex` from the desktop cell variant, replacing `gap` with `> div + div { margin-top: ... }` for plain block-stacked spacing. Where the _same class_ is reused as a mobile `` (not a `
`), keep the flex+gap version but move it under the mobile media query — safe specifically when the two usages are already mutually exclusive via existing `display:none` breakpoints (verify that before assuming it's safe to fork the class by viewport).
- **Shared `grid-template-columns` string between a list header and its rows (`.listHeader`/`.invoiceRow` both defining the same 6-then-7-track template) is a drift hazard** — when one fix (adding a trailing column) and another fix (header-checkbox alignment) land in the same PR, flag explicitly that both edits target the same literal string in two places so they don't get applied against stale copies of each other.
- **`Tooltip`'s `aria-describedby` goes on a `display:contents` wrapper span, not the focusable child** — doesn't reliably get announced on focus. Not a blocker when the focusable element already carries its own `aria-label` (name comes from there, independent of Tooltip's describedby wiring) — noted as a known pre-existing gap, not something to fix per-usage.
@@ -45,16 +45,16 @@ Adds an opt-in "Enable AI assistance" toggle (Step 4) + "Generate with AI" batch
Client-only content-model cleanup on `ReportContentEditor.tsx`/`overviewPdf.ts` (mostly de-numbering footnotes + moving 2 facts from footnote-only to inline). Spec posted covering all 5 ACs.
- **"Deposit" inline label reuses `Badge`'s existing `.attachmentDeposit` variant** (`--color-attachment-deposit-bg`/`-text`, teal) rather than inventing a new badge color — the exact same wording (`sourceReports.table.attachmentType.deposit`) already exists as a Document-Type badge elsewhere in the same reports feature (`ReportInvoiceList.tsx`). When a new inline "this row is an X" label is needed, check `attachmentType`-style badges first — this app already has a full palette of document/entry-type pills before reaching for a new variant.
-- **PDF "no Badge" fallback pattern**: pdfmake has no pill primitive, so a Badge-equivalent becomes a bracketed plain-text suffix `(Deposit)` in the *same* translation key, rendered as a separate lower-weight text run (`{ text: '...', color: '#6b7280', fontSize: 8 }`) via pdfmake's array-of-runs `text` field — not a new stacked line, not a filled/colored cell (per-run `fillColor` doesn't compose reliably with a whole-cell `text` array in pdfmake tables). `#6b7280` is the PDF-baked literal equivalent of `--color-text-muted`/`--color-gray-500` (confirmed via `merge.ts`'s hardcoded `styles.small.color`); PDF exports have no dark mode by design (fixed light-background documents), so hardcoded hex is expected/correct there, unlike in `client/src/**/*.css` where it would be a stylelint violation.
+- **PDF "no Badge" fallback pattern**: pdfmake has no pill primitive, so a Badge-equivalent becomes a bracketed plain-text suffix `(Deposit)` in the _same_ translation key, rendered as a separate lower-weight text run (`{ text: '...', color: '#6b7280', fontSize: 8 }`) via pdfmake's array-of-runs `text` field — not a new stacked line, not a filled/colored cell (per-run `fillColor` doesn't compose reliably with a whole-cell `text` array in pdfmake tables). `#6b7280` is the PDF-baked literal equivalent of `--color-text-muted`/`--color-gray-500` (confirmed via `merge.ts`'s hardcoded `styles.small.color`); PDF exports have no dark mode by design (fixed light-background documents), so hardcoded hex is expected/correct there, unlike in `client/src/**/*.css` where it would be a stylelint violation.
- **Conditional block removal — no placeholder, just omit from the render tree**: for "this metadata block doesn't apply to this report type" (claim reports skip `sourceInfoBlock`), the correct spec is `{condition && ...}` (full omission), relying on the parent's existing `display:flex; gap: var(--spacing-N)` to naturally close the space — never a `display:none`-but-present placeholder or a manually tightened margin override. Same principle applies to the pdfmake side: skip the whole `content.push(...)` call rather than pushing an empty/near-empty stack.
-- **Secondary/muted metadata line under an editable field**: reused the `.dateLineLabel`/`.footnotes` muted-xs-text convention (`--font-size-xs` + `--color-text-muted`) for the new "area name" sub-line under Usage — this is the established in-file precedent for "annotation, not content" text, not a new pattern. Placed *below* the `EditableField`, never inline/parenthetical beside it, specifically because AC required it be visually distinguishable as non-editable — inline-beside-an-input reads as part of the same string.
+- **Secondary/muted metadata line under an editable field**: reused the `.dateLineLabel`/`.footnotes` muted-xs-text convention (`--font-size-xs` + `--color-text-muted`) for the new "area name" sub-line under Usage — this is the established in-file precedent for "annotation, not content" text, not a new pattern. Placed _below_ the `EditableField`, never inline/parenthetical beside it, specifically because AC required it be visually distinguishable as non-editable — inline-beside-an-input reads as part of the same string.
- **PDF stack-building refactor flagged, not just a style note**: extending `overviewPdf.ts`'s Usage cell from a 2-way ternary (`attachmentsNote ? stack : text`) to a 3-optional-line array build (usage + area + attachmentsNote) is a real code-shape change for `frontend-developer`, called out explicitly in the spec as an implementation note so it isn't missed as "just add one more line."
## Issue #1931 — Single "Enhance with AI" action (removes #1901's double opt-in)
Deletes Step 4's "Enable AI assistance" checkbox entirely; Step 5's action gates on `llmEnabled` (from `GET /api/config`) instead of the now-removed `aiEnabled`, and relabels "Generate with AI" → "Enhance with AI". Corrects course on the opt-in-toggle pattern this same memory file recorded for #1901 — that pattern is now retired for this feature.
-- **Removing a `.settingsDivider` section from the middle of a flex-column `gap`-based card is a clean no-artifact deletion** — confirmed by checking that each section's separator is a *top* border owned by that section itself (not a trailing divider owned by the section above), and spacing comes from `gap` not margin-bottom. Deleting the last child leaves nothing vestigial. This is the general check to run before flagging "orphaned divider" concerns on any `settingsCard`/`settingsDivider`-shaped removal — don't assume a spacing bug exists without tracing which element owns which border.
+- **Removing a `.settingsDivider` section from the middle of a flex-column `gap`-based card is a clean no-artifact deletion** — confirmed by checking that each section's separator is a _top_ border owned by that section itself (not a trailing divider owned by the section above), and spacing comes from `gap` not margin-bottom. Deleting the last child leaves nothing vestigial. This is the general check to run before flagging "orphaned divider" concerns on any `settingsCard`/`settingsDivider`-shaped removal — don't assume a spacing bug exists without tracing which element owns which border.
- **"Revealed by opt-in" → "always present" gating change does NOT by itself require new visual weight.** The existing `.aiGenerateRow` bordered container + `btnSecondary` (not `btnPrimary`) already read as "optional, user-initiated, not a required step" — that visual signal comes from button hierarchy/container styling, not from conditional-rendering-as-a-proxy-for-optionality. When a future story removes a gating toggle, check the button's own style tier before assuming the removal demands a prominence change.
- **A11y gap from removing an opt-in's helper text**: the deleted checkbox's helper text was the only place (for AT and sighted users alike) that pre-explained the action's overwrite behavior before first encounter. Recommended fix pattern: a static (not conditionally-swapped) visually-hidden `sharedStyles.srOnly` span + `aria-describedby` on the button, describing the destructive-of-edits behavior unconditionally — cheaper and more reliable than trying to mirror the sighted user's click-triggered confirm-modal experience for screen-reader users in advance. Reusable pattern for any "action always visible, consequence disclosed only after click via modal" case.
- **German label for "Enhance" (vs. existing "generieren"/"aktivieren" copy)**: recommended **"Mit KI verbessern"** over "Mit KI überarbeiten" — "verbessern" (improve) is the tighter semantic match for "enhance existing content" than "überarbeiten" (rework/substantially rewrite), and preserves the established verb-first "Mit KI ___" sentence shape. Anchors the translator's discretion call; formal `KI` glossary entry still pending in #1917.
@@ -170,6 +170,17 @@ Part of the Bank Report Wizard Refinement Round 2 mini-epic (follows #1898). Spe
- **Found a real responsive risk while auditing `WizardStepper` for a 5th step**: the desktop `
` stepper (`WizardStepper.module.css` `.stepper`) has no `overflow-x` handling and the mobile dot-view only activates at `max-width: 767px`. Going from 4→5 full-text-label steps pushes the natural row width past a typical ~700px tablet content area well before that 767px cutoff — an `768px`-vs-`767px` off-by-one boundary that was invisible at 4 steps but becomes real at 5. Fix specified: a `768px–1023px` tablet-range media query shrinking `.stepList` gap, `.connector` width, and `.label` font-size (not just relying on the existing mobile/desktop binary). **Any future story that adds another step to an existing `WizardStepper` instance should re-check this same boundary** — it's a per-step-count risk, not a one-time fix.
- Confirmed (via grep) `--breakpoint-*` tokens do not exist anywhere in `tokens.css` — see [token-reference.md](token-reference.md) for the permanent note; don't reference them in future specs.
+## Issue #1973 — Report column visibility: wire preview toggles through to the generated PDF
+
+Spec posted (`gh issue comment` id 5196838267) on `ReportContentEditor.tsx`/`.module.css` + `shared.module.css`, ahead of implementation. `overviewPdf.ts` geometry (AC 3.x/R7) explicitly out of scope — pdfmake content-tree work, no CSS surface, per the #1898 precedent noted above.
+
+- **New shared CSS class**: `shared.module.css` gains `.bannerWarning` (amber: `--color-warning-bg`/`--color-warning` border/`--color-warning-text-on-light`), completing the `STATUS BANNERS` family alongside pre-existing `.bannerSuccess`/`.bannerError`. This is the formal promotion flagged as a "future candidate" back in #1891 (`InvoiceDepositsSection.warningBanner` vs. `MassMoveModal.warningBlock`, both amber duplicates) — only the **plain-text tier** was promoted (`InvoiceDepositsSection`'s shape: no icon, no heading), reserving the heavier icon+heading `MassMoveModal.warningBlock` pattern for consequential/irreversible-action warnings specifically. Any future non-blocking/reversible advisory should reach for `sharedStyles.bannerWarning` + `role="status"` first, before adding a local warning banner class.
+- **`role="status"` vs `role="alert"` for warnings, disambiguated**: `role="alert"` (assertive) is reserved in this codebase for submit/validation failures (`Step5Actions.bannerError`) and irreversible-action confirmations (`MassMoveModal.warningBlock`); `role="status"` (polite) is for a live region reporting a toggle-driven **derived** state that isn't blocking anything — same bucket as `DiaryEntryForm`'s duration display. Use the "does this block the user or just inform them" test to pick between the two.
+- **Locked/disabled-checkbox explanation pattern**: reused this exact file's own `.footnoteMarker` "*N:" legend convention instead of a `title` attribute or per-item sr-only text — visible `aria-hidden` asterisk appended to the label (keeps the checkbox's accessible _name_ clean) + one shared legend paragraph below the toggle row, referenced via `aria-describedby`. Disabled-state token values (`opacity: 0.6; cursor: not-allowed;`) reused verbatim from `ReportWizardPage.module.css`'s own `.optionCheckbox:disabled` pair — same page family, not a new convention. Reusable pattern for any future "this one option in a multi-select list can't be turned off, and needs a _visible_, not just hover/title, reason."
+- **Deleting a group-level hint paragraph and replacing it with a narrower-scoped one**: confirmed the "clean no-artifact deletion, rely on parent flex `gap`" principle again (same as #1931) — and additionally confirmed the _vacated slot_ can be reused by an unrelated new element without any layout risk, since nothing sizes off the deleted element's specific height/content, only the flex-column `gap`.
+- **Mobile-exposure finding methodology**: before writing "document why mobile is excluded" into an E2E AC, actually trace whether the control in question has _any_ `@media` rule hiding it — `ReportContentEditor`'s desktop/mobile split only ever toggles `.table` vs. `.mobileCardList`, never the toggle-group chrome above both. A control that gates both responsive render branches identically is exposed at every viewport by construction; don't assume "there's a mobile card list" implies "desktop-only chrome," verify per-element.
+- **Pre-existing touch-target gap turned real**: `.columnToggle` chips had no `min-height` while purely preview-only (informational, low-stakes to miss-tap); now that the same control changes PDF content, flagged and fixed via the Style Guide's canonical `@media (max-width: 1024px) { min-height: 44px; }` rather than leaving it as a "pre-existing gap, don't block" item — the stakes of the control changed, so the bar for its touch target should too.
+
## Issue #1932 — Cover letter overhaul: layout gate (AC 4.1–4.5 + folded-in #1925 date-caption ACs 6.1/6.2)
User ruled out markdown/rich-text/new deps for the body ("no full wysiwyg necessary — just a simple text body with line breaks"); this narrowed my scope to pure layout (spacing/alignment/typography hierarchy), a paragraph-spacing call, the signature-block empty-state behavior, and the date/closing-caption language-mixing fix. Spec posted as issue comment `5160362068`; implementation of §§2/3/5 (signature field, sender-from-user, prompt guard, reset-button fix) was **already landed in the worktree ahead of this gate** — checked `git status`/`git diff` directly rather than assuming, and wrote the spec to build on that state rather than duplicate it.
@@ -177,4 +188,4 @@ User ruled out markdown/rich-text/new deps for the body ("no full wysiwyg necess
- **PDF block order was already correct** (sender→recipient→date→reference→subject→body→signature matches AC 4.1's required order) — the fix was hierarchy (varied margins: 4/32/20/4/16/32/54pt, `alignment:'right'` on the date, one new bold pdfmake style `letterSubject`), not reordering. Don't assume "not laid out as a business letter" ACs always require restructuring the block sequence — check the existing order against the AC's stated order first.
- **Paragraph spacing decision: literal blank-line rendering, no typographic paragraph-spacing model.** Rejected splitting the body into per-paragraph pdfmake nodes with their own margins — that would depart from the "one text node with `\n`" approach the PO's premise correction already validated as working, for no real visual gain (one rendered line is already ~18pt, a perfectly reasonable paragraph gap — see the empirical measurement in [pdfmake-rendering-verification.md](pdfmake-rendering-verification.md)).
- **Date/closing caption language-mixing (#1925 fold-in): resolved by restyling as chrome, not by translating the caption through `reportT`.** Every sibling caption in the same panel (Sender/Recipient/Subject/Body/Signature labels) is `EditableField`'s ``, always interface-language — translating only the date/closing captions into report language would make them the one inconsistent caption in the panel, violating the very "consistent with other captions" AC it's supposed to satisfy. Fix instead: restructure the inline `"Label: value"` row into label-above-value (matching `EditableField`'s `` recipe exactly — same font-size/weight/color), generalizing the one-off `.dateLineField` into a reusable `.readOnlyField`/`.readOnlyLabel`/`.readOnlyValue` recipe shared by both the date row and the new closing row. Reusable takeaway: when a UI-panel AC complains about "one line mixing languages," check whether the actual bug is inline concatenation (fixable by restructuring to two visually-separate lines) before reaching for a translation fix that would break consistency with sibling captions.
-- **New i18n key discipline**: checked the actual worktree i18n JSON directly rather than trusting the task prompt's "beyond the closing key already planned" framing at face value — found `closing` and `signatureLabel` were *both* already added, and only `sourceReports.editable.closingLabel` (the new read-only row's caption) was still missing. Always verify "what's already there" against the live file, not against what a hand-off comment implies.
+- **New i18n key discipline**: checked the actual worktree i18n JSON directly rather than trusting the task prompt's "beyond the closing key already planned" framing at face value — found `closing` and `signatureLabel` were _both_ already added, and only `sourceReports.editable.closingLabel` (the new read-only row's caption) was still missing. Always verify "what's already there" against the live file, not against what a hand-off comment implies.
diff --git a/client/src/components/reports/ReportContentEditor.module.css b/client/src/components/reports/ReportContentEditor.module.css
index 2acfe7114..46f76d93c 100644
--- a/client/src/components/reports/ReportContentEditor.module.css
+++ b/client/src/components/reports/ReportContentEditor.module.css
@@ -90,14 +90,6 @@
margin-bottom: var(--spacing-3);
}
-/* Preview-only disclaimer for the column toggles — these do not affect the generated PDF. */
-.columnToggleHint {
- margin: 0;
- font-size: var(--font-size-xs);
- color: var(--color-text-muted);
- font-style: italic;
-}
-
.columnToggles {
display: flex;
flex-wrap: wrap;
@@ -114,6 +106,29 @@
white-space: nowrap;
}
+.requiredMarker {
+ color: var(--color-text-muted);
+ font-weight: var(--font-weight-semibold);
+}
+
+.columnToggleRequiredHint {
+ margin: 0;
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ font-style: italic;
+}
+
+.columnToggle:has(input:disabled) {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+@media (max-width: 1024px) {
+ .columnToggle {
+ min-height: 44px;
+ }
+}
+
/* Table Wrapper (Desktop) */
.tableWrapper {
overflow-x: auto;
diff --git a/client/src/components/reports/ReportContentEditor.test.tsx b/client/src/components/reports/ReportContentEditor.test.tsx
index 778a54068..691f2224b 100644
--- a/client/src/components/reports/ReportContentEditor.test.tsx
+++ b/client/src/components/reports/ReportContentEditor.test.tsx
@@ -80,7 +80,9 @@ import type {
ReportContent,
ReportContentRow,
ReportContentLabels,
+ ReportColumnKey,
} from '../../lib/reportContent/index.js';
+import { reportColumnsForUseCase } from '../../lib/reportContent/index.js';
import { ReportContentEditor } from './ReportContentEditor.js';
import styles from './ReportContentEditor.module.css';
@@ -172,17 +174,21 @@ function getMobileList(container: HTMLElement): HTMLElement {
function renderEditor(overridesProp: Partial[0]> = {}) {
const onFieldChange = jest.fn();
const onFieldReset = jest.fn();
+ const onToggleColumn = jest.fn();
const utils = render(
,
);
- return { ...utils, onFieldChange, onFieldReset };
+ return { ...utils, onFieldChange, onFieldReset, onToggleColumn };
}
// A fully-populated ReportContent (cover letter + one row with a non-null attachmentsNote) used by
@@ -351,6 +357,9 @@ describe('ReportContentEditor — cover letter card', () => {
overrides={{}}
onFieldChange={jest.fn()}
onFieldReset={jest.fn()}
+ hiddenColumns={new Set()}
+ onToggleColumn={jest.fn()}
+ attachDocuments={false}
t={t}
/>,
);
@@ -1122,30 +1131,32 @@ describe('ReportContentEditor — #1959 isSplit / isDepositReduced inline labels
});
});
-describe('ReportContentEditor — #1959 column visibility toggles (local state, no persistence)', () => {
+describe('ReportContentEditor — #1973 column visibility toggles (controlled: hiddenColumns/onToggleColumn props)', () => {
function getToggleGroup(container: HTMLElement): HTMLElement {
return within(container).getByRole('group', {
name: 'sourceReports.editable.columnVisibilityLabel',
});
}
- it('renders one checked checkbox per column, labeled from content.labels.*, with a Status toggle only for overview reports', () => {
+ it('renders one checked checkbox per column when hiddenColumns is empty, labeled from content.labels.*, with a Status toggle only for overview reports', () => {
const { container } = renderEditor({ content: makeContent({ isOverview: false }) });
const group = within(getToggleGroup(container));
const boxes = group.getAllByRole('checkbox');
expect(boxes).toHaveLength(6); // vendor, invoiceNumber, date, invoiceAmount, allocatedAmount, usage
for (const box of boxes) expect(box).toBeChecked();
- // Labels come from content.labels.* (report language), never a chrome t() echo.
+ // Labels come from content.labels.* (report language), never a chrome t() echo. Allocated
+ // Amount is excluded from this loop — its label carries a trailing " *" required marker (see
+ // the dedicated AC2.2 test below), so an exact-match getByLabelText would not find it here.
for (const label of [
LABELS.vendor,
LABELS.invoiceNumber,
LABELS.date,
LABELS.invoiceAmount,
- LABELS.allocatedAmount,
LABELS.usage,
]) {
expect(group.getByLabelText(label)).toBeChecked();
}
+ expect(group.getByLabelText(LABELS.allocatedAmount, { exact: false })).toBeChecked();
expect(group.queryByLabelText(LABELS.status)).not.toBeInTheDocument();
});
@@ -1156,6 +1167,95 @@ describe('ReportContentEditor — #1959 column visibility toggles (local state,
expect(group.getByLabelText(LABELS.status)).toBeChecked();
});
+ // (AC 2.5, carried from #1966 AC2) The rendered checkbox ORDER — not just its length — must
+ // equal reportColumnsForUseCase(isOverview) exactly, for both use cases, independent of
+ // hiddenColumns' contents (hiding a column removes its th/td from the table, never its own
+ // toggle). A count-only assertion previously passed against a hand-typed array literal that had
+ // silently drifted from this same derivation (AC 2.1 violation, since fixed) — a future reorder,
+ // or a swapped column that happened to keep the same length, would have passed it too. Order
+ // matters here beyond cosmetics: the PDF's header row is built from this identical array.
+ it.each([
+ ['overview', true, 7],
+ ['claim/proof-of-funds', false, 6],
+ ] as const)(
+ '(AC2.5, scenario 27) %s: rendered checkbox order exactly equals reportColumnsForUseCase(isOverview) (%i columns), even with some columns hidden',
+ (_label, isOverview, expectedCount) => {
+ expect(reportColumnsForUseCase(isOverview)).toHaveLength(expectedCount);
+ const { container } = renderEditor({
+ content: makeContent({ isOverview }),
+ hiddenColumns: new Set(['vendor']),
+ });
+ const boxes = within(getToggleGroup(container)).getAllByRole('checkbox');
+ expect(boxes).toHaveLength(expectedCount);
+
+ // Guard the guard: if data-column-key were ever dropped from the markup, every entry below
+ // would read null. reportColumnsForUseCase(isOverview) is never empty (6 or 7 real keys), so
+ // that failure mode is NOT the "[] equals []" vacuous-pass shape — a null-filled array can
+ // never toEqual a string-keyed one — but asserting non-null explicitly here means the test
+ // fails with an immediate "attribute missing" signal rather than a confusing array diff.
+ const renderedOrder = boxes.map((box) => box.getAttribute('data-column-key'));
+ for (const key of renderedOrder) {
+ expect(key).not.toBeNull();
+ }
+ expect(renderedOrder).toEqual(reportColumnsForUseCase(isOverview));
+ },
+ );
+
+ it('(AC2.6, scenario 28) claim/proof-of-funds NEVER renders a Status checkbox, at any hiddenColumns value — including one that names status explicitly', () => {
+ for (const hiddenColumns of [
+ new Set(),
+ new Set(['status']),
+ new Set(['vendor', 'usage']),
+ ]) {
+ const { container, unmount } = renderEditor({
+ content: makeContent({ isOverview: false }),
+ hiddenColumns,
+ });
+ expect(
+ within(getToggleGroup(container)).queryByLabelText(LABELS.status),
+ ).not.toBeInTheDocument();
+ unmount();
+ }
+ });
+
+ it('(AC2.2, scenario 25) the Allocated Amount checkbox is disabled and has a non-empty accessible description', () => {
+ const { container } = renderEditor();
+ const box = within(getToggleGroup(container)).getByLabelText(LABELS.allocatedAmount, {
+ exact: false,
+ });
+ expect(box).toBeDisabled();
+ const describedBy = box.getAttribute('aria-describedby');
+ expect(describedBy).toBeTruthy();
+ const descriptionEl = container.querySelector(`#${describedBy}`);
+ expect(descriptionEl).not.toBeNull();
+ expect(descriptionEl!.textContent!.trim().length).toBeGreaterThan(0);
+ });
+
+ it('(AC2.3, scenario 26) every OTHER column checkbox is enabled, and hiding every one of them at once leaves the desktop table with exactly one (Allocated Amount) column', () => {
+ const { container } = renderEditor({ content: makeContent({ isOverview: true }) });
+ const group = within(getToggleGroup(container));
+ for (const box of group.getAllByRole('checkbox')) {
+ if (box.getAttribute('aria-describedby')) continue; // the locked allocatedAmount checkbox
+ expect(box).not.toBeDisabled();
+ }
+
+ const everyHideable = new Set([
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'usage',
+ ]);
+ const { container: hiddenContainer } = renderEditor({
+ content: makeContent({ isOverview: true }),
+ hiddenColumns: everyHideable,
+ });
+ const table = getDesktopTable(hiddenContainer);
+ expect(table.querySelectorAll('thead th')).toHaveLength(1);
+ expect(within(table).getByText(LABELS.allocatedAmount, { selector: 'th' })).toBeInTheDocument();
+ });
+
// Every column gets a DISTINCT value so a disappearing cell can be attributed to the toggled
// column and not shadowed by an identical string elsewhere in the table (the fixture's default
// invoice/allocated/summary amounts are all €100.00).
@@ -1176,26 +1276,40 @@ describe('ReportContentEditor — #1959 column visibility toggles (local state,
});
}
+ // allocatedAmount is deliberately excluded here — it is the locked column and can never be
+ // hidden by ANY hiddenColumns value (see columns.test.ts's "defense-in-depth" coverage and the
+ // AC2.2 disabled-checkbox test above), so a "hiding it removes the column" case would assert a
+ // state the production code makes unreachable.
it.each([
- [LABELS.vendor, 'ACME'],
- [LABELS.invoiceNumber, 'INV-001'],
- [LABELS.date, '01/10/2026'],
- [LABELS.invoiceAmount, '€111.00'],
- [LABELS.allocatedAmount, '€222.00'],
- ])(
- 'unchecking the "%s" toggle removes that column header AND its cell value from the desktop table and the mobile card',
- (label, cellValue) => {
- const { container } = renderEditor({ content: distinctValueContent() });
+ [LABELS.vendor, 'ACME', 'vendor'],
+ [LABELS.invoiceNumber, 'INV-001', 'invoiceNumber'],
+ [LABELS.date, '01/10/2026', 'date'],
+ [LABELS.invoiceAmount, '€111.00', 'invoiceAmount'],
+ ] as const)(
+ 'a hiddenColumns prop containing "%s" removes that column header AND its cell value from the desktop table and the mobile card',
+ (label, cellValue, columnKey) => {
+ const { container, rerender } = renderEditor({ content: distinctValueContent() });
const table = getDesktopTable(container);
const mobileList = getMobileList(container);
const headerCountBefore = table.querySelectorAll('thead th').length;
- // Positive: header label and cell value are both present before the toggle.
+ // Positive: header label and cell value are both present with nothing hidden.
expect(within(table).getByText(label, { selector: 'th' })).toBeInTheDocument();
expect(within(table).getByText(cellValue)).toBeInTheDocument();
expect(within(mobileList).getByText(cellValue)).toBeInTheDocument();
- fireEvent.click(within(getToggleGroup(container)).getByLabelText(label));
+ rerender(
+ ([columnKey])}
+ onToggleColumn={jest.fn()}
+ attachDocuments={false}
+ t={t}
+ />,
+ );
// ...and both are gone afterwards, in BOTH responsive trees.
expect(within(table).queryByText(label, { selector: 'th' })).not.toBeInTheDocument();
@@ -1206,43 +1320,37 @@ describe('ReportContentEditor — #1959 column visibility toggles (local state,
// The toggle itself stays visible (so the column can be restored) and reflects hidden state.
const box = within(getToggleGroup(container)).getByLabelText(label);
expect(box).not.toBeChecked();
- // Re-checking restores the column — the state is a real toggle, not a one-way hide.
- fireEvent.click(box);
- expect(within(table).getByText(label, { selector: 'th' })).toBeInTheDocument();
- expect(within(table).getByText(cellValue)).toBeInTheDocument();
},
);
- it('unchecking the Usage toggle removes the usage EditableField (and its inline meta text) entirely', () => {
+ it('unchecking the Usage toggle (hiddenColumns={usage}) removes the usage EditableField (and its inline meta text) entirely', () => {
const rows = [
makeRow({ invoiceId: 'inv-1', usageText: 'Kitchen work', areaText: 'Ground Floor' }),
];
- const { container } = renderEditor({ content: makeContent({ rows }) });
- expect(screen.getAllByDisplayValue('Kitchen work').length).toBeGreaterThan(0);
- expect(container.querySelectorAll(`.${styles.usageMetaText}`).length).toBeGreaterThan(0);
-
- fireEvent.click(within(getToggleGroup(container)).getByLabelText(LABELS.usage));
-
+ const { container } = renderEditor({
+ content: makeContent({ rows }),
+ hiddenColumns: new Set(['usage']),
+ });
expect(screen.queryAllByDisplayValue('Kitchen work')).toHaveLength(0);
expect(container.querySelectorAll(`.${styles.usageMetaText}`)).toHaveLength(0);
});
- it('unchecking the Status toggle removes the status Badge from an overview report', () => {
+ it('unchecking the Status toggle (hiddenColumns={status}) removes the status Badge from an overview report', () => {
const rows = [makeRow({ invoiceId: 'inv-1', status: 'paid', statusText: 'REPORT_PAID_TEXT' })];
- const { container } = renderEditor({ content: makeContent({ isOverview: true, rows }) });
- expect(screen.getAllByText('REPORT_PAID_TEXT').length).toBeGreaterThan(0);
-
- fireEvent.click(within(getToggleGroup(container)).getByLabelText(LABELS.status));
-
+ const { container } = renderEditor({
+ content: makeContent({ isOverview: true, rows }),
+ hiddenColumns: new Set(['status']),
+ });
expect(screen.queryAllByText('REPORT_PAID_TEXT')).toHaveLength(0);
expect(
within(getDesktopTable(container)).queryByText(LABELS.status, { selector: 'th' }),
).not.toBeInTheDocument();
});
- it('hides only the toggled column, leaving the others rendered (toggles are independent)', () => {
- const { container } = renderEditor();
- fireEvent.click(within(getToggleGroup(container)).getByLabelText(LABELS.vendor));
+ it('hides only the named column, leaving the others rendered (each toggle is independent)', () => {
+ const { container } = renderEditor({
+ hiddenColumns: new Set(['vendor']),
+ });
const table = getDesktopTable(container);
expect(within(table).queryByText('ACME')).not.toBeInTheDocument();
// Every other column's value survives.
@@ -1251,14 +1359,52 @@ describe('ReportContentEditor — #1959 column visibility toggles (local state,
expect(within(table).getByDisplayValue('Baseline usage')).toBeInTheDocument();
});
- it('does not invoke onFieldChange/onFieldReset when a column is toggled (visibility is local state, never an override)', () => {
- const { container, onFieldChange, onFieldReset } = renderEditor();
- for (const box of within(getToggleGroup(container)).getAllByRole('checkbox')) {
- fireEvent.click(box);
- }
+ it('(scenario 29) clicking a checkbox calls onToggleColumn with the exact column key, and NEVER onFieldChange/onFieldReset — visibility reaches its own callback, never the override callbacks', () => {
+ const { container, onFieldChange, onFieldReset, onToggleColumn } = renderEditor({
+ content: makeContent({ isOverview: true }),
+ });
+ fireEvent.click(within(getToggleGroup(container)).getByLabelText(LABELS.vendor));
+ expect(onToggleColumn).toHaveBeenCalledTimes(1);
+ expect(onToggleColumn).toHaveBeenCalledWith('vendor');
+
+ fireEvent.click(within(getToggleGroup(container)).getByLabelText(LABELS.usage));
+ expect(onToggleColumn).toHaveBeenCalledTimes(2);
+ expect(onToggleColumn).toHaveBeenLastCalledWith('usage');
+
expect(onFieldChange).not.toHaveBeenCalled();
expect(onFieldReset).not.toHaveBeenCalled();
});
+
+ it('(AC6.2, scenario 30) the warning banner renders with role="status" and the usageHiddenAttachmentsWarning key ONLY when Usage is hidden AND attachDocuments is on — absent in the other three combinations', () => {
+ const matrix: [boolean, boolean, boolean][] = [
+ // [usageHidden, attachDocuments, expectBanner]
+ [false, false, false],
+ [false, true, false],
+ [true, false, false],
+ [true, true, true],
+ ];
+ for (const [usageHidden, attachDocuments, expectBanner] of matrix) {
+ const { container, unmount } = renderEditor({
+ hiddenColumns: usageHidden
+ ? new Set(['usage'])
+ : new Set(),
+ attachDocuments,
+ });
+ const banner = within(container).queryByRole('status');
+ if (expectBanner) {
+ expect(banner).not.toBeNull();
+ expect(banner!.textContent).toBe('sourceReports.editable.usageHiddenAttachmentsWarning');
+ } else {
+ expect(banner).toBeNull();
+ }
+ unmount();
+ }
+ });
+
+ it('(AC1.3/AC1.4, scenario 31) columnVisibilityHint is no longer rendered anywhere', () => {
+ renderEditor();
+ expect(screen.queryByText(/columnVisibilityHint/)).not.toBeInTheDocument();
+ });
});
describe('ReportContentEditor — areaText / attachmentsNote inline meta (#1959: one grey element inside the usage cell)', () => {
diff --git a/client/src/components/reports/ReportContentEditor.tsx b/client/src/components/reports/ReportContentEditor.tsx
index 78643ee91..5341c15a8 100644
--- a/client/src/components/reports/ReportContentEditor.tsx
+++ b/client/src/components/reports/ReportContentEditor.tsx
@@ -3,13 +3,23 @@
* Handles field changes and resets via callbacks; no state management.
*/
-import { useId, useState } from 'react';
+import { useId, useMemo } from 'react';
import type { TFunction } from 'i18next';
import type { InvoiceStatus } from '@cornerstone/shared';
-import type { ReportContent, ReportContentOverrides } from '../../lib/reportContent/index.js';
-import { overrideKey } from '../../lib/reportContent/index.js';
+import type {
+ ReportColumnKey,
+ ReportContent,
+ ReportContentOverrides,
+} from '../../lib/reportContent/index.js';
+import {
+ isColumnLocked,
+ overrideKey,
+ reportColumnsForUseCase,
+ visibleReportColumns,
+} from '../../lib/reportContent/index.js';
import { Badge } from '../Badge/Badge.js';
import { EditableField } from '../EditableField/EditableField.js';
+import sharedStyles from '../../styles/shared.module.css';
import styles from './ReportContentEditor.module.css';
export interface ReportContentEditorProps {
@@ -17,6 +27,13 @@ export interface ReportContentEditorProps {
overrides: ReportContentOverrides;
onFieldChange: (key: string, value: string) => void;
onFieldReset: (key: string) => void;
+ /** Columns the user has hidden from the preview and generated PDF (#1973 AC 1.1: this
+ * component holds no column state of its own — it is fully controlled by the parent). */
+ hiddenColumns: ReadonlySet;
+ onToggleColumn: (col: ReportColumnKey) => void;
+ /** Whether the wizard's "attach source documents" setting is enabled — drives the
+ * Usage-hidden-with-attachments warning banner (AC 3/#1973 UX spec §2). */
+ attachDocuments: boolean;
t: TFunction;
/** HTML lang attribute for report-language content. Omit when report language matches UI language. */
lang?: string;
@@ -32,14 +49,16 @@ const STATUS_BADGE_CLASSNAME: Record = {
quotation: styles.statusQuotation!,
};
-type ColumnKey =
- 'vendor' | 'invoiceNumber' | 'date' | 'status' | 'invoiceAmount' | 'allocatedAmount' | 'usage';
+type ColumnKey = ReportColumnKey;
export function ReportContentEditor({
content,
overrides,
onFieldChange,
onFieldReset,
+ hiddenColumns,
+ onToggleColumn,
+ attachDocuments,
t,
lang,
uiLang,
@@ -47,22 +66,29 @@ export function ReportContentEditor({
// Helper: check if a field has been overridden
const isFieldEdited = (key: string): boolean => key in overrides;
- // Column visibility state. PREVIEW-ONLY: `hiddenColumns` is local to this component and is not
- // exposed as a prop or callback — the generated PDF always contains every column. The hint
- // rendered beside the toggles says so, because the control otherwise reads as "choose the
- // report's columns" (every other control in this editor does change the PDF). Wiring these
- // through to the PDF is a filed follow-up.
- const columnHintId = useId();
- const [hiddenColumns, setHiddenColumns] = useState>(new Set());
- const toggleColumn = (col: ColumnKey) => {
- setHiddenColumns((prev) => {
- const next = new Set(prev);
- if (next.has(col)) next.delete(col);
- else next.add(col);
- return next;
- });
+ // Visible columns (AC 2.1's single derivation, shared with the PDF geometry engine) —
+ // hiddenColumns/onToggleColumn are fully controlled by the parent (ReportWizardPage), which is
+ // what makes this control actually change the generated PDF instead of being preview-only.
+ const visible = useMemo(
+ () => new Set(visibleReportColumns(content.isOverview, hiddenColumns)),
+ [content.isOverview, hiddenColumns],
+ );
+ const show = (col: ColumnKey) => visible.has(col);
+ const requiredHintId = useId();
+
+ // Label lookup for the column-toggle list — mirrors overviewPdf.ts's HEADER_LABEL pattern, so
+ // the toggle list's column ENUMERATION comes from reportColumnsForUseCase (AC 2.1's single
+ // derivation, shared with the PDF geometry engine) rather than a second, independently
+ // maintained array literal.
+ const COLUMN_LABEL: Record = {
+ vendor: content.labels.vendor,
+ invoiceNumber: content.labels.invoiceNumber,
+ date: content.labels.date,
+ status: content.labels.status,
+ invoiceAmount: content.labels.invoiceAmount,
+ allocatedAmount: content.labels.allocatedAmount,
+ usage: content.labels.usage,
};
- const show = (col: ColumnKey) => !hiddenColumns.has(col);
return (
@@ -220,36 +246,41 @@ export function ReportContentEditor({
{t('sourceReports.editable.tableHeading')}
-
- {t('sourceReports.editable.columnVisibilityHint')}
-
- {(
- [
- ['vendor', content.labels.vendor],
- ['invoiceNumber', content.labels.invoiceNumber],
- ['date', content.labels.date],
- ...(content.isOverview
- ? [['status', content.labels.status] as [ColumnKey, string]]
- : []),
- ['invoiceAmount', content.labels.invoiceAmount],
- ['allocatedAmount', content.labels.allocatedAmount],
- ['usage', content.labels.usage],
- ] as [ColumnKey, string][]
- ).map(([col, label]) => (
+ {reportColumnsForUseCase(content.isOverview).map((col) => (
- toggleColumn(col)} />
- {label}
+ onToggleColumn(col)}
+ data-column-key={col}
+ />
+ {COLUMN_LABEL[col]}
+ {isColumnLocked(col) && (
+
+ {' '}
+ *
+
+ )}
))}
+
+ * {t('sourceReports.editable.allocatedAmountRequiredHint')}
+
+ {!show('usage') && attachDocuments && (
+
+ {t('sourceReports.editable.usageHiddenAttachmentsWarning')}
+
+ )}
diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json
index 30908b275..e0fc162e3 100644
--- a/client/src/i18n/de/budget.json
+++ b/client/src/i18n/de/budget.json
@@ -1223,7 +1223,8 @@
"coverLetterHeading": "Anschreiben",
"tableHeading": "Berichtstabelle",
"columnVisibilityLabel": "Spalten ein-/ausblenden",
- "columnVisibilityHint": "Nur Vorschau – das erzeugte PDF enthält immer alle Spalten",
+ "allocatedAmountRequiredHint": "Erforderlich – jeder Bericht enthält den zugeordneten Betrag.",
+ "usageHiddenAttachmentsWarning": "Anhänge sind aktiviert, aber wenn Sie „Verwendung\" ausblenden, fehlt in der PDF-Datei der Bezug zwischen den einzelnen Zeilen und den zugehörigen Anhängen.",
"enhanceWithAi": "Mit KI verbessern",
"enhanceWithAiDescription": "Ersetzt die unten stehenden Verwendungstexte und das Anschreiben durch KI-generierte Inhalte. Vorgenommene Bearbeitungen gehen dabei verloren.",
"generating": "Generiere… ({{seconds}}s)",
diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json
index 8b9ce8341..14b1a8977 100644
--- a/client/src/i18n/en/budget.json
+++ b/client/src/i18n/en/budget.json
@@ -1223,7 +1223,8 @@
"coverLetterHeading": "Cover Letter",
"tableHeading": "Report Table",
"columnVisibilityLabel": "Show/hide columns",
- "columnVisibilityHint": "Preview only — the generated PDF always includes every column",
+ "allocatedAmountRequiredHint": "Required — every report includes the allocated amount.",
+ "usageHiddenAttachmentsWarning": "Attachments are enabled, but hiding Usage removes the reference connecting each row to its attached document(s) in the PDF.",
"enhanceWithAi": "Enhance with AI",
"enhanceWithAiDescription": "Replaces the usage descriptions and cover letter below with AI-generated content. Any edits you've made will be discarded.",
"generating": "Generating… ({{seconds}}s)",
diff --git a/client/src/lib/reportContent/columns.test.ts b/client/src/lib/reportContent/columns.test.ts
new file mode 100644
index 000000000..a5ec2c4e0
--- /dev/null
+++ b/client/src/lib/reportContent/columns.test.ts
@@ -0,0 +1,140 @@
+/**
+ * Unit tests for client/src/lib/reportContent/columns.ts (#1973 AC 2.1)
+ *
+ * This module is the SINGLE source of truth for the report table's column set, consumed by both
+ * ReportContentEditor (toggle UI) and overviewPdf.ts (PDF geometry engine). Every assertion below
+ * checks the exact array/boolean value, never just `.length` or truthiness, per the QA spec's
+ * "would this fail if the guarded thing were deleted" standard — a reorder, a stray extra column,
+ * or a locked-column leak must all be caught here.
+ */
+import { describe, it, expect } from '@jest/globals';
+import type { ReportColumnKey } from './columns.js';
+import {
+ reportColumnsForUseCase,
+ isColumnLocked,
+ visibleReportColumns,
+ REQUIRED_REPORT_COLUMN,
+} from './columns.js';
+
+const ALL_COLUMNS: ReportColumnKey[] = [
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ 'usage',
+];
+
+describe('REQUIRED_REPORT_COLUMN', () => {
+ it("is 'allocatedAmount' — R1: the only column that can never be hidden", () => {
+ expect(REQUIRED_REPORT_COLUMN).toBe('allocatedAmount');
+ });
+});
+
+describe('reportColumnsForUseCase', () => {
+ it('(scenario 1) isOverview=true returns exactly the 7 keys in canonical order, not just the right length', () => {
+ expect(reportColumnsForUseCase(true)).toEqual([
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ 'usage',
+ ]);
+ });
+
+ it('(scenario 1) isOverview=false returns exactly the 6 keys in the same relative order, minus status', () => {
+ expect(reportColumnsForUseCase(false)).toEqual([
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ 'usage',
+ ]);
+ });
+
+ it('a reorder would be caught: the overview array is NOT just a superset of the claim array in any order, it is the claim array with status re-inserted at index 3', () => {
+ const overview = reportColumnsForUseCase(true);
+ const claim = reportColumnsForUseCase(false);
+ const withoutStatus = overview.filter((c) => c !== 'status');
+ expect(withoutStatus).toEqual(claim);
+ expect(overview.indexOf('status')).toBe(3);
+ });
+});
+
+describe('isColumnLocked', () => {
+ it('(scenario 2) is true for allocatedAmount and false for every other ReportColumnKey — enumerated, not sampled', () => {
+ for (const col of ALL_COLUMNS) {
+ expect(isColumnLocked(col)).toBe(col === 'allocatedAmount');
+ }
+ // Sanity: this enumeration actually covers all 7 keys, so the loop above isn't vacuous.
+ expect(ALL_COLUMNS).toHaveLength(7);
+ });
+});
+
+describe('visibleReportColumns', () => {
+ it('(scenario 3) nothing hidden ⇒ full set, exactly equal to reportColumnsForUseCase(isOverview) — both use cases', () => {
+ expect(visibleReportColumns(true, new Set())).toEqual(reportColumnsForUseCase(true));
+ expect(visibleReportColumns(false, new Set())).toEqual(reportColumnsForUseCase(false));
+ });
+
+ it('(scenario 4) every hideable overview column hidden at once leaves exactly [allocatedAmount]', () => {
+ const hidden = new Set([
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'usage',
+ ]);
+ expect(visibleReportColumns(true, hidden)).toEqual(['allocatedAmount']);
+ });
+
+ it('(scenario 4) every hideable claim column hidden at once leaves exactly [allocatedAmount]', () => {
+ const hidden = new Set([
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'invoiceAmount',
+ 'usage',
+ ]);
+ expect(visibleReportColumns(false, hidden)).toEqual(['allocatedAmount']);
+ });
+
+ it('(scenario 5) a caller attempting to hide allocatedAmount cannot — defense-in-depth beneath the disabled checkbox', () => {
+ expect(visibleReportColumns(true, new Set(['allocatedAmount']))).toContain('allocatedAmount');
+ expect(visibleReportColumns(false, new Set(['allocatedAmount']))).toContain('allocatedAmount');
+ // Even combined with every OTHER column also hidden, allocatedAmount alone survives.
+ const hideEverything = new Set(ALL_COLUMNS);
+ expect(visibleReportColumns(true, hideEverything)).toEqual(['allocatedAmount']);
+ });
+
+ it('(scenario 6) claim/proof-of-funds (isOverview=false) never includes status — structurally, for any hiddenColumns value, including one that explicitly tries to un-hide it', () => {
+ // There is no hiddenColumns value that can produce 'status' for isOverview=false, because
+ // 'status' is absent from CLAIM_COLUMNS entirely — try the emptiest possible set (nothing
+ // hidden) and a set that "hides" an unrelated column, neither can conjure it.
+ expect(visibleReportColumns(false, new Set())).not.toContain('status');
+ expect(visibleReportColumns(false, new Set(['vendor']))).not.toContain('status');
+ expect(visibleReportColumns(false, new Set(['status']))).not.toContain('status');
+ });
+
+ it('hiding a subset preserves canonical column order (not the order columns were hidden/unhidden in)', () => {
+ const hidden = new Set(['invoiceNumber', 'usage']);
+ expect(visibleReportColumns(true, hidden)).toEqual([
+ 'vendor',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ ]);
+ });
+
+ it('hiding a column not present in the given use case (e.g. status for a claim report) is a no-op on the visible set', () => {
+ expect(visibleReportColumns(false, new Set(['status']))).toEqual(
+ reportColumnsForUseCase(false),
+ );
+ });
+});
diff --git a/client/src/lib/reportContent/columns.ts b/client/src/lib/reportContent/columns.ts
new file mode 100644
index 000000000..a47620c76
--- /dev/null
+++ b/client/src/lib/reportContent/columns.ts
@@ -0,0 +1,58 @@
+/**
+ * Report table column visibility — single source of truth for AC 2.1: both the
+ * ReportContentEditor UI (column toggles) and overviewPdf.ts's geometry engine consume this
+ * module, so the base column set per use case, the locked column, and the "always include the
+ * locked column" guard are defined exactly once and cannot drift into two independent copies.
+ */
+
+export type ReportColumnKey =
+ 'vendor' | 'invoiceNumber' | 'date' | 'status' | 'invoiceAmount' | 'allocatedAmount' | 'usage';
+
+/** R1: Allocated Amount is the only column that can never be hidden. */
+export const REQUIRED_REPORT_COLUMN: ReportColumnKey = 'allocatedAmount';
+
+const OVERVIEW_COLUMNS: readonly ReportColumnKey[] = [
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ 'usage',
+];
+
+// R6: claim/proof-of-funds reports have no `status` VALUE in the content model at all
+// (buildReportContent.ts: `status: isOverview ? status : null`) — `status` is physically absent
+// from this list so no code path can add it to a claim/proof-of-funds report (AC 2.6 is
+// structural, not a runtime check).
+const CLAIM_COLUMNS: readonly ReportColumnKey[] = [
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ 'usage',
+];
+
+export function reportColumnsForUseCase(isOverview: boolean): readonly ReportColumnKey[] {
+ return isOverview ? OVERVIEW_COLUMNS : CLAIM_COLUMNS;
+}
+
+export function isColumnLocked(column: ReportColumnKey): boolean {
+ return column === REQUIRED_REPORT_COLUMN;
+}
+
+/**
+ * The visible column list for a given use case and hidden-column selection, in canonical order.
+ * Always includes the locked column regardless of `hiddenColumns`' contents — defense in depth
+ * beneath the UI's disabled checkbox, and what makes the PDF geometry engine's AC 4.7 hold
+ * structurally rather than incidentally.
+ */
+export function visibleReportColumns(
+ isOverview: boolean,
+ hiddenColumns: ReadonlySet,
+): ReportColumnKey[] {
+ return reportColumnsForUseCase(isOverview).filter(
+ (col) => isColumnLocked(col) || !hiddenColumns.has(col),
+ );
+}
diff --git a/client/src/lib/reportContent/index.ts b/client/src/lib/reportContent/index.ts
index 767a7d30d..94b020f4e 100644
--- a/client/src/lib/reportContent/index.ts
+++ b/client/src/lib/reportContent/index.ts
@@ -17,3 +17,10 @@ export { buildReportContent } from './buildReportContent.js';
export { applyOverrides } from './applyOverrides.js';
export { applyAiContent } from './applyAiContent.js';
export { overrideKey } from './overrideKeys.js';
+export {
+ reportColumnsForUseCase,
+ visibleReportColumns,
+ isColumnLocked,
+ REQUIRED_REPORT_COLUMN,
+} from './columns.js';
+export type { ReportColumnKey } from './columns.js';
diff --git a/client/src/lib/reportPdf/merge.test.ts b/client/src/lib/reportPdf/merge.test.ts
index 91712b3d0..5e6e20399 100644
--- a/client/src/lib/reportPdf/merge.test.ts
+++ b/client/src/lib/reportPdf/merge.test.ts
@@ -305,18 +305,53 @@ describe('generateReportPdf', () => {
});
});
- it('calls buildOverviewContent with (reportContent, skippedByInvoice map) — the new 2-arg shape', async () => {
+ it('calls buildOverviewContent with (reportContent, skippedByInvoice map, hiddenColumns) — the #1973 3-arg shape', async () => {
const invoice = makeInvoice({ invoiceId: 'inv-1' });
const report = makeReport([invoice]);
const content = makeContent();
await generateReportPdf(report, new Set(['inv-1']), content, { attachDocuments: false });
- expect(mockBuildOverviewContent).toHaveBeenCalledWith(content, expect.any(Map));
+ expect(mockBuildOverviewContent).toHaveBeenCalledWith(content, expect.any(Map), new Set());
const skippedByInvoiceArg = mockBuildOverviewContent.mock.calls[0]![1] as Map;
expect(skippedByInvoiceArg.size).toBe(0);
});
+ describe('#1973: hiddenColumns plumbing', () => {
+ it('a hiddenColumns Set passed in options reaches buildOverviewContent unchanged', async () => {
+ const invoice = makeInvoice({ invoiceId: 'inv-1' });
+ const report = makeReport([invoice]);
+ const content = makeContent();
+ const hiddenColumns = new Set<'vendor'>(['vendor']);
+
+ await generateReportPdf(report, new Set(['inv-1']), content, {
+ attachDocuments: false,
+ hiddenColumns,
+ });
+
+ expect(mockBuildOverviewContent).toHaveBeenCalledWith(
+ content,
+ expect.any(Map),
+ hiddenColumns,
+ );
+ const hiddenColumnsArg = mockBuildOverviewContent.mock.calls[0]![2] as Set;
+ expect(hiddenColumnsArg.has('vendor')).toBe(true);
+ });
+
+ it('omitting hiddenColumns entirely behaves identically to passing an explicit empty Set (the internal default)', async () => {
+ const invoice = makeInvoice({ invoiceId: 'inv-1' });
+ const report = makeReport([invoice]);
+ const content = makeContent();
+
+ await generateReportPdf(report, new Set(['inv-1']), content, { attachDocuments: false });
+
+ const hiddenColumnsArg = mockBuildOverviewContent.mock.calls[0]![2] as Set;
+ expect(hiddenColumnsArg).toBeInstanceOf(Set);
+ expect(hiddenColumnsArg.size).toBe(0);
+ expect(hiddenColumnsArg).toEqual(new Set());
+ });
+ });
+
it('builds the skippedByInvoice map passed to buildOverviewContent from actual skip failures', async () => {
const invoice = makeInvoice({
invoiceId: 'inv-1',
@@ -352,8 +387,9 @@ describe('generateReportPdf', () => {
expect(result.skippedDocuments).toEqual([]);
// buildOverviewContent no longer receives an appendix map argument at all (only reportContent,
- // skippedByInvoice) — appendix numbering is purely internal to the pdf-lib splice step.
- expect(mockBuildOverviewContent.mock.calls[0]).toHaveLength(2);
+ // skippedByInvoice, hiddenColumns) — appendix numbering is purely internal to the pdf-lib
+ // splice step.
+ expect(mockBuildOverviewContent.mock.calls[0]).toHaveLength(3);
// Final blob comes from finalDoc.save(), not the raw pdfmake text blob.
const bytes = new Uint8Array(await result.blob.arrayBuffer());
diff --git a/client/src/lib/reportPdf/merge.ts b/client/src/lib/reportPdf/merge.ts
index 3caa28e35..54bf6259b 100644
--- a/client/src/lib/reportPdf/merge.ts
+++ b/client/src/lib/reportPdf/merge.ts
@@ -8,7 +8,7 @@ import { loadPdfLibs } from './loader.js';
import { buildPageHeader, buildPageFooter } from './shared.js';
import { buildCoverLetterContent } from './coverLetterPdf.js';
import { buildOverviewContent } from './overviewPdf.js';
-import type { GeneratedReport, SkippedDocument } from './types.js';
+import type { GeneratedReport, ReportPdfOptions, SkippedDocument } from './types.js';
import { getDocumentPreviewUrl } from '../paperlessApi.js';
import { PAGE_MARGIN_X, PAGE_TOP_MARGIN, PAGE_MARGIN_BOTTOM, PDF_STYLES } from './pageGeometry.js';
@@ -34,8 +34,9 @@ export async function generateReportPdf(
report: SourceReportResponse,
includedInvoiceIds: Set,
reportContent: ReportContent,
- options: { attachDocuments: boolean },
+ options: ReportPdfOptions,
): Promise {
+ const hiddenColumns = options.hiddenColumns ?? new Set();
const { pdfMake, PDFDocument } = await loadPdfLibs();
const skippedDocuments: SkippedDocument[] = [];
const appendixByInvoiceId = new Map();
@@ -113,7 +114,7 @@ export async function generateReportPdf(
content.push(...coverLetter);
}
- const overview = buildOverviewContent(reportContent, skippedByInvoice);
+ const overview = buildOverviewContent(reportContent, skippedByInvoice, hiddenColumns);
content.push(...overview);
// Step 3: Generate pdfmake document
diff --git a/client/src/lib/reportPdf/overviewPdf.test.ts b/client/src/lib/reportPdf/overviewPdf.test.ts
index e5c9fde56..f2c18e26c 100644
--- a/client/src/lib/reportPdf/overviewPdf.test.ts
+++ b/client/src/lib/reportPdf/overviewPdf.test.ts
@@ -43,13 +43,22 @@
* exported `VENDOR_SAFE_TOKEN_CHARS`.
*/
import { describe, it, expect } from '@jest/globals';
-import type { ReportContent, ReportContentRow, ReportSkipReason } from '../reportContent/index.js';
+import type {
+ ReportContent,
+ ReportContentRow,
+ ReportSkipReason,
+ ReportColumnKey,
+} from '../reportContent/index.js';
+import { reportColumnsForUseCase, visibleReportColumns } from '../reportContent/index.js';
import type { UsageCellSegment } from './overviewPdf.js';
import {
buildOverviewContent,
splitIntoPageSafeChunks,
packUsageCellRows,
buildUsageTextRuns,
+ computeColumnWidths,
+ usageSafeTokenCharsForWidth,
+ usageChunkCharsForWidth,
USAGE_WIDTH_7COL,
USAGE_WIDTH_6COL,
USAGE_SAFE_TOKEN_CHARS_7COL,
@@ -1671,3 +1680,473 @@ describe('AC7 — skip reason labels come from reportContent.labels.skipReasonLa
expect(notesStack.stack[0]!.text).toBe('*1: Beta (B-2) — INVALID-SENTINEL');
});
});
+
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+// #1973 — column visibility wired through to the PDF geometry engine (96 legal subsets)
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+//
+// R6/AC2.6: budget-overview (isOverview=true) has 6 free (hideable) columns -> 2^6 = 64 legal
+// subsets; claim/proof-of-funds (isOverview=false) has 5 free columns -> 2^5 = 32 legal subsets.
+// allocatedAmount is never free (R1) so it is not part of either mask. 64 + 32 = 96 total.
+const OVERVIEW_FREE: ReportColumnKey[] = [
+ 'vendor',
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'usage',
+];
+const CLAIM_FREE: ReportColumnKey[] = ['vendor', 'invoiceNumber', 'date', 'invoiceAmount', 'usage'];
+
+/** Every legal hiddenColumns Set for a use case: one per bitmask over its free-column list. */
+function allLegalHiddenSets(isOverview: boolean): Set[] {
+ const free = isOverview ? OVERVIEW_FREE : CLAIM_FREE;
+ const sets: Set[] = [];
+ for (let mask = 0; mask < 1 << free.length; mask++) {
+ const hidden = free.filter((_col, i) => (mask & (1 << i)) === 0);
+ sets.push(new Set(hidden));
+ }
+ return sets;
+}
+
+// Sanity on the enumerator itself — if this drifts, every test below silently tests fewer/more
+// subsets than the AC requires.
+describe('#1973 subset enumerator sanity', () => {
+ it('produces exactly 64 overview subsets and 32 claim subsets (96 total, per R6)', () => {
+ expect(allLegalHiddenSets(true)).toHaveLength(64);
+ expect(allLegalHiddenSets(false)).toHaveLength(32);
+ });
+
+ it('every produced subset always keeps allocatedAmount visible (never appears in any hidden set)', () => {
+ for (const isOverview of [true, false]) {
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ expect(hidden.has('allocatedAmount')).toBe(false);
+ }
+ }
+ });
+});
+
+describe('#1973 AC3.1/3.2/3.3: computeColumnWidths — full base sets (scenario 7)', () => {
+ it('7-column budget-overview: absorber is usage, widths.usage === USAGE_WIDTH_7COL exactly', () => {
+ const { widths, absorber } = computeColumnWidths(reportColumnsForUseCase(true));
+ expect(absorber).toBe('usage');
+ expect(widths.usage).toBe(USAGE_WIDTH_7COL);
+ });
+
+ it('6-column claim/proof-of-funds: absorber is usage, widths.usage === USAGE_WIDTH_6COL exactly', () => {
+ const { widths, absorber } = computeColumnWidths(reportColumnsForUseCase(false));
+ expect(absorber).toBe('usage');
+ expect(widths.usage).toBe(USAGE_WIDTH_6COL);
+ });
+});
+
+describe('#1973 AC3.2/3.3/3.5: geometry across all 96 legal subsets', () => {
+ // Reference per-column pinned widths, derived from a real computeColumnWidths call rather than
+ // re-typed literals (AC3.2 explicitly forbids adding more bare-literal geometry assertions —
+ // #1950). The 7-column full set has every FixedColumnKey visible except usage (the absorber),
+ // so it supplies the reference width for all six non-usage columns at once.
+ const REFERENCE_WIDTHS = computeColumnWidths(reportColumnsForUseCase(true)).widths;
+
+ function totalWidth(
+ widths: Partial>,
+ visible: ReportColumnKey[],
+ ): number {
+ return tableOffsetsTotal(visible.length) + visible.reduce((sum, col) => sum + widths[col]!, 0);
+ }
+
+ it('(AC3.2, scenario 8) for every subset where usage or vendor is visible (72 of 96), the total equals printableWidth() exactly', () => {
+ let checked = 0;
+ for (const isOverview of [true, false]) {
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ const visible = visibleReportColumns(isOverview, hidden);
+ if (!visible.includes('usage') && !visible.includes('vendor')) continue;
+ const { widths, absorber } = computeColumnWidths(visible);
+ expect(absorber).not.toBeNull();
+ expect(totalWidth(widths, visible)).toBe(printableWidth());
+ checked++;
+ }
+ }
+ expect(checked).toBe(72); // 48 overview + 24 claim, per R7/AC3.2
+ });
+
+ it('(AC3.4, scenario 9) for every subset where NEITHER usage nor vendor is visible (24 of 96), the total is strictly less than printableWidth(), and the min/max checkpoints are exactly 84.00pt / 315.00pt, computed from tableOffsetsTotal + the pinned constants', () => {
+ const totals: number[] = [];
+ let checked = 0;
+ for (const isOverview of [true, false]) {
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ const visible = visibleReportColumns(isOverview, hidden);
+ if (visible.includes('usage') || visible.includes('vendor')) continue;
+ const { widths, absorber } = computeColumnWidths(visible);
+ expect(absorber).toBeNull();
+ const total = totalWidth(widths, visible);
+ expect(total).toBeLessThan(printableWidth());
+ totals.push(total);
+ checked++;
+ }
+ }
+ expect(checked).toBe(24); // 16 overview + 8 claim
+
+ // Min checkpoint: {allocatedAmount} alone (reachable identically from both use cases).
+ const minExpected = tableOffsetsTotal(1) + REFERENCE_WIDTHS.allocatedAmount!;
+ expect(minExpected).toBe(84);
+ expect(Math.min(...totals)).toBe(minExpected);
+
+ // Max checkpoint: the 5-column overview subset {invoiceNumber, date, status, invoiceAmount,
+ // allocatedAmount} — the largest no-absorber subset, since only budget-overview has a 5th
+ // free (non-usage/vendor) column (status) to add.
+ const maxVisible = visibleReportColumns(true, new Set(['vendor', 'usage']));
+ expect(maxVisible).toEqual([
+ 'invoiceNumber',
+ 'date',
+ 'status',
+ 'invoiceAmount',
+ 'allocatedAmount',
+ ]);
+ const maxExpected = totalWidth(REFERENCE_WIDTHS, maxVisible);
+ expect(maxExpected).toBe(315);
+ expect(Math.max(...totals)).toBe(maxExpected);
+ });
+
+ it('(AC3.5, scenario 10) every visible non-absorber column keeps its exact pinned width, at every one of the 96 subsets', () => {
+ let checked = 0;
+ for (const isOverview of [true, false]) {
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ const visible = visibleReportColumns(isOverview, hidden);
+ const { widths, absorber } = computeColumnWidths(visible);
+ for (const col of visible) {
+ if (col === absorber) continue;
+ expect(widths[col]).toBe(REFERENCE_WIDTHS[col]);
+ checked++;
+ }
+ }
+ }
+ expect(checked).toBeGreaterThan(0);
+ });
+});
+
+describe('#1973 AC3.6: usageSafeTokenCharsForWidth recomputes per subset width, not the pinned *_7COL/_6COL constants', () => {
+ it('(scenario 11) hiding date from a 7-column report widens Usage, producing a strictly higher safe-token-char threshold than USAGE_SAFE_TOKEN_CHARS_7COL', () => {
+ const visible = visibleReportColumns(true, new Set(['date']));
+ const { widths } = computeColumnWidths(visible);
+ const recomputed = usageSafeTokenCharsForWidth(widths.usage!);
+ expect(widths.usage!).toBeGreaterThan(USAGE_WIDTH_7COL);
+ expect(recomputed).toBeGreaterThan(USAGE_SAFE_TOKEN_CHARS_7COL);
+ });
+});
+
+describe('#1973 AC3.7: usageChunkCharsForWidth — one-sided clamp (scenario 12)', () => {
+ // No subset among today's 96 legal combinations reaches the downward branch (hiding columns
+ // only ever WIDENS Usage — see computeColumnWidths' own derivation comment), so both directions
+ // are exercised by calling the function directly with synthetic widths, per the QA spec.
+ it('a width NARROWER than USAGE_WIDTH_7COL scales the budget strictly below 650', () => {
+ const narrower = USAGE_WIDTH_7COL / 2;
+ const result = usageChunkCharsForWidth(narrower);
+ expect(result).toBeLessThan(MAX_SAFE_USAGE_CHUNK_CHARS);
+ expect(result).toBe(Math.floor(MAX_SAFE_USAGE_CHUNK_CHARS * (narrower / USAGE_WIDTH_7COL)));
+ });
+
+ it('a width WIDER than USAGE_WIDTH_7COL (e.g. USAGE_WIDTH_6COL, or hiding every other free column) stays clamped at exactly 650, never scaling up', () => {
+ expect(USAGE_WIDTH_6COL).toBeGreaterThan(USAGE_WIDTH_7COL);
+ expect(usageChunkCharsForWidth(USAGE_WIDTH_6COL)).toBe(MAX_SAFE_USAGE_CHUNK_CHARS);
+
+ // A much wider synthetic width (e.g. the degenerate near-full-page Usage-only case) must
+ // still clamp at 650, not scale proportionally past it.
+ const muchWider = USAGE_WIDTH_7COL * 3;
+ expect(usageChunkCharsForWidth(muchWider)).toBe(MAX_SAFE_USAGE_CHUNK_CHARS);
+ });
+
+ it('exactly at USAGE_WIDTH_7COL (the reference width) returns exactly 650 — the boundary is inclusive', () => {
+ expect(usageChunkCharsForWidth(USAGE_WIDTH_7COL)).toBe(MAX_SAFE_USAGE_CHUNK_CHARS);
+ });
+});
+
+describe('#1973 AC2.4/AC4.1/AC4.2: buildOverviewContent renders every one of the 96 legal subsets without a malformed row', () => {
+ function fixtureContent(isOverview: boolean): ReportContent {
+ return makeContent({
+ isOverview,
+ rows: [
+ makeRow({ invoiceId: 'inv-1', vendor: 'ACME', statusText: isOverview ? 'Pending' : null }),
+ makeRow({ invoiceId: 'inv-2', vendor: 'Beta Co', statusText: isOverview ? 'Paid' : null }),
+ ],
+ summaryRows: [{ key: 'total', label: 'sourceReports.table.total', amountText: '€500.00' }],
+ });
+ }
+
+ it('every one of the 96 subsets builds without throwing, and every header/body/summary row has exactly visible.length cells (scenarios 13, 15)', () => {
+ let checked = 0;
+ for (const isOverview of [true, false]) {
+ const content = fixtureContent(isOverview);
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ const visible = visibleReportColumns(isOverview, hidden);
+ let result: unknown[] = [];
+ expect(() => {
+ result = buildOverviewContent(content, new Map(), hidden);
+ }).not.toThrow();
+ const table = getTable(result);
+ // Header row.
+ expect((table.body[0] as unknown[]).length).toBe(visible.length);
+ // Every remaining row (data + summary — no continuation rows possible with this short
+ // fixture text) also matches the visible column count exactly.
+ for (const row of table.body.slice(1)) {
+ expect((row as unknown[]).length).toBe(visible.length);
+ }
+ checked++;
+ }
+ }
+ expect(checked).toBe(96);
+ });
+
+ it('(AC2.7, scenario 14) the single-column case ({allocatedAmount} alone) produces a 1-wide table with one header cell and no zero-width column, for both use cases', () => {
+ for (const isOverview of [true, false]) {
+ const free = isOverview ? OVERVIEW_FREE : CLAIM_FREE;
+ const content = fixtureContent(isOverview);
+ const result = buildOverviewContent(content, new Map(), new Set(free));
+ const table = getTable(result);
+ expect(table.widths).toHaveLength(1);
+ expect(table.widths[0]).toBeGreaterThan(0);
+ expect((table.body[0] as unknown[]).length).toBe(1);
+ }
+ });
+});
+
+describe('#1973 AC4.3: no content belonging to a visible column is ever dropped, across every subset (scenario 16)', () => {
+ function fixtureContent(isOverview: boolean): ReportContent {
+ return makeContent({
+ isOverview,
+ rows: [
+ makeRow({
+ invoiceId: 'inv-1',
+ vendor: 'Vendor One',
+ invoiceNumber: 'INV-100',
+ dateText: 'date-a',
+ statusText: isOverview ? 'Pending' : null,
+ invoiceAmountText: '€111.00',
+ allocatedAmountValueText: '€222.00',
+ usageText: 'Kitchen work',
+ }),
+ makeRow({
+ invoiceId: 'inv-2',
+ vendor: 'Vendor Two',
+ invoiceNumber: 'INV-200',
+ dateText: 'date-b',
+ statusText: isOverview ? 'Paid' : null,
+ invoiceAmountText: '€333.00',
+ allocatedAmountValueText: '€444.00',
+ usageText: 'Bathroom work',
+ }),
+ ],
+ summaryRows: [],
+ });
+ }
+
+ function dataRowCellsByColumn(
+ result: unknown[],
+ visible: ReportColumnKey[],
+ ): Partial>[] {
+ const table = getTable(result);
+ // Header (1) + 2 data rows, no summary rows in this fixture, no continuation rows (short text).
+ const dataRows = table.body.slice(1, 3);
+ return dataRows.map((row) => {
+ const texts = rowTexts(row);
+ const map: Partial> = {};
+ visible.forEach((col, i) => {
+ map[col] = texts[i];
+ });
+ return map;
+ });
+ }
+
+ it('every visible column, at every subset, renders byte-identical text to the same column in the full-column-set baseline', () => {
+ let comparisons = 0;
+ for (const isOverview of [true, false]) {
+ const content = fixtureContent(isOverview);
+ const baselineVisible = reportColumnsForUseCase(isOverview) as ReportColumnKey[];
+ const baseline = dataRowCellsByColumn(
+ buildOverviewContent(content, new Map()),
+ baselineVisible,
+ );
+
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ const visible = visibleReportColumns(isOverview, hidden);
+ const rows = dataRowCellsByColumn(
+ buildOverviewContent(content, new Map(), hidden),
+ visible,
+ );
+ for (const col of visible) {
+ for (let r = 0; r < rows.length; r++) {
+ expect(rows[r]![col]).toBe(baseline[r]![col]);
+ comparisons++;
+ }
+ }
+ }
+ }
+ expect(comparisons).toBeGreaterThan(0);
+ });
+});
+
+describe('#1973 AC4.4: summaryRows render identically at every subset (scenario 17)', () => {
+ function fixtureContent(isOverview: boolean): ReportContent {
+ return makeContent({
+ isOverview,
+ rows: [makeRow({ invoiceId: 'inv-1' })],
+ summaryRows: [
+ { key: 'subtotal', label: 'sourceReports.table.subtotal', amountText: '€100.00' },
+ { key: 'total', label: 'sourceReports.table.total', amountText: '€200.00' },
+ ],
+ });
+ }
+
+ it("every summaryRows entry's amountText renders byte-for-byte identically regardless of hiddenColumns (R4: visibility never changes a number)", () => {
+ for (const isOverview of [true, false]) {
+ const content = fixtureContent(isOverview);
+ for (const hidden of allLegalHiddenSets(isOverview)) {
+ const visible = visibleReportColumns(isOverview, hidden);
+ const result = buildOverviewContent(content, new Map(), hidden);
+ // Every subset renders BOTH declared amounts somewhere in the document — either as
+ // in-table cells (Tier 1/2) or as a stack block beneath the table (Tier 3, asserted in
+ // detail in the AC4.5/4.6 describe block below).
+ const allStrings = JSON.stringify(result);
+ expect(allStrings).toContain('€100.00');
+ expect(allStrings).toContain('€200.00');
+ expect(visible.length).toBeGreaterThan(0); // sanity: subset is non-degenerate to reach here
+ }
+ }
+ });
+});
+
+describe('#1973 AC4.5/AC4.6: summary-label three-tier placement, asserted per tier (scenario 18)', () => {
+ function fixtureContent(isOverview: boolean): ReportContent {
+ return makeContent({
+ isOverview,
+ rows: [],
+ summaryRows: [{ key: 'total', label: 'TOTAL_LABEL', amountText: '€999.00' }],
+ });
+ }
+
+ it("Tier 1 (92 subsets): label lands at the last visible LEADING column's own cell — e.g. date, when it is the only leading column left visible", () => {
+ const content = fixtureContent(true);
+ const hidden = new Set(['vendor', 'invoiceNumber', 'status', 'usage']);
+ const visible = visibleReportColumns(true, hidden);
+ expect(visible).toEqual(['date', 'invoiceAmount', 'allocatedAmount']);
+
+ const result = buildOverviewContent(content, new Map(), hidden);
+ const table = getTable(result);
+ const summaryRow = table.body[table.body.length - 1] as { text?: unknown }[];
+ expect(rowTexts(summaryRow)).toEqual(['TOTAL_LABEL', '', '€999.00']);
+ });
+
+ it('Tier 2 ({invoiceAmount, allocatedAmount}, no leading column): label appears in the invoiceAmount cell, NOT the allocatedAmount cell', () => {
+ const content = fixtureContent(false);
+ const hidden = new Set(['vendor', 'invoiceNumber', 'date', 'usage']);
+ const visible = visibleReportColumns(false, hidden);
+ expect(visible).toEqual(['invoiceAmount', 'allocatedAmount']);
+
+ const result = buildOverviewContent(content, new Map(), hidden);
+ const table = getTable(result);
+ const summaryRow = table.body[table.body.length - 1] as Record[];
+ expect(rowTexts(summaryRow)).toEqual(['TOTAL_LABEL', '€999.00']);
+ // The label is specifically in the invoiceAmount cell (index 0), not folded into the bold
+ // right-aligned allocatedAmount cell (index 1).
+ expect(summaryRow[0]!['bold']).toBe(true);
+ expect(summaryRow[0]!['alignment']).toBeUndefined();
+ expect(summaryRow[1]!['alignment']).toBe('right');
+ });
+
+ it.each([
+ ['overview', true, ['vendor', 'invoiceNumber', 'date', 'status', 'invoiceAmount', 'usage']],
+ ['overview+usage', true, ['vendor', 'invoiceNumber', 'date', 'status', 'invoiceAmount']],
+ ['claim', false, ['vendor', 'invoiceNumber', 'date', 'invoiceAmount', 'usage']],
+ ['claim+usage', false, ['vendor', 'invoiceNumber', 'date', 'invoiceAmount']],
+ ] as const)(
+ 'Tier 3 (%s): {allocatedAmount} / {allocatedAmount, usage} render NO in-table summary row — the label+amount live in a separate stack block below the table instead',
+ (_label, isOverview, hiddenList) => {
+ const content = fixtureContent(isOverview);
+ const hidden = new Set(hiddenList as unknown as ReportColumnKey[]);
+ const visible = visibleReportColumns(isOverview, hidden);
+ expect(visible.every((c) => c === 'allocatedAmount' || c === 'usage')).toBe(true);
+
+ const result = buildOverviewContent(content, new Map(), hidden);
+ const table = getTable(result);
+ // No data rows in this fixture (rows: []), so the table body must be JUST the header — no
+ // unlabelled bare-number row leaked into the table for the total.
+ expect(table.body).toHaveLength(1);
+
+ // The label+amount instead render as a stack block AFTER the table item in the content array.
+ const tableIndex = result.findIndex(
+ (c) => typeof c === 'object' && c !== null && 'table' in c,
+ );
+ const afterTable = result[tableIndex + 1] as { stack?: { columns: { text: string }[] }[] };
+ expect(afterTable.stack).toBeDefined();
+ expect(afterTable.stack).toHaveLength(1);
+ const [labelCell, amountCell] = afterTable.stack![0]!.columns;
+ expect(labelCell!.text).toBe('TOTAL_LABEL');
+ expect(amountCell!.text).toBe('€999.00');
+ },
+ );
+});
+
+describe('#1973 AC4.7: inline (partial) label survives even with every other free column hidden (scenario 19)', () => {
+ it('visible = [allocatedAmount] alone still renders the isSplit inline label in the Allocated Amount cell text', () => {
+ const row = makeRow({ allocatedAmountValueText: '€400.00', isSplit: true });
+ const content = makeContent({
+ isOverview: true,
+ rows: [row],
+ labels: { ...makeLabels(), splitNote: 'SPLIT_LABEL_SENTINEL' },
+ });
+ const hidden = new Set(OVERVIEW_FREE);
+ const visible = visibleReportColumns(true, hidden);
+ expect(visible).toEqual(['allocatedAmount']);
+
+ const result = buildOverviewContent(content, new Map(), hidden);
+ const table = getTable(result);
+ expect(rowTexts(table.body[1])).toEqual(['€400.00 (SPLIT_LABEL_SENTINEL)']);
+ });
+});
+
+describe('#1973 AC6.1 regression (R3/#1965 precondition): legend is unconditional, independent of Invoice Amount visibility (scenario 20)', () => {
+ it('a subset with Invoice Amount hidden and a (partial) row still emits the split footnote/legend text — this must hold structurally, not via an `if (invoiceAmountHidden)` branch', () => {
+ const row = makeRow({ isSplit: true });
+ const content = makeContent({
+ rows: [row],
+ footnotes: [{ id: 'split', marker: 'SPLIT_MARKER', text: 'SPLIT_LEGEND_SENTINEL' }],
+ });
+
+ // Invoice Amount hidden, alongside vendor/invoiceNumber/date/usage — only allocatedAmount
+ // (locked) survives, so Invoice Amount is unambiguously absent from `visible`.
+ const hidden = new Set(CLAIM_FREE);
+ const visible = visibleReportColumns(false, hidden);
+ expect(visible).not.toContain('invoiceAmount');
+
+ const result = buildOverviewContent(content, new Map(), hidden);
+ const allText = JSON.stringify(result);
+ expect(allText).toContain('SPLIT_LEGEND_SENTINEL');
+
+ // Same assertion holds when Invoice Amount IS visible too — the legend does not depend on it
+ // either way (R3 rejects a conditional legend in BOTH directions).
+ const withInvoiceAmountVisible = visibleReportColumns(true, new Set());
+ expect(withInvoiceAmountVisible).toContain('invoiceAmount');
+ const resultVisible = buildOverviewContent(
+ makeContent({ isOverview: true, rows: [row], footnotes: content.footnotes }),
+ new Map(),
+ );
+ expect(JSON.stringify(resultVisible)).toContain('SPLIT_LEGEND_SENTINEL');
+ });
+});
+
+describe('#1973 AC6.3: attachment-tier skip-footnote handling is unaffected by column visibility (scenario 21)', () => {
+ it('the same skippedDocuments input produces identical footnote output whether or not Usage is hidden', () => {
+ const row = makeRow({ invoiceId: 'inv-1', vendor: 'Skip Co', invoiceNumber: 'SK-1' });
+ const content = makeContent({ rows: [row] });
+ const skipped = new Map([['inv-1', ['footnoteFetchFailed']]]);
+
+ const baseline = buildOverviewContent(content, skipped);
+ const baselineNotes = (baseline[baseline.length - 1] as { stack: { text: string }[] }).stack;
+
+ const withUsageHidden = buildOverviewContent(content, skipped, new Set(['usage']));
+ const hiddenNotes = (
+ withUsageHidden[withUsageHidden.length - 1] as { stack: { text: string }[] }
+ ).stack;
+
+ expect(hiddenNotes.map((n) => n.text)).toEqual(baselineNotes.map((n) => n.text));
+ });
+});
diff --git a/client/src/lib/reportPdf/overviewPdf.ts b/client/src/lib/reportPdf/overviewPdf.ts
index 3a8d98579..2ed536299 100644
--- a/client/src/lib/reportPdf/overviewPdf.ts
+++ b/client/src/lib/reportPdf/overviewPdf.ts
@@ -3,7 +3,13 @@
* Consumes ReportContent (text only); no data derivation.
*/
import type { Content } from 'pdfmake/build/pdfmake';
-import type { ReportContent, ReportContentRow, ReportSkipReason } from '../reportContent/index.js';
+import type {
+ ReportContent,
+ ReportContentRow,
+ ReportSkipReason,
+ ReportColumnKey,
+} from '../reportContent/index.js';
+import { visibleReportColumns } from '../reportContent/index.js';
import {
TABLE_LAYOUT,
REFUND_TEXT_COLOR,
@@ -32,6 +38,74 @@ const ALLOCATED_AMOUNT_WIDTH = 75; // value+markers (~57pt) + " (Abschlagszahlun
// both hold; "Zugeordneter Betrag" header wraps at its internal space ("Zugeordneter"=60.42pt,
// "Betrag"=29.42pt — both < 75pt) so it never needs word-breaking either.
+/**
+ * Every column except Usage has a pinned, content-measured width (see the constants above).
+ * Usage is the odd one out: its width is derived per-subset by computeColumnWidths below, never
+ * pinned, which is what FixedColumnKey / PINNED_WIDTHS being Usage-exclusive encodes at the type
+ * level (#1973).
+ */
+type FixedColumnKey = Exclude;
+const PINNED_WIDTHS: Record = {
+ vendor: VENDOR_WIDTH,
+ invoiceNumber: INVOICE_NUMBER_WIDTH,
+ date: DATE_WIDTH,
+ status: STATUS_WIDTH,
+ invoiceAmount: INVOICE_AMOUNT_WIDTH,
+ allocatedAmount: ALLOCATED_AMOUNT_WIDTH,
+};
+const RIGHT_ALIGNED_COLUMNS: ReadonlySet = new Set([
+ 'invoiceAmount',
+ 'allocatedAmount',
+]);
+const LEADING_COLUMNS: readonly ReportColumnKey[] = ['vendor', 'invoiceNumber', 'date', 'status'];
+
+export interface ColumnWidths {
+ widths: Partial>;
+ absorber: ReportColumnKey | null;
+}
+
+/**
+ * The R7 width-absorber algorithm (#1973). For a given ordered visible-column list, the
+ * "absorber" is 'usage' if visible, else 'vendor' if visible, else null (no absorber — every
+ * remaining column is bounded/numeric, so the table renders narrower than the page rather than
+ * wider).
+ *
+ * Provably correct against AC 3.1-3.5 (re-derived here, not merely asserted):
+ * - When an absorber exists: `total = tableOffsetsTotal(n) + fixedSum + (usableColumnWidth(n) -
+ * fixedSum) = tableOffsetsTotal(n) + usableColumnWidth(n) = printableWidth()` EXACTLY,
+ * algebraically, for any visible set with an absorber (AC 3.2's 72-subset case).
+ * - When no absorber exists: `total = tableOffsetsTotal(n) + fixedSum(all visible)`, strictly
+ * less than printableWidth() since no term consumes the remaining slack (AC 3.4's 24-subset
+ * case).
+ * - Every non-absorber visible column keeps its exact PINNED_WIDTHS value in every case (AC 3.5
+ * holds by construction).
+ * - Removing any column while 'usage' stays the absorber strictly INCREASES widths.usage (fixedSum
+ * shrinks by the removed column's pinned width, and usableColumnWidth(n) grows by
+ * tableOffsetsTotal's per-column increment, 8.5pt) — so the narrowest Usage can ever be, across
+ * all 96 legal subsets, is USAGE_WIDTH_7COL (138.28pt), reached only at the full 7-column set.
+ */
+export function computeColumnWidths(visible: readonly ReportColumnKey[]): ColumnWidths {
+ const n = visible.length;
+ const absorber: ReportColumnKey | null = visible.includes('usage')
+ ? 'usage'
+ : visible.includes('vendor')
+ ? 'vendor'
+ : null;
+ let fixedSum = 0;
+ const widths: Partial> = {};
+ for (const col of visible) {
+ if (col === absorber) continue;
+ // Every non-absorber column reached here is a genuine FixedColumnKey: 'usage' is only ever
+ // skipped as `absorber` (never appears in this branch), so this narrowly-scoped cast is the
+ // one exception the compliance checklist allows for satisfying PINNED_WIDTHS' exhaustive key
+ // type — do not widen it further.
+ widths[col] = PINNED_WIDTHS[col as FixedColumnKey];
+ fixedSum += widths[col]!;
+ }
+ if (absorber) widths[absorber] = usableColumnWidth(n) - fixedSum;
+ return { widths, absorber };
+}
+
/**
* Usage column width (both shapes) — an EXPLICIT NUMERIC width computed from
* usableColumnWidth(), never `'*'`. #1929 round-3 architect review CRITICAL/HIGH1:
@@ -363,6 +437,53 @@ export const USAGE_SAFE_TOKEN_CHARS_6COL = safeTokenChars(
*/
export const VENDOR_SAFE_TOKEN_CHARS = safeTokenChars(VENDOR_WIDTH, BODY_WORST_CASE_CHAR_WIDTH_PT);
+/** Per-subset Usage safe-token-char threshold (AC 3.6) — same formula as USAGE_SAFE_TOKEN_CHARS_*COL
+ * above, generalized to any Usage width computeColumnWidths produces. */
+export function usageSafeTokenCharsForWidth(usageWidthPt: number): number {
+ return safeTokenChars(usageWidthPt, BODY_WORST_CASE_CHAR_WIDTH_PT);
+}
+
+/**
+ * AC 3.7 one-sided clamp. MAX_SAFE_USAGE_CHUNK_CHARS (650) was measured (see that constant's own
+ * doc comment) against a real render at the 7-column shape's Usage width (USAGE_WIDTH_7COL,
+ * 138.28pt) — the narrowest Usage can ever be across all 96 legal subsets (hiding any column
+ * while Usage stays visible only ever widens it further; see computeColumnWidths' derivation).
+ * Scaling proportionally to width and then clamping to 650 means this budget MAY scale down for a
+ * width narrower than the reference, and MUST NOT scale up for a wider one — required by AC 3.7
+ * specifically so a FUTURE column addition that narrows Usage below today's floor fails safe
+ * instead of silently reinstating the #1929 content-loss defect. No subset in today's 96 legal
+ * combinations reaches the downward branch — it exists for that future case, and must be tested
+ * by calling this function directly with a synthetic width (see QA Spec), not by enumerating
+ * today's subsets. Do not "simplify" this to always return MAX_SAFE_USAGE_CHUNK_CHARS: that would
+ * remove the clamp's entire reason for existing.
+ *
+ * Why the full 7-column shape returns EXACTLY 650 rather than 649: the ratio is exactly 1.0 there,
+ * because computeColumnWidths derives that subset's Usage width via the SAME usableColumnWidth(7)
+ * call USAGE_WIDTH_7COL uses, minus a `fixedSum` that sums the identical six PINNED_WIDTHS values
+ * USAGE_FIXED_SUM_7COL sums. Two computations of `usableColumnWidth(7) - X` are bit-identical
+ * whenever both `X`s are bit-identical (floating-point subtraction is a deterministic function of
+ * its two operands) — so the only question is whether `fixedSum === USAGE_FIXED_SUM_7COL`, and at
+ * this magnitude (six terms, tens of pt each, nowhere near the 52-bit mantissa's limit) that sum is
+ * order-independent REGARDLESS of whether a term is fractional — verified by exhaustively summing
+ * all 720 orderings of the six pinned widths with each one substituted for a non-integer value in
+ * turn: zero divergence. So, unlike an earlier draft of this comment claimed, a fractional pinned
+ * width does NOT threaten this equality on its own; do not trust that framing if it reappears.
+ *
+ * The real fragility is that USAGE_FIXED_SUM_7COL / USAGE_FIXED_SUM_6COL (above) are hand-written
+ * literal sums with no type-level tether to PINNED_WIDTHS or OVERVIEW_COLUMNS/CLAIM_COLUMNS —
+ * unlike PINNED_WIDTHS, which is typed `Record` and forces a compile error
+ * if a column is added without a pinned-width entry, nothing re-checks USAGE_FIXED_SUM_7COL's term
+ * list against the columns it's meant to cover. If a future column addition or removal updates one
+ * without the other, `fixedSum` and USAGE_FIXED_SUM_7COL would sum different term sets — a real,
+ * likely non-trivial divergence (not a subtle rounding nudge) — and THAT is what would silently
+ * break the boundary assertion in overviewPdf.test.ts's AC 3.7 block. Re-check that test, and this
+ * comment, if either constant's term list is ever touched independently of the other.
+ */
+export function usageChunkCharsForWidth(usageWidthPt: number): number {
+ const scaled = Math.floor(MAX_SAFE_USAGE_CHUNK_CHARS * (usageWidthPt / USAGE_WIDTH_7COL));
+ return Math.min(MAX_SAFE_USAGE_CHUNK_CHARS, scaled);
+}
+
/**
* Splits `text` into inline pdfmake text runs. Whitespace-free runs at or under `safeTokenChars`
* are emitted verbatim (default whitespace-only wrapping); a run over that length gets
@@ -445,10 +566,9 @@ export const HEADER_ROW_HEIGHT_MAX =
HEADER_ROW_VERTICAL_PADDING_PT;
/**
- * Every cell-content channel this table renders (buildLeadingCells/buildAmountCells/the row-
- * building loop below), and the bound that closes each one (#1939, product-architect round-4
- * sweep). Documentation only — see the two exceptions called out at the end; neither is fixed
- * here (scope guard: AC7).
+ * Every cell-content channel this table renders (buildBodyCell/the row-building loop below), and
+ * the bound that closes each one (#1939, product-architect round-4 sweep). Documentation only —
+ * see the two exceptions called out at the end; neither is fixed here (scope guard: AC7).
*
* - `vendor` — server `maxLength: 200` (`server/src/routes/vendors.ts` createVendorSchema,
* `name` field). Worst case (VENDOR_SAFE_TOKEN_CHARS break-all, 200 chars
@@ -457,7 +577,7 @@ export const HEADER_ROW_HEIGHT_MAX =
* - `invoiceNumber` — server `maxLength: 100` (`server/src/routes/invoices.ts`
* createInvoiceSchema, `invoiceNumber` field). Worst case ~158pt — 74.6%
* margin. NOT routed through buildUsageTextRuns (rendered as a plain
- * `contentRow.invoiceNumber` text cell in buildLeadingCells) — see the
+ * `contentRow.invoiceNumber` text cell in buildBodyCell) — see the
* exception below.
* - `statusText` — enum label via `reportT` (bounded by construction: finite enum of
* translated strings, not user input).
@@ -491,6 +611,7 @@ export const HEADER_ROW_HEIGHT_MAX =
export function buildOverviewContent(
reportContent: ReportContent,
skippedDocuments: Map,
+ hiddenColumns: ReadonlySet = new Set(),
): Content[] {
const content: Content[] = [];
@@ -530,58 +651,66 @@ export function buildOverviewContent(
});
}
+ // Visible columns for this report, in canonical order — the single AC 2.1 derivation shared
+ // with ReportContentEditor's toggle UI (client/src/lib/reportContent/columns.ts). The R7
+ // width-absorber algorithm (computeColumnWidths) is applied against this exact set.
+ const visible = visibleReportColumns(reportContent.isOverview, hiddenColumns);
+ const { widths: colWidths } = computeColumnWidths(visible);
+
// Build table columns — every header cell goes through buildHeaderCell so a single-word label
// wider than its column (#1929 round-3 architect review HIGH1) breaks mid-character instead of
// overflowing; harmless for labels that already fit.
- const usageWidth = reportContent.isOverview ? USAGE_WIDTH_7COL : USAGE_WIDTH_6COL;
- const columns: Content[] = [
- buildHeaderCell(reportContent.labels.vendor, VENDOR_WIDTH),
- buildHeaderCell(reportContent.labels.invoiceNumber, INVOICE_NUMBER_WIDTH),
- buildHeaderCell(reportContent.labels.date, DATE_WIDTH),
- ];
-
- // Add status column only if budget-overview
- if (reportContent.isOverview) {
- columns.push(buildHeaderCell(reportContent.labels.status, STATUS_WIDTH));
- }
-
- columns.push(
- buildHeaderCell(reportContent.labels.invoiceAmount, INVOICE_AMOUNT_WIDTH, 'right'),
- buildHeaderCell(reportContent.labels.allocatedAmount, ALLOCATED_AMOUNT_WIDTH, 'right'),
- buildHeaderCell(reportContent.labels.usage, usageWidth),
+ const HEADER_LABEL: Record = {
+ vendor: reportContent.labels.vendor,
+ invoiceNumber: reportContent.labels.invoiceNumber,
+ date: reportContent.labels.date,
+ status: reportContent.labels.status,
+ invoiceAmount: reportContent.labels.invoiceAmount,
+ allocatedAmount: reportContent.labels.allocatedAmount,
+ usage: reportContent.labels.usage,
+ };
+ const columns: Content[] = visible.map((col) =>
+ buildHeaderCell(
+ HEADER_LABEL[col],
+ colWidths[col]!,
+ RIGHT_ALIGNED_COLUMNS.has(col) ? 'right' : undefined,
+ ),
);
+ const nonUsageVisible = visible.filter((c): c is FixedColumnKey => c !== 'usage');
+ const usageVisible = visible.includes('usage');
+
+ // R2's three-tier fallback for a summary row's label placement (AC 4.5/4.6), computed once per
+ // document rather than per row.
+ const lastLeadingVisible = [...LEADING_COLUMNS].reverse().find((c) => visible.includes(c));
+ const hasLeadingVisible = lastLeadingVisible !== undefined;
+ const invoiceAmountVisible = visible.includes('invoiceAmount');
+ // Tier 3, exactly the {allocatedAmount} / {allocatedAmount, usage} subsets: no leading column
+ // and no invoiceAmount column survive to carry the label, so summary rows render as a stack
+ // block below the table instead of a table row — matching ReportContentEditor.tsx's preview,
+ // which always renders summary rows in a separate block (R2's "preview parity").
+ const usesSeparateSummaryBlock = !hasLeadingVisible && !invoiceAmountVisible;
+
/**
- * Helper: build summary row (subtotal/total) with label at last leading index.
+ * Helper: build summary row (subtotal/total) with the label at the last visible leading column
+ * (Tier 1), falling back to invoiceAmount when no leading column survives (Tier 2). Never
+ * called for usesSeparateSummaryBlock's Tier 3 subsets.
*/
function buildSummaryRow(labelText: string, amountText: string): Content[] {
- const leadingCount = reportContent.isOverview ? 4 : 3;
- const row: Content[] = [];
-
- // Leading cells: empty except the last one which has the label
- for (let i = 0; i < leadingCount; i++) {
- if (i === leadingCount - 1) {
- row.push({ text: labelText, style: 'tableCell', bold: true });
- } else {
- row.push({ text: '', style: 'tableCell' });
- }
- }
-
- // Empty invoiceAmount cell
- row.push({ text: '', style: 'tableCell' });
-
- // Bold right-aligned amount
- row.push({
- text: amountText,
- style: 'tableCell',
- alignment: 'right',
- bold: true,
- });
-
- // Empty trailing usage cell
- row.push({ text: '', style: 'tableCell' });
-
- return row;
+ return nonUsageVisible
+ .map((col): Content => {
+ if (col === 'allocatedAmount') {
+ return { text: amountText, style: 'tableCell', alignment: 'right', bold: true };
+ }
+ if (col === lastLeadingVisible) {
+ return { text: labelText, style: 'tableCell', bold: true }; // Tier 1
+ }
+ if (col === 'invoiceAmount' && !hasLeadingVisible) {
+ return { text: labelText, style: 'tableCell', bold: true }; // Tier 2
+ }
+ return { text: '', style: 'tableCell' };
+ })
+ .concat(usageVisible ? [{ text: '', style: 'tableCell' }] : []);
}
// Build table rows from reportContent.rows
@@ -602,79 +731,63 @@ export function buildOverviewContent(
}
/**
- * Leading (vendor/invoiceNumber/date/[status]) cells for a content row. Status is pushed
- * unconditionally whenever isOverview — see AC14: a falsy statusText must still produce a
- * cell, or the row's cell count falls short of the 7-entry `widths` array and pdfmake throws
- * "Malformed table row, a cell is undefined."
+ * Body cell for one non-usage visible column. AC14 precedent: status is never omitted even
+ * when falsy — every visible non-usage column must always produce a cell, or the row's cell
+ * count falls short of `widths` and pdfmake throws "Malformed table row, a cell is undefined."
*/
- function buildLeadingCells(
+ function buildBodyCell(
+ col: FixedColumnKey,
contentRow: ReportContentRow,
- isOverview: boolean,
- statusText: string,
- ): Content[] {
- const cells: Content[] = [
- // Vendor names are free-form business names (unlike invoiceNumber/dateText, which are
- // system-generated and bounded) — protected with the same per-token break-all treatment
- // as Usage (#1929 round-3 architect review HIGH1: "Elektroinstallationsbetrieb" measured
- // 92.72pt against the 45pt Vendor column).
- { text: buildUsageTextRuns(contentRow.vendor, VENDOR_SAFE_TOKEN_CHARS), style: 'tableCell' },
- { text: contentRow.invoiceNumber, style: 'tableCell' },
- { text: contentRow.dateText, style: 'tableCell' },
- ];
- if (isOverview) {
- cells.push({ text: statusText, style: 'tableCell' });
+ allocatedRuns: Content[],
+ ): Content {
+ switch (col) {
+ case 'vendor':
+ // Vendor names are free-form business names (unlike invoiceNumber/dateText, which are
+ // system-generated and bounded) — protected with the same per-token break-all treatment
+ // as Usage (#1929 round-3 architect review HIGH1: "Elektroinstallationsbetrieb" measured
+ // 92.72pt against the 45pt Vendor column).
+ return {
+ text: buildUsageTextRuns(contentRow.vendor, VENDOR_SAFE_TOKEN_CHARS),
+ style: 'tableCell',
+ };
+ case 'invoiceNumber':
+ return { text: contentRow.invoiceNumber, style: 'tableCell' };
+ case 'date':
+ return { text: contentRow.dateText, style: 'tableCell' };
+ case 'status':
+ return { text: contentRow.statusText ?? '', style: 'tableCell' };
+ case 'invoiceAmount':
+ return {
+ text: contentRow.invoiceAmountText,
+ style: 'tableCell',
+ alignment: 'right',
+ color: contentRow.isRefund ? REFUND_TEXT_COLOR : undefined,
+ };
+ case 'allocatedAmount':
+ return {
+ text: allocatedRuns,
+ style: 'tableCell',
+ alignment: 'right',
+ color: contentRow.isRefund ? REFUND_TEXT_COLOR : undefined,
+ };
}
- return cells;
- }
-
- /**
- * Invoice-amount and allocated-amount cells for a content row.
- */
- function buildAmountCells(contentRow: ReportContentRow, allocatedRuns: Content[]): Content[] {
- return [
- {
- text: contentRow.invoiceAmountText,
- style: 'tableCell',
- alignment: 'right',
- color: contentRow.isRefund ? REFUND_TEXT_COLOR : undefined,
- },
- {
- text: allocatedRuns,
- style: 'tableCell',
- alignment: 'right',
- color: contentRow.isRefund ? REFUND_TEXT_COLOR : undefined,
- },
- ];
- }
-
- /**
- * Empty leading cells for a Usage continuation row — every column blank except Usage.
- */
- function buildEmptyLeadingCells(isOverview: boolean): Content[] {
- const cells: Content[] = [
- { text: '', style: 'tableCell' },
- { text: '', style: 'tableCell' },
- { text: '', style: 'tableCell' },
- ];
- if (isOverview) cells.push({ text: '', style: 'tableCell' });
- return cells;
}
/**
- * Empty amount cells for a Usage continuation row.
+ * Empty body cell for a Usage continuation row — every non-usage visible column blank.
*/
- function buildEmptyAmountCells(): Content[] {
- return [
- { text: '', style: 'tableCell', alignment: 'right' },
- { text: '', style: 'tableCell', alignment: 'right' },
- ];
+ function buildEmptyBodyCell(col: FixedColumnKey): Content {
+ return RIGHT_ALIGNED_COLUMNS.has(col)
+ ? { text: '', style: 'tableCell', alignment: 'right' }
+ : { text: '', style: 'tableCell' };
}
- // Word-break thresholds for this table shape (#1929 round-2 review finding: AC2 permits
- // breaking a word only when it doesn't fit its column alone — see buildUsageTextRuns).
- const usageSafeTokenChars = reportContent.isOverview
- ? USAGE_SAFE_TOKEN_CHARS_7COL
- : USAGE_SAFE_TOKEN_CHARS_6COL;
+ // Word-break threshold and chunk budget for THIS subset's Usage width (AC 3.6/3.7) — derived
+ // from the per-subset width computeColumnWidths produced above, not the old two-shape
+ // constants (USAGE_SAFE_TOKEN_CHARS_7COL/6COL, which remain exported unchanged as the "hiding
+ // nothing" baseline values plus the reference denominator usageChunkCharsForWidth's clamp uses).
+ const usageSafeTokenChars = usageVisible ? usageSafeTokenCharsForWidth(colWidths.usage!) : 0;
+ const usageChunkChars = usageVisible ? usageChunkCharsForWidth(colWidths.usage!) : 0;
/**
* Renders one packed row's worth of Usage-cell segments (see packUsageCellRows) into a cell.
@@ -711,7 +824,10 @@ export function buildOverviewContent(
skipMarkerText += `*${noteNum}`;
}
- // Build allocated runs: value+skip markers, then optional inline labels, then optional refund note
+ // Build allocated runs: value+skip markers, then optional inline labels, then optional
+ // refund note. `allocatedAmount` is the locked column — always in `visible` regardless of
+ // `hiddenColumns` — so allocatedRuns is always built and always rendered here; #1973 AC 4.7
+ // holds structurally, not incidentally.
const allocatedRuns: Content[] = [
{ text: `${contentRow.allocatedAmountValueText}${skipMarkerText}` },
];
@@ -740,6 +856,15 @@ export function buildOverviewContent(
allocatedRuns.push({ text: ` ${contentRow.refundNoteText}` });
}
+ const nonUsageCells = nonUsageVisible.map((col) =>
+ buildBodyCell(col, contentRow, allocatedRuns),
+ );
+
+ if (!usageVisible) {
+ rows.push(nonUsageCells);
+ continue; // no Usage cell ⇒ no continuation rows possible
+ }
+
// Area and attachmentsNote render inline as one trailing grey suffix on the Usage cell
// (#1959), '\n'-prefixed and joined by ' · '.
const metaPieces: string[] = [];
@@ -760,25 +885,21 @@ export function buildOverviewContent(
if (metaPieces.length > 0) {
cellSegments.push({ text: `\n${metaPieces.join(' · ')}`, meta: true });
}
- const packedCellRows = packUsageCellRows(cellSegments, MAX_SAFE_USAGE_CHUNK_CHARS);
+ const packedCellRows = packUsageCellRows(cellSegments, usageChunkChars);
- rows.push([
- ...buildLeadingCells(contentRow, reportContent.isOverview, contentRow.statusText ?? ''),
- ...buildAmountCells(contentRow, allocatedRuns),
- buildUsageCell(packedCellRows[0]!),
- ]);
+ rows.push([...nonUsageCells, buildUsageCell(packedCellRows[0]!)]);
for (let i = 1; i < packedCellRows.length; i++) {
- rows.push([
- ...buildEmptyLeadingCells(reportContent.isOverview),
- ...buildEmptyAmountCells(),
- buildUsageCell(packedCellRows[i]!),
- ]);
+ rows.push([...nonUsageVisible.map(buildEmptyBodyCell), buildUsageCell(packedCellRows[i]!)]);
}
}
- // Add summary rows from reportContent.summaryRows
- for (const summaryRow of reportContent.summaryRows) {
- rows.push(buildSummaryRow(summaryRow.label, summaryRow.amountText));
+ // Add summary rows from reportContent.summaryRows — Tier 1/2 push a row into the table body;
+ // Tier 3 (usesSeparateSummaryBlock) renders as a stack block below the table instead (see the
+ // block pushed after the table, below).
+ if (!usesSeparateSummaryBlock) {
+ for (const summaryRow of reportContent.summaryRows) {
+ rows.push(buildSummaryRow(summaryRow.label, summaryRow.amountText));
+ }
}
// Add table
@@ -790,35 +911,34 @@ export function buildOverviewContent(
// (as round 1 did) is inert, since layout is only consumed for border/padding/fill
// callbacks (#1929 architect review, CRITICAL 1).
dontBreakRows: true,
- // Usage is an explicit NUMBER (usageWidth), never '*' — #1929 round-3 architect review
+ // Every column is an explicit NUMBER, never '*' — #1929 round-3 architect review
// CRITICAL/HIGH1: pdfmake never grows a fixed column past its declared width
// (elasticWidth is read but assigned nowhere), so declaring every column numeric makes
// the star column's content-driven overflow branch (columnCalculator.js's case-1) simply
- // unreachable — the table's total rendered width is printableWidth() for any input.
- widths: reportContent.isOverview
- ? [
- VENDOR_WIDTH,
- INVOICE_NUMBER_WIDTH,
- DATE_WIDTH,
- STATUS_WIDTH,
- INVOICE_AMOUNT_WIDTH,
- ALLOCATED_AMOUNT_WIDTH,
- usageWidth,
- ]
- : [
- VENDOR_WIDTH,
- INVOICE_NUMBER_WIDTH,
- DATE_WIDTH,
- INVOICE_AMOUNT_WIDTH,
- ALLOCATED_AMOUNT_WIDTH,
- usageWidth,
- ],
+ // unreachable — the table's total rendered width is printableWidth() for any input (see
+ // computeColumnWidths' derivation for the general N-column proof).
+ widths: visible.map((col) => colWidths[col]!),
body: rows,
},
layout: TABLE_LAYOUT, // no longer carries dontBreakRows — see shared.ts
margin: [0, 0, 0, 20],
});
+ // Tier 3 (usesSeparateSummaryBlock): summary rows render as their own stack block, matching
+ // ReportContentEditor.tsx's preview which always renders summary rows separately from the
+ // table (R2 "preview parity").
+ if (usesSeparateSummaryBlock && reportContent.summaryRows.length > 0) {
+ content.push({
+ stack: reportContent.summaryRows.map((row) => ({
+ columns: [
+ { text: row.label, style: 'tableCell', bold: true },
+ { text: row.amountText, style: 'tableCell', bold: true, alignment: 'right' },
+ ],
+ })),
+ margin: [0, 0, 0, 20],
+ });
+ }
+
// Add footnotes (skip block only; split/deposit annotations are now rendered inline)
const footnotes: Content[] = [];
diff --git a/client/src/lib/reportPdf/realRender.test.ts b/client/src/lib/reportPdf/realRender.test.ts
index 6c2c4ba8d..bb397557a 100644
--- a/client/src/lib/reportPdf/realRender.test.ts
+++ b/client/src/lib/reportPdf/realRender.test.ts
@@ -3410,3 +3410,67 @@ describe('legend sentence layout and occurrence count (#1980)', () => {
expect(allStrings.some((s) => s.includes(depositReducedSentence))).toBe(false);
});
});
+
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+// #1973 AC 2.7 — the degenerate single-column case ({allocatedAmount} alone), through the REAL,
+// unmocked pdfmake table-layout resolver. This is the strongest guard for AC 2.7 in the whole
+// suite: overviewPdf.test.ts only inspects the declared Content[] tree (which cannot see a real
+// pdfmake table-layout failure), while this file actually resolves layout via getBlob().
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+describe('#1973 AC2.7: single-column ({allocatedAmount} alone) real render', () => {
+ it.each([
+ ['budget-overview', 'budget-overview' as const],
+ ['claim', 'claim' as const],
+ ])(
+ 'a real, unmocked pdfmake render of the %s report with every hideable column hidden completes without throwing and produces at least one page',
+ async (_label, useCase) => {
+ const { generateReportPdf } = await import('./merge.js');
+ const { reportColumnsForUseCase, REQUIRED_REPORT_COLUMN } =
+ await import('../reportContent/columns.js');
+ const report = makeInvoice({ invoiceId: 'inv-solo', vendorName: 'Solo Vendor' });
+ const fullReport: SourceReportResponse = {
+ type: useCase === 'budget-overview' ? 'budget-overview' : 'claim',
+ source: {
+ id: 'src-1',
+ name: 'Home Loan',
+ sourceType: 'bank_loan',
+ reference: null,
+ contactAddress: null,
+ },
+ invoices: [report],
+ totalAmount: 100,
+ unallocatedInvoices: [],
+ generatedAt: '2026-02-15T00:00:00.000Z',
+ };
+ const includedIds = new Set(['inv-solo']);
+ const formatters = formattersFor('en-US');
+ const content = buildReportContent(fullReport, includedIds, useCase, tEn, formatters, {
+ includeCoverLetter: false,
+ household: null,
+ });
+
+ // Every column this use case offers, except the locked one, is hidden — the degenerate
+ // single-column case (visible === [allocatedAmount]).
+ const hiddenColumns = new Set(
+ reportColumnsForUseCase(content.isOverview).filter((c) => c !== REQUIRED_REPORT_COLUMN),
+ );
+
+ let result: Awaited>;
+ await expect(
+ (async () => {
+ result = await generateReportPdf(fullReport, includedIds, content, {
+ attachDocuments: false,
+ hiddenColumns,
+ });
+ })(),
+ ).resolves.not.toThrow();
+
+ expect(result!.blob).toBeInstanceOf(Blob);
+ expect(result!.blob.size).toBeGreaterThan(0);
+
+ const pdfDoc = await PDFDocument.load(await result!.blob.arrayBuffer());
+ expect(pdfDoc.getPageCount()).toBeGreaterThanOrEqual(1);
+ },
+ );
+});
diff --git a/client/src/lib/reportPdf/types.ts b/client/src/lib/reportPdf/types.ts
index 74af3f5f8..11a62c0a4 100644
--- a/client/src/lib/reportPdf/types.ts
+++ b/client/src/lib/reportPdf/types.ts
@@ -1,10 +1,12 @@
/**
* Types for the report PDF pipeline.
*/
-import type { ReportSkipReason } from '../reportContent/index.js';
+import type { ReportColumnKey, ReportSkipReason } from '../reportContent/index.js';
export interface ReportPdfOptions {
attachDocuments: boolean;
+ /** Columns hidden by the user in the report wizard's preview. Omitted/empty = hide nothing. */
+ hiddenColumns?: ReadonlySet;
}
export interface GeneratedReport {
diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.test.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.test.tsx
index 0f2c30494..702159109 100644
--- a/client/src/pages/ReportWizardPage/ReportWizardPage.test.tsx
+++ b/client/src/pages/ReportWizardPage/ReportWizardPage.test.tsx
@@ -1298,7 +1298,8 @@ describe('ReportWizardPage', () => {
expect(callArgs[0]).toEqual(expect.objectContaining({ type: 'claim' })); // report
expect(callArgs[1]).toEqual(new Set(['inv-1'])); // includedInvoiceIds
expect(callArgs[2]).toEqual(expect.objectContaining({ isOverview: false })); // effectiveContent
- expect(callArgs[3]).toEqual({ attachDocuments: true }); // default attachDocuments
+ // #1973: options now also carries hiddenColumns (default: empty Set, nothing hidden).
+ expect(callArgs[3]).toEqual({ attachDocuments: true, hiddenColumns: new Set() });
});
it('opens the PDF preview modal and renders an iframe once generation succeeds', async () => {
@@ -1468,6 +1469,89 @@ describe('ReportWizardPage', () => {
);
});
+ // ─── #1973: column visibility wired through to generateReportPdf ───────────────────────────────
+
+ describe('#1973 column visibility: toggles reach generateReportPdf, and reset on use-case change', () => {
+ function columnToggleGroup(): HTMLElement {
+ return screen.getByRole('group', { name: 'Show/hide columns' });
+ }
+
+ it('(scenario 37) toggling a column via the rendered ReportContentEditor, then Preview PDF, calls generateReportPdf with a hiddenColumns Set containing the toggled column', async () => {
+ mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] });
+ mockGetSourceReport.mockResolvedValue(makeReport());
+ renderPage();
+ const user = userEvent.setup();
+ await goToStep5(user); // useCaseIndex=1 -> 'claim'
+
+ await user.click(within(columnToggleGroup()).getByLabelText('Vendor'));
+ await user.click(screen.getByRole('button', { name: 'Preview PDF' }));
+
+ await waitFor(() => expect(mockGenerateReportPdf).toHaveBeenCalledTimes(1));
+ const options = mockGenerateReportPdf.mock.calls[0]![3] as { hiddenColumns?: Set };
+ expect(options.hiddenColumns).toBeInstanceOf(Set);
+ expect(options.hiddenColumns!.has('vendor')).toBe(true);
+ });
+
+ it('(AC5.2) toggling a column issues no PATCH (or any) request to /api/users/me/preferences — column visibility is per-session only, never persisted', async () => {
+ mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] });
+ mockGetSourceReport.mockResolvedValue(makeReport());
+ renderPage();
+ const user = userEvent.setup();
+ await goToStep5(user);
+
+ // Install the request-interception spy only AFTER reaching step 5 — the page's own mocked
+ // init fetches (config/household/paperless, none of which are preferences-related) already
+ // ran during mount via the jest.unstable_mockModule mocks above, and asserting "no fetch at
+ // all" would conflate those with the thing this AC actually pins: that a column TOGGLE
+ // specifically never reaches a persistence endpoint. Real backend calls unrelated to the
+ // toggle are allowed to no-op through; only a `/preferences` URL fails the test.
+ const originalFetch = globalThis.fetch;
+ const fetchSpy = jest.fn().mockImplementation(async (input) => {
+ if (String(input).includes('/api/users/me/preferences')) {
+ throw new Error('unexpected PATCH to the preferences endpoint from a column toggle');
+ }
+ return new Response('{}', { status: 200 });
+ });
+ globalThis.fetch = fetchSpy;
+ try {
+ for (const label of ['Vendor', 'Invoice No.', 'Usage']) {
+ await user.click(within(columnToggleGroup()).getByLabelText(label));
+ }
+
+ const preferencesCalls = fetchSpy.mock.calls.filter((call) =>
+ String(call[0]).includes('/api/users/me/preferences'),
+ );
+ expect(preferencesCalls).toHaveLength(0);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it('(scenario 38, AC5.1 end-to-end) hiding a column on one use case, then switching use case and returning to step 5, shows every checkbox checked again', async () => {
+ mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] });
+ mockGetSourceReport.mockResolvedValue(makeReport());
+ renderPage();
+ const user = userEvent.setup();
+ await goToStep5(user, 1); // 'claim'
+
+ const vendorBox = within(columnToggleGroup()).getByLabelText('Vendor') as HTMLInputElement;
+ await user.click(vendorBox);
+ expect(vendorBox).not.toBeChecked();
+
+ // Navigate back to step 1 via the desktop stepper nav (clickable up to maxReachedStep=5).
+ await user.click(screen.getByRole('button', { name: 'Report Type' }));
+ await waitFor(() => screen.getByRole('radiogroup'));
+
+ // Select a DIFFERENT use case ('budget-overview', index 0) and walk forward to step 5 again.
+ await goToStep5(user, 0);
+
+ const group = within(columnToggleGroup());
+ for (const box of group.getAllByRole('checkbox')) {
+ expect(box).toBeChecked();
+ }
+ });
+ });
+
// ─── Story #1900: on-demand generation — Download ──────────────────────────────────────────────
describe('on-demand generation: Download (Story #1900)', () => {
diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx
index adc25a24f..4b1079299 100644
--- a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx
+++ b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx
@@ -87,6 +87,7 @@ export function ReportWizardPage() {
overrides,
aiContent,
aiError,
+ hiddenColumns,
reportLanguageOverride,
attachDocuments,
includeCoverLetter,
@@ -312,6 +313,7 @@ export function ReportWizardPage() {
const result = await generateReportPdf(report, includedInvoiceIds, effectiveContent, {
attachDocuments,
+ hiddenColumns,
});
dispatch({ type: 'PDF_GENERATED', payload: { skippedDocuments: result.skippedDocuments } });
@@ -320,7 +322,15 @@ export function ReportWizardPage() {
console.error(err);
return null;
}
- }, [report, useCase, effectiveContent, excludedLineIds, excludedInvoiceIds, attachDocuments]);
+ }, [
+ report,
+ useCase,
+ effectiveContent,
+ excludedLineIds,
+ excludedInvoiceIds,
+ attachDocuments,
+ hiddenColumns,
+ ]);
// Handle preview PDF
const handlePreviewPdf = useCallback(async () => {
@@ -846,6 +856,9 @@ export function ReportWizardPage() {
dispatch({ type: 'SET_OVERRIDE', payload: { key, value } })
}
onFieldReset={(key) => dispatch({ type: 'RESET_OVERRIDE', payload: { key } })}
+ hiddenColumns={hiddenColumns}
+ onToggleColumn={(column) => dispatch({ type: 'TOGGLE_COLUMN', payload: { column } })}
+ attachDocuments={attachDocuments}
t={t}
lang={reportLanguage !== resolvedLocale ? reportLanguage : undefined}
uiLang={reportLanguage !== resolvedLocale ? resolvedLocale : undefined}
diff --git a/client/src/pages/ReportWizardPage/wizardReducer.test.ts b/client/src/pages/ReportWizardPage/wizardReducer.test.ts
index 31bb62a25..09c01c8d1 100644
--- a/client/src/pages/ReportWizardPage/wizardReducer.test.ts
+++ b/client/src/pages/ReportWizardPage/wizardReducer.test.ts
@@ -4,8 +4,8 @@
* Pure unit tests — no React rendering, no jsdom, no module mocks.
* All tests operate on the reducer, factories, and selectors directly.
*
- * Coverage: createInitialWizardState, wizardReducer (all 20 action types),
- * isGeneratingAi, hasManualEdits, isDirty, isGeneratingOnly.
+ * Coverage: createInitialWizardState, wizardReducer (all 22 action types, including #1973's
+ * TOGGLE_COLUMN), isGeneratingAi, hasManualEdits, isDirty, isGeneratingOnly.
*
* Story #1947 / Bug #1943 regression tests are in Group 17.
*/
@@ -147,12 +147,16 @@ describe('Tier factories (via createInitialWizardState)', () => {
expect(state.skippedDocuments).toEqual([]);
});
- it('freshContentTier shape: aiContent=null, aiRequestId=null, aiError="", overrides={}', () => {
+ it('freshContentTier shape: aiContent=null, aiRequestId=null, aiError="", overrides={}, hiddenColumns=empty Set', () => {
const state = createInitialWizardState(null);
expect(state.aiContent).toBeNull();
expect(state.aiRequestId).toBeNull();
expect(state.aiError).toBe('');
expect(state.overrides).toEqual({});
+ // #1973: hiddenColumns joined ContentTier — R5 co-locates it with overrides for
+ // reset-on-use-case-change purposes.
+ expect(state.hiddenColumns).toBeInstanceOf(Set);
+ expect(state.hiddenColumns.size).toBe(0);
});
});
@@ -245,6 +249,18 @@ describe('SELECT_USE_CASE', () => {
expect(next.aiError).toBe('');
});
+ it('(#1973 R5) resets hiddenColumns to empty, even when the prior state had columns hidden — a hidden-Status selection on a budget-overview report must not survive a switch to claim', () => {
+ const state = makeState({
+ hiddenColumns: new Set(['status', 'vendor']),
+ });
+ const next = wizardReducer(state, {
+ type: 'SELECT_USE_CASE',
+ payload: { useCase: 'claim', step2RequestId: 'req-1' },
+ });
+ expect(next.hiddenColumns).toBeInstanceOf(Set);
+ expect(next.hiddenColumns.size).toBe(0);
+ });
+
it('preserves SettingsTier fields', () => {
const state = makeState({
attachDocuments: false,
@@ -701,6 +717,73 @@ describe('DISCARD_EDITS', () => {
expect(next.aiRequestId).toBeNull();
expect(next.aiError).toBe('persistent error'); // preserved because aiRequestId was null
});
+
+ it('(#1973) PRESERVES hiddenColumns across the call — the one deliberately asymmetric case: column visibility is a presentation choice, not a discardable content edit, so a future refactor must not "fix" this back to clearing it alongside overrides/aiContent', () => {
+ const state = makeState({
+ overrides: { key: 'val' },
+ aiContent: makeAiResult(),
+ hiddenColumns: new Set(['vendor', 'usage']),
+ });
+ const next = wizardReducer(state, { type: 'DISCARD_EDITS' });
+ // Sanity: overrides/aiContent DID clear, same as the tests above.
+ expect(next.overrides).toEqual({});
+ expect(next.aiContent).toBeNull();
+ // hiddenColumns did NOT clear.
+ expect(next.hiddenColumns).toEqual(new Set(['vendor', 'usage']));
+ });
+});
+
+// ─── Group 13b: TOGGLE_COLUMN (#1973) ─────────────────────────────────────────
+
+describe('TOGGLE_COLUMN', () => {
+ it('toggling an unhidden column adds it to hiddenColumns', () => {
+ const state = makeState({ hiddenColumns: new Set() });
+ const next = wizardReducer(state, {
+ type: 'TOGGLE_COLUMN',
+ payload: { column: 'vendor' },
+ });
+ expect(next.hiddenColumns).toEqual(new Set(['vendor']));
+ });
+
+ it('toggling an already-hidden column removes it from hiddenColumns', () => {
+ const state = makeState({ hiddenColumns: new Set(['vendor']) });
+ const next = wizardReducer(state, {
+ type: 'TOGGLE_COLUMN',
+ payload: { column: 'vendor' },
+ });
+ expect(next.hiddenColumns).toEqual(new Set());
+ });
+
+ it('toggling two different columns independently does not affect each other', () => {
+ const state = makeState({ hiddenColumns: new Set() });
+ const afterVendor = wizardReducer(state, {
+ type: 'TOGGLE_COLUMN',
+ payload: { column: 'vendor' },
+ });
+ const afterBoth = wizardReducer(afterVendor, {
+ type: 'TOGGLE_COLUMN',
+ payload: { column: 'usage' },
+ });
+ expect(afterBoth.hiddenColumns).toEqual(new Set(['vendor', 'usage']));
+
+ // Un-hiding vendor leaves usage hidden, untouched.
+ const afterUnhideVendor = wizardReducer(afterBoth, {
+ type: 'TOGGLE_COLUMN',
+ payload: { column: 'vendor' },
+ });
+ expect(afterUnhideVendor.hiddenColumns).toEqual(new Set(['usage']));
+ });
+
+ it("does not mutate the previous state's hiddenColumns Set (new Set returned each time)", () => {
+ const priorHidden = new Set<'vendor'>(['vendor']);
+ const state = makeState({ hiddenColumns: priorHidden });
+ const next = wizardReducer(state, {
+ type: 'TOGGLE_COLUMN',
+ payload: { column: 'usage' },
+ });
+ expect(priorHidden).toEqual(new Set(['vendor'])); // untouched
+ expect(next.hiddenColumns).not.toBe(priorHidden);
+ });
});
// ─── Group 14: GO_TO_STEP ─────────────────────────────────────────────────────
diff --git a/client/src/pages/ReportWizardPage/wizardReducer.ts b/client/src/pages/ReportWizardPage/wizardReducer.ts
index 33afdce99..e7db935bd 100644
--- a/client/src/pages/ReportWizardPage/wizardReducer.ts
+++ b/client/src/pages/ReportWizardPage/wizardReducer.ts
@@ -4,7 +4,7 @@ import type {
GenerateReportContentResponse,
} from '@cornerstone/shared';
import type { ResolvedLocale } from '../../contexts/LocaleContext.js';
-import type { ReportContentOverrides } from '../../lib/reportContent/index.js';
+import type { ReportColumnKey, ReportContentOverrides } from '../../lib/reportContent/index.js';
import type { SkippedDocument } from '../../lib/reportPdf/index.js';
export interface SelectionTier {
@@ -32,6 +32,9 @@ export interface ContentTier {
aiContent: GenerateReportContentResponse | null;
aiRequestId: string | null;
aiError: string;
+ /** R5: per-wizard-run only, resets on use-case change (via freshContentTier()), never
+ * persisted to a preference endpoint. */
+ hiddenColumns: Set;
}
export interface SettingsTier {
@@ -78,7 +81,13 @@ function freshReportTier(): ReportTier {
}
function freshContentTier(): ContentTier {
- return { overrides: {}, aiContent: null, aiRequestId: null, aiError: '' };
+ return {
+ overrides: {},
+ aiContent: null,
+ aiRequestId: null,
+ aiError: '',
+ hiddenColumns: new Set(),
+ };
}
function freshSettingsTier(): SettingsTier {
@@ -118,7 +127,8 @@ export type WizardAction =
| { type: 'AI_GENERATION_BLOCKED'; payload: { error: string } }
| { type: 'DISCARD_EDITS' }
| { type: 'GO_TO_STEP'; payload: { step: number } }
- | { type: 'PDF_GENERATED'; payload: { skippedDocuments: SkippedDocument[] } };
+ | { type: 'PDF_GENERATED'; payload: { skippedDocuments: SkippedDocument[] } }
+ | { type: 'TOGGLE_COLUMN'; payload: { column: ReportColumnKey } };
export function createInitialWizardState(sourceIdFromQuery: string | null): WizardState {
return {
@@ -259,9 +269,14 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
case 'DISCARD_EDITS':
// M-I: spread freshContentTier() for AC4 enforcement, then override aiError conditionally.
+ // hiddenColumns is explicitly PRESERVED here (not discarded with overrides/aiContent):
+ // column visibility is a presentation choice, not a "content edit" — R5 co-locates it with
+ // `overrides` on ContentTier for reset-on-use-case-change purposes only, not to make it
+ // discardable together with text edits (#1973).
return {
...state,
...freshContentTier(),
+ hiddenColumns: state.hiddenColumns,
aiError: state.aiRequestId !== null ? '' : state.aiError,
};
@@ -275,6 +290,13 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
case 'PDF_GENERATED':
return { ...state, skippedDocuments: action.payload.skippedDocuments };
+ case 'TOGGLE_COLUMN': {
+ const next = new Set(state.hiddenColumns);
+ if (next.has(action.payload.column)) next.delete(action.payload.column);
+ else next.add(action.payload.column);
+ return { ...state, hiddenColumns: next };
+ }
+
default:
return (action satisfies never, state);
}
diff --git a/client/src/styles/shared.module.css b/client/src/styles/shared.module.css
index f1801b907..e534aef52 100644
--- a/client/src/styles/shared.module.css
+++ b/client/src/styles/shared.module.css
@@ -443,6 +443,21 @@
font-size: var(--font-size-sm);
}
+/**
+ * Inline warning banner — amber-tinted, displayed for a non-blocking, reversible
+ * caution the user should be aware of before proceeding. Not for errors (use
+ * .bannerError) and not for actions that cannot be undone (use MassMoveModal's
+ * icon+heading warningBlock pattern for those).
+ */
+.bannerWarning {
+ background-color: var(--color-warning-bg);
+ border: 1px solid var(--color-warning);
+ border-radius: var(--radius-md);
+ color: var(--color-warning-text-on-light);
+ padding: var(--spacing-3);
+ font-size: var(--font-size-sm);
+}
+
/* ============================================================
* SCREEN READER ONLY
* ============================================================ */
diff --git a/e2e/pages/ReportWizardPage.ts b/e2e/pages/ReportWizardPage.ts
index 88ab181e3..48125e2ff 100644
--- a/e2e/pages/ReportWizardPage.ts
+++ b/e2e/pages/ReportWizardPage.ts
@@ -487,6 +487,18 @@ export class ReportWizardPage {
readonly footnotesBlock: Locator;
readonly footnoteItems: Locator;
+ // Issue #1973: column visibility is now wired through to the generated PDF (previously
+ // preview-only local state, #1966). `columnToggleGroup` is the single `role="group"` rendered
+ // ABOVE both the desktop `` and the mobile `.mobileCardList` — it is NOT duplicated or
+ // viewport-gated (verified on disk: no `@media` rule touches `.columnToggles`/`.columnToggleGroup`
+ // in `ReportContentEditor.module.css`), so the same locator is valid at every configured
+ // viewport. Individual checkboxes are reached via `columnToggleGroup.getByLabel()`.
+ // `usageHiddenAttachmentsWarning` is the AC 6.2 warning banner (`sharedStyles.bannerWarning`,
+ // `role="status"`), scoped by its CSS-module class rather than by text so it survives copy
+ // edits and is unambiguous against the page's other `role="status"` regions (e.g. `Toast`).
+ readonly columnToggleGroup: Locator;
+ readonly usageHiddenAttachmentsWarning: Locator;
+
// Claim confirm modal
readonly claimConfirmModal: Locator;
readonly claimConfirmModalBody: Locator;
@@ -625,6 +637,10 @@ export class ReportWizardPage {
this.footnotesBlock = page.locator('[class*="footnotes"]');
this.footnoteItems = this.footnotesBlock.locator('li');
+ // Issue #1973.
+ this.columnToggleGroup = page.getByRole('group', { name: 'Show/hide columns' });
+ this.usageHiddenAttachmentsWarning = page.locator('[class*="bannerWarning"]');
+
this.claimConfirmModal = page.getByRole('dialog', { name: 'Mark Invoices as Claimed?' });
this.claimConfirmModalBody = this.claimConfirmModal.locator('p');
this.claimConfirmConfirmButton = this.claimConfirmModal.locator('[class*="btnPrimary"]');
diff --git a/e2e/tests/budget/reportWizardEditableContent.spec.ts b/e2e/tests/budget/reportWizardEditableContent.spec.ts
index 2a2600bcd..18a8f8034 100644
--- a/e2e/tests/budget/reportWizardEditableContent.spec.ts
+++ b/e2e/tests/budget/reportWizardEditableContent.spec.ts
@@ -120,6 +120,35 @@
* reset-button fix (glyph sizing only, unchanged `resetButton` className/DOM structure) never
* broke the existing `resetButtonFor`/`hasEditedIndicator` POM locators.
*
+ * Issue #1973 (column visibility wired through to the generated PDF, supersedes #1966): the
+ * `hiddenColumns` selection used to be `ReportContentEditor` local state that never left the
+ * component (preview-only, #1966) — it now lives in `ReportWizardPage`'s wizard-reducer state
+ * and is threaded through to `generatePdfFromContent`/`overviewPdf.ts`, so hiding a column
+ * actually changes the downloaded/uploaded/previewed PDF, not just the on-screen preview. See
+ * `ReportWizardPage.ts`'s "Issue #1973" docstring paragraph for `columnToggleGroup`/
+ * `usageHiddenAttachmentsWarning`.
+ * - Scenario 28: The DOM-level baseline carried forward from #1966 (AC 7.1) — every column
+ * checkbox present by accessible name, unticking removes both the `` and matching ` `,
+ * re-ticking restores both, and no toggle ever issues a preference PATCH (AC 5.2). Desktop
+ * only, by documented exclusion (AC 7.3) — the `columnheader`/`cell` ARIA-role assertions
+ * require real `` semantics, absent from the mobile `.mobileCardList` fallback.
+ * - Scenario 29: THE scenario that closes the #1966 gap (AC 1.2/7.2) — hiding the Usage column
+ * (seeded with substantial text) produces a measurably SMALLER downloaded PDF than a baseline
+ * download with Usage visible, proving the toggle reaches PDF generation and not only the
+ * preview DOM. A bare non-trivial-size check (Scenario 8's shape) would not catch a regression
+ * back to preview-only behavior.
+ * - Scenario 30: AC 2.2 — the Allocated Amount checkbox is disabled with a resolvable, non-empty
+ * `aria-describedby`, and a forced click cannot uncheck it. All three viewports (AC 7.3).
+ * - Scenario 31: AC 6.2 — the Usage-hidden-with-attachments warning banner renders in exactly
+ * one of the four (Usage hidden/visible) × (attachDocuments on/off) combinations. All three
+ * viewports (AC 7.3).
+ * - Scenario 32: AC 5.1 — hiding a column, then switching use case (`claim` 6 columns →
+ * `budget-overview` 7 columns), restores the full base set for the NEW use case. All three
+ * viewports (AC 7.3).
+ * - Scenario 33: AC 5.3 — hiding a column, then reloading the wizard on the same `?sourceId=`
+ * deep link, restores the full base set rather than the previously-hidden state. All three
+ * viewports (AC 7.3).
+ *
* PDF generation (pdfmake + pdf-lib via dynamic `import()`) can be slow, especially on a cold
* chunk load — every scenario that opens the preview modal, downloads, or uploads uses
* `test.slow()`. As established in Scenario 8's own note, this project has no PDF-text-extraction
@@ -2304,26 +2333,38 @@ test.describe('Report wizard editable content — signature field reset (Scenari
});
// ─────────────────────────────────────────────────────────────────────────────
-// Scenario 24: Column-visibility toggles — local state, no persistence (#1966)
+// Scenario 28: Column-visibility toggles — DOM baseline (Issue #1973, supersedes #1966)
// ─────────────────────────────────────────────────────────────────────────────
//
-// ReportContentEditor renders a `role="group"` labelled "Show/hide columns" above the summary
-// table. Checkboxes control per-column visibility using local `useState` only — the PDF always
-// includes every column regardless of toggle state. This scenario asserts:
+// Issue #1973 lifted `hiddenColumns` out of `ReportContentEditor` local state into
+// `ReportWizardPage` wizard-reducer state and threaded it through to PDF generation — the
+// checkboxes are no longer preview-only (that was #1966's premise, now superseded, not amended;
+// see this file's class-level docstring and `wizardReducer.ts`'s `ContentTier.hiddenColumns`).
+// This scenario is the DOM-level baseline carried forward verbatim from #1966 (AC 7.1):
// AC1: every column checkbox is present and locatable by accessible name;
// AC2: the rendered checkbox count equals the component-defined toggleable-column count;
-// AC3: toggling fires no PATCH to /api/users/me/preferences (local state, not persisted);
-// AC4: coverage runs at desktop viewport only (no `@responsive` tag) — the toggle group and
-// checkboxes are always visible regardless of viewport, but the `` removal assertion
-// uses `getByRole('columnheader')` which requires elements in the accessibility tree;
-// the table is CSS-hidden on mobile (`max-width: 767px → .table { display: none }`), so
-// `columnheader` assertions would fail at mobile. The mobile card layout is tested in
-// other scenarios that carry `@responsive`.
+// AC5.2: toggling fires no PATCH to /api/users/me/preferences (R5: per-run only, never
+// persisted — carried forward from #1966 AC3 with updated rationale: the toggles now
+// DO reach the PDF, but still must not reach the server);
+// the corresponding AND every matching are absent from the DOM when hidden, and
+// restored when re-ticked.
+// The PDF-level consequence (AC 1.2/7.2 — this is the specific gap that made #1966
+// insufficient) is asserted separately in Scenario 29 below, since it needs a real download
+// round trip rather than a DOM read.
+//
+// Viewport scope (AC 7.3): desktop only, by explicit exclusion with reason, not silently. The
+// toggle group and checkboxes are visible at every viewport (verified on disk: no `@media` rule
+// in `ReportContentEditor.module.css` touches `.columnToggles`), but THIS scenario's ` `/
+// ` ` removal assertions use `getByRole('columnheader')`/`getByRole('cell')`, which require
+// real `` semantics — the desktop `` is CSS-hidden below 768px, replaced by a
+// `div`/`span`-based `.mobileCardList` with no table roles at all. Scenarios 30-33 below cover
+// the checkbox-state behaviors (locked column, warning banner, use-case reset, reload reset) at
+// all three configured viewports per AC 7.3, since those assertions don't depend on table roles.
//
// Uses `budget-overview` (7 columns incl. Status) to exercise the `content.isOverview` branch
// in ReportContentEditor's column list — a claim report would render 6 columns.
-test.describe('Report wizard editable content — column-visibility toggles, local state (Scenario 24, #1966)', () => {
+test.describe('Report wizard editable content — column-visibility toggles reach the PDF (Scenario 28, Issue #1973)', () => {
// toggleable columns for budget-overview in insertion order (matches component source)
const OVERVIEW_COLUMNS = [
'Vendor',
@@ -2365,7 +2406,7 @@ test.describe('Report wizard editable content — column-visibility toggles, loc
await reachStep5(wizard, sourceId, 'budget-overview');
// ── AC1 + AC2: group present, every checkbox visible and checked, count matches component ──
- const columnGroup = page.getByRole('group', { name: 'Show/hide columns' });
+ const columnGroup = wizard.columnToggleGroup;
await expect(columnGroup).toBeVisible();
const checkboxes = columnGroup.getByRole('checkbox');
@@ -2379,7 +2420,7 @@ test.describe('Report wizard editable content — column-visibility toggles, loc
await expect(columnGroup.getByLabel(label)).toBeChecked();
}
- // ── AC3: intercept preference writes ──
+ // ── AC5.2: intercept preference writes ──
// Note: API is an object (`testData.ts`), so `${API}/...` would expand to
// `[object Object]/...` and never match. Use the glob form instead.
const prefPatches: string[] = [];
@@ -2428,7 +2469,7 @@ test.describe('Report wizard editable content — column-visibility toggles, loc
await expect(vendorHeader).toHaveCount(1);
await expect(vendorCell).toHaveCount(1);
- // AC3: no preference PATCH was issued during any column toggle
+ // AC5.2: no preference PATCH was issued during any column toggle
expect(prefPatches, 'column toggle must not write to /api/users/me/preferences').toHaveLength(
0,
);
@@ -2440,6 +2481,381 @@ test.describe('Report wizard editable content — column-visibility toggles, loc
});
});
+// ─────────────────────────────────────────────────────────────────────────────
+// Scenario 29: Column visibility reaches the generated PDF, not only the preview DOM
+// (AC 1.2 / 7.2, Issue #1973)
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// This is the scenario that actually closes the #1966 gap: a test that only checks the DOM (as
+// Scenario 28 above does, correctly, for the preview surface) would pass identically whether or
+// not the toggle reaches PDF generation — that gap is exactly what #1966 shipped and #1973 exists
+// to close. As established in Scenario 8's own note (and Story #1879/#1899's boundary), this
+// project has no PDF-text-extraction library in its E2E dependencies (no pdf-parse/pdfjs), so
+// reading the literal rendered column text back out of the downloaded bytes is out of scope here
+// (that's `overviewPdf.test.ts`'s job, per the QA spec's AC 4.3). The achievable, still-falsifiable
+// E2E-level proxy is a SIZE-DIFF, not a bare "non-trivial size" check like Scenario 8's: a
+// baseline download (all columns visible) vs. a second download taken after hiding the Usage
+// column (seeded with substantial real text so hiding it removes non-trivial content weight) must
+// produce a MEASURABLY SMALLER file. A "> 1000 bytes" check alone (Scenario 8's assertion) would
+// pass identically regardless of whether the toggle ever reached generation — do not simplify this
+// back to that shape.
+
+test.describe('Report wizard editable content — hiding a column shrinks the downloaded PDF (Scenario 29, AC 1.2/7.2)', () => {
+ test('Downloading after hiding the Usage column produces a measurably smaller PDF than the baseline download with Usage visible', async ({
+ page,
+ testPrefix,
+ }) => {
+ test.slow();
+ const wizard = new ReportWizardPage(page);
+
+ let vendorId = '';
+ let sourceId = '';
+ let workItemId = '';
+ try {
+ vendorId = await createVendorViaApi(page, { name: `${testPrefix} SizeDiff Vendor` });
+ sourceId = await createBudgetSourceViaApi(page, {
+ name: `${testPrefix} SizeDiff Source`,
+ totalAmount: 10000,
+ });
+ workItemId = await createWorkItemViaApi(page, { title: `${testPrefix} WI SizeDiff` });
+ const invoice = await seedAllocatedInvoice(page, workItemId, vendorId, sourceId, {
+ invoiceNumber: `${testPrefix}-SZDF-001`,
+ amount: 400,
+ date: '2026-06-12',
+ status: 'pending',
+ });
+
+ await reachStep5(wizard, sourceId, 'claim');
+ const vendorName = `${testPrefix} SizeDiff Vendor`;
+
+ // Give Usage substantial real content weight so hiding it removes a non-trivial amount of
+ // rendered PDF content (short strings could round-trip through pdfmake's compression with
+ // a difference too small to reliably assert on across environments).
+ const longUsage = Array.from(
+ { length: 40 },
+ (_, i) => `Line item narrative segment ${i} describing the work performed in detail.`,
+ ).join(' ');
+ await wizard.editField(wizard.usageField(vendorName, invoice.invoiceNumber!), longUsage);
+
+ const baselineDownload = await wizard.download();
+ const baselinePath = await baselineDownload.path();
+ expect(baselinePath, 'baseline download must have saved to a local temp file').toBeTruthy();
+ const baselineSize = statSync(baselinePath!).size;
+
+ await wizard.columnToggleGroup.getByLabel('Usage').uncheck();
+ await expect(wizard.columnToggleGroup.getByLabel('Usage')).not.toBeChecked();
+
+ const hiddenDownload = await wizard.download();
+ const hiddenPath = await hiddenDownload.path();
+ expect(
+ hiddenPath,
+ 'hidden-column download must have saved to a local temp file',
+ ).toBeTruthy();
+ const hiddenSize = statSync(hiddenPath!).size;
+
+ // AC 1.2/7.2: the PDF *consequence*, not just the DOM consequence. A test that only
+ // asserted `hiddenSize > 1000` (Scenario 8's shape) would pass identically whether or not
+ // the toggle ever reached `generatePdfFromContent`/`overviewPdf.ts` — the strictly-smaller
+ // comparison against this test's OWN baseline is what actually falls if the wiring regresses
+ // back to #1966's preview-only behavior. Do not weaken this to a non-trivial-size check.
+ expect(hiddenSize).toBeLessThan(baselineSize);
+ } finally {
+ if (workItemId) await deleteWorkItemViaApi(page, workItemId);
+ if (sourceId) await deleteBudgetSourceViaApi(page, sourceId);
+ if (vendorId) await deleteVendorViaApi(page, vendorId);
+ }
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Scenario 30: Allocated Amount column is locked (AC 2.2, Issue #1973)
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// Runs at all three configured viewports (AC 7.3) — the toggle group (and therefore this
+// checkbox) is not viewport-gated.
+
+test.describe(
+ 'Report wizard editable content — Allocated Amount checkbox is locked (Scenario 30, AC 2.2)',
+ { tag: '@responsive' },
+ () => {
+ test('The Allocated Amount checkbox is disabled with a resolvable, non-empty accessible description, and a forced click cannot uncheck it', async ({
+ page,
+ testPrefix,
+ }) => {
+ const wizard = new ReportWizardPage(page);
+
+ let vendorId = '';
+ let sourceId = '';
+ let workItemId = '';
+ try {
+ vendorId = await createVendorViaApi(page, { name: `${testPrefix} Locked Vendor` });
+ sourceId = await createBudgetSourceViaApi(page, {
+ name: `${testPrefix} Locked Source`,
+ totalAmount: 5000,
+ });
+ workItemId = await createWorkItemViaApi(page, { title: `${testPrefix} WI Locked` });
+ await seedAllocatedInvoice(page, workItemId, vendorId, sourceId, {
+ invoiceNumber: `${testPrefix}-LOCK-001`,
+ amount: 250,
+ date: '2026-06-05',
+ status: 'pending',
+ });
+
+ await reachStep5(wizard, sourceId, 'claim');
+
+ const allocatedCheckbox = wizard.columnToggleGroup.getByLabel('Allocated Amount');
+ await expect(allocatedCheckbox).toBeVisible();
+ await expect(allocatedCheckbox).toBeDisabled();
+ await expect(allocatedCheckbox).toBeChecked();
+
+ const describedBy = await allocatedCheckbox.getAttribute('aria-describedby');
+ expect(
+ describedBy,
+ 'the locked checkbox must carry a resolvable aria-describedby',
+ ).toBeTruthy();
+ const hintText = await page.locator(`#${describedBy}`).textContent();
+ expect(hintText?.trim()).not.toBe('');
+
+ // A forced click bypasses Playwright's actionability check (which would otherwise refuse
+ // to interact with a disabled element outright) — this is a genuine behavioral proof the
+ // control is inert, not just a restatement of `toBeDisabled()` above. Browsers do not
+ // deliver interaction-driven state changes to disabled form controls even when a click is
+ // forced onto them, so `uncheck` either no-ops or throws; either way the checkbox must
+ // still be checked afterward.
+ await allocatedCheckbox.uncheck({ force: true }).catch(() => {});
+ await expect(allocatedCheckbox).toBeChecked();
+ } finally {
+ if (workItemId) await deleteWorkItemViaApi(page, workItemId);
+ if (sourceId) await deleteBudgetSourceViaApi(page, sourceId);
+ if (vendorId) await deleteVendorViaApi(page, vendorId);
+ }
+ });
+ },
+);
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Scenario 31: Usage-hidden-with-attachments warning banner (AC 6.2, Issue #1973)
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// All four combinations of (Usage hidden/visible) × (attachDocuments on/off) in a single test,
+// reusing one seeded invoice/source (R4: hiding a column never changes a number, so the same
+// fixture is valid across every combination). Runs at all three configured viewports (AC 7.3).
+// The warning is scoped by its CSS-module class (`usageHiddenAttachmentsWarning`, `[class*=
+// "bannerWarning"]`) rather than by text, per the checklist's "E2E text locators after label
+// changes" guidance — it survives minor English copy edits and is unambiguous against the page's
+// other `role="status"` regions (e.g. `Toast`).
+
+test.describe(
+ 'Report wizard editable content — Usage-hidden attachments warning (Scenario 31, AC 6.2)',
+ { tag: '@responsive' },
+ () => {
+ test('The warning renders only when Usage is hidden AND attach-documents is enabled — absent in all three other combinations', async ({
+ page,
+ testPrefix,
+ }) => {
+ const wizard = new ReportWizardPage(page);
+
+ let vendorId = '';
+ let sourceId = '';
+ let workItemId = '';
+ try {
+ vendorId = await createVendorViaApi(page, { name: `${testPrefix} Warn Vendor` });
+ sourceId = await createBudgetSourceViaApi(page, {
+ name: `${testPrefix} Warn Source`,
+ totalAmount: 5000,
+ });
+ workItemId = await createWorkItemViaApi(page, { title: `${testPrefix} WI Warn` });
+ await seedAllocatedInvoice(page, workItemId, vendorId, sourceId, {
+ invoiceNumber: `${testPrefix}-WARN-001`,
+ amount: 275,
+ date: '2026-06-06',
+ status: 'pending',
+ });
+
+ // attachDocuments defaults to true (freshSettingsTier()).
+ await reachStep5(wizard, sourceId, 'claim');
+
+ // 1) Usage visible + attach on → no warning.
+ await expect(wizard.usageHiddenAttachmentsWarning).toHaveCount(0);
+
+ // 2) Usage hidden + attach on → warning present.
+ await wizard.columnToggleGroup.getByLabel('Usage').uncheck();
+ await expect(wizard.columnToggleGroup.getByLabel('Usage')).not.toBeChecked();
+ await expect(wizard.usageHiddenAttachmentsWarning).toBeVisible();
+
+ // 3) Usage hidden + attach off → no warning (nothing left to warn about). Going back to
+ // Settings and forward again must NOT reset hiddenColumns — R5 co-locates it with
+ // `overrides` on ContentTier for use-case-change resets only (see Scenario 32), and
+ // `SET_ATTACH_DOCUMENTS` touches only `SettingsTier`.
+ await wizard.goBack(); // step 5 -> step 4 (Settings)
+ await wizard.toggleAttachDocuments();
+ await expect(wizard.attachDocumentsCheckbox).not.toBeChecked();
+ await wizard.step4NextButton.click();
+ await expect(wizard.columnToggleGroup.getByLabel('Usage')).not.toBeChecked();
+ await expect(wizard.usageHiddenAttachmentsWarning).toHaveCount(0);
+
+ // 4) Usage visible + attach off → no warning.
+ await wizard.columnToggleGroup.getByLabel('Usage').check();
+ await expect(wizard.columnToggleGroup.getByLabel('Usage')).toBeChecked();
+ await expect(wizard.usageHiddenAttachmentsWarning).toHaveCount(0);
+ } finally {
+ if (workItemId) await deleteWorkItemViaApi(page, workItemId);
+ if (sourceId) await deleteBudgetSourceViaApi(page, sourceId);
+ if (vendorId) await deleteVendorViaApi(page, vendorId);
+ }
+ });
+ },
+);
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Scenario 32: Hidden columns reset on use-case change (AC 5.1, Issue #1973)
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// Runs at all three configured viewports (AC 7.3). Walks all the way back to step 1 via
+// `goBack()` (viewport-independent — the currently-mounted step's own Back button, not the
+// desktop-only stepper's `goToStep()`), switches use case from `claim` (6 columns) to
+// `budget-overview` (7 columns, adding Status), and re-walks forward — proving BOTH that the
+// hidden selection is cleared AND that the restored base set matches the NEW use case's own
+// column count, not the old one's.
+
+test.describe(
+ 'Report wizard editable content — hidden columns reset on use-case change (Scenario 32, AC 5.1)',
+ { tag: '@responsive' },
+ () => {
+ test('Switching use case after hiding a column restores the full base set for the newly selected use case', async ({
+ page,
+ testPrefix,
+ }) => {
+ const wizard = new ReportWizardPage(page);
+
+ let vendorId = '';
+ let sourceId = '';
+ let workItemId = '';
+ try {
+ vendorId = await createVendorViaApi(page, { name: `${testPrefix} Reset Vendor` });
+ sourceId = await createBudgetSourceViaApi(page, {
+ name: `${testPrefix} Reset Source`,
+ totalAmount: 5000,
+ });
+ workItemId = await createWorkItemViaApi(page, { title: `${testPrefix} WI Reset` });
+ await seedAllocatedInvoice(page, workItemId, vendorId, sourceId, {
+ invoiceNumber: `${testPrefix}-RESET-001`,
+ amount: 225,
+ date: '2026-06-07',
+ status: 'pending',
+ });
+
+ await reachStep5(wizard, sourceId, 'claim');
+ await expect(wizard.columnToggleGroup.getByRole('checkbox')).toHaveCount(6);
+ await wizard.columnToggleGroup.getByLabel('Vendor').uncheck();
+ await expect(wizard.columnToggleGroup.getByLabel('Vendor')).not.toBeChecked();
+
+ // Walk all the way back to step 1 (5 -> 4 -> 3 -> 2 -> 1).
+ await wizard.goBack();
+ await wizard.goBack();
+ await wizard.goBack();
+ await wizard.goBack();
+ await expect(wizard.useCaseRadioGroup).toBeVisible();
+
+ await wizard.selectUseCase('budget-overview');
+ await wizard.goNextFromStep1();
+ await wizard.selectSource(sourceId);
+ await wizard.goNextFromStep2();
+ await wizard.goNextFromStep3();
+ await wizard.step4NextButton.click();
+
+ // Full base set for the NEW use case (7 columns incl. Status, not the old 6) — every
+ // checkbox checked, including the one that was hidden before the use-case switch.
+ const checkboxes = wizard.columnToggleGroup.getByRole('checkbox');
+ await expect(checkboxes).toHaveCount(7);
+ const count = await checkboxes.count();
+ for (let i = 0; i < count; i++) {
+ await expect(checkboxes.nth(i)).toBeChecked();
+ }
+ } finally {
+ if (workItemId) await deleteWorkItemViaApi(page, workItemId);
+ if (sourceId) await deleteBudgetSourceViaApi(page, sourceId);
+ if (vendorId) await deleteVendorViaApi(page, vendorId);
+ }
+ });
+ },
+);
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Scenario 33: Hidden columns reset on reload / re-entry (AC 5.3, Issue #1973)
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// Runs at all three configured viewports (AC 7.3). Uses this app's established `?sourceId=`
+// deep-link resume pattern (`reportWizard.spec.ts` Scenario 7 / `ReportWizardPage.goto(sourceId)`)
+// rather than a fresh unparented navigation, so the reload genuinely re-enters the SAME
+// in-progress report rather than starting an unrelated one. `hiddenColumns` lives only in
+// in-memory `useReducer` state (R5 — never persisted), so a full page reload discards it the same
+// way it discards every other wizard-run-scoped field; this scenario proves that directly rather
+// than assuming it from the state architecture.
+
+test.describe(
+ 'Report wizard editable content — hidden columns reset on reload (Scenario 33, AC 5.3)',
+ { tag: '@responsive' },
+ () => {
+ test('Reloading the wizard on the same ?sourceId= deep link restores the full base set instead of the previously-hidden state', async ({
+ page,
+ testPrefix,
+ }) => {
+ const wizard = new ReportWizardPage(page);
+
+ let vendorId = '';
+ let sourceId = '';
+ let workItemId = '';
+ try {
+ vendorId = await createVendorViaApi(page, { name: `${testPrefix} Reload Vendor` });
+ sourceId = await createBudgetSourceViaApi(page, {
+ name: `${testPrefix} Reload Source`,
+ totalAmount: 5000,
+ });
+ workItemId = await createWorkItemViaApi(page, { title: `${testPrefix} WI Reload` });
+ await seedAllocatedInvoice(page, workItemId, vendorId, sourceId, {
+ invoiceNumber: `${testPrefix}-RELOAD-001`,
+ amount: 235,
+ date: '2026-06-08',
+ status: 'pending',
+ });
+
+ // Reach step 5 via the ?sourceId= deep-link flow (goto(sourceId) keeps the query param
+ // in the URL across the reload below — see reportWizard.spec.ts Scenario 7).
+ await wizard.goto(sourceId);
+ await wizard.selectUseCase('claim');
+ await wizard.goNextFromStep1();
+ await wizard.goNextFromStep2();
+ await wizard.goNextFromStep3();
+ await wizard.step4NextButton.click();
+
+ await wizard.columnToggleGroup.getByLabel('Vendor').uncheck();
+ await expect(wizard.columnToggleGroup.getByLabel('Vendor')).not.toBeChecked();
+
+ // Reload the same URL (still carrying ?sourceId=) and re-walk the deep-link flow.
+ await page.reload();
+ await expect(wizard.useCaseRadioGroup).toBeVisible();
+ await wizard.selectUseCase('claim');
+ await wizard.goNextFromStep1();
+ await wizard.goNextFromStep2();
+ await wizard.goNextFromStep3();
+ await wizard.step4NextButton.click();
+
+ const checkboxes = wizard.columnToggleGroup.getByRole('checkbox');
+ await expect(checkboxes).toHaveCount(6);
+ const count = await checkboxes.count();
+ for (let i = 0; i < count; i++) {
+ await expect(checkboxes.nth(i)).toBeChecked();
+ }
+ } finally {
+ if (workItemId) await deleteWorkItemViaApi(page, workItemId);
+ if (sourceId) await deleteBudgetSourceViaApi(page, sourceId);
+ if (vendorId) await deleteVendorViaApi(page, vendorId);
+ }
+ });
+ },
+);
+
// ─────────────────────────────────────────────────────────────────────────────
// Scenario 25: lang attribute on the report table when report language
// differs from the UI locale (Issue #1910)
diff --git a/wiki b/wiki
index da1324b6c..eb247743c 160000
--- a/wiki
+++ b/wiki
@@ -1 +1 @@
-Subproject commit da1324b6cfd52add0a46feb386de3b2dd00e1bb5
+Subproject commit eb247743c75495ecdb85e15206021d296a1891be