Skip to content

fix(reports): route usage-cell grey meta suffix through per-token runs - #2002

Merged
steilerDev merged 5 commits into
betafrom
fix/1968-usage-cell-overflow
Aug 5, 2026
Merged

fix(reports): route usage-cell grey meta suffix through per-token runs#2002
steilerDev merged 5 commits into
betafrom
fix/1968-usage-cell-overflow

Conversation

@steilerDev

Copy link
Copy Markdown
Owner

Summary

  • Emitting the grey areaText/attachmentsNote suffix as a single pdfmake run left any unbroken token wider than the Usage column without overflow protection; now routes through buildUsageTextRuns so break-all applies per token, matching the body prose treatment.
  • Relaxes the splitUsageCell test-helper invariant from "exactly one grey run" to "all grey runs contiguous at the tail", concatenating their text so all existing metaRun.text assertions remain unchanged.
  • Removes the grey suffix from the recorded-not-fixed channel enumeration (three → two channels) and updates the buildUsageCell function comment.

Fixes #1968

Test plan

  • All 95 existing overviewPdf tests pass unchanged
  • Lint clean in modified files; pre-existing tech-debt warnings untouched
  • Trailer verification passes

🤖 Generated with Claude Code

steilerDev and others added 3 commits August 4, 2026 20:25
#1968)

The grey areaText/attachmentsNote suffix in the Usage column was emitted
as a single pdfmake run, leaving any unbroken token wider than the column
unprottected. Route it through buildUsageTextRuns (applying
DEPOSIT_NOTE_TEXT_COLOR to each resulting run) to match the break-all
treatment already applied to the body prose.

Also updates:
- splitUsageCell test helper: relaxes the "exactly one grey run" invariant
  to "all grey runs are contiguous at the tail", concatenating their text
  so all existing metaRun.text assertions remain unchanged
- Channel enumeration comment: removes the grey suffix from the
  recorded-not-fixed list (three → two channels)
- buildUsageCell function comment: describes the new multi-run behaviour

Fixes #1968

Co-Authored-By: Claude frontend-developer <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
…pread rule

Content is a union that includes string; spreading r: Content fails the
TypeScript "spread types may only be created from object types" check.
Use Object.assign instead to merge the color property.

Co-Authored-By: Claude frontend-developer <noreply@anthropic.com>
…-run invariant (#1968)

realRender.test.ts has its own splitUsageCell helper with the same
strict one-grey-run invariant. Relax to "contiguous grey runs at the
tail", concatenating their text into metaRaw/metaText so all existing
assertions remain unchanged.

Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
@steilerDev

Copy link
Copy Markdown
Owner Author

[ux-designer]

Design Review — PR #2002

Scope qualification

This PR touches only client/src/lib/reportPdf/ — pdfmake document generation, no CSS, design tokens, CSS Modules, browser components, dark mode, or responsive layout in scope. The standard token-adherence / dark-mode / responsive checklist does not apply. The only meaningful design question is whether the printed PDF artifact remains visually consistent.

Visual consistency (core review question)

Before: body prose went through buildUsageTextRuns (per-token break-all protection on oversized tokens, whitespace-only wrapping elsewhere); the grey meta suffix was emitted as a single run — { text, color: DEPOSIT_NOTE_TEXT_COLOR } — with no overflow protection.

After: both body prose and grey meta suffix go through buildUsageTextRuns with the same usageSafeTokenChars threshold, then each returned meta run gets Object.assign({}, r, { color: DEPOSIT_NOTE_TEXT_COLOR }).

This makes the grey suffix more consistent with body prose, not less. The only pre-fix asymmetry was the missing word-break protection on the suffix; that gap is now closed.

Object.assign correctness

Object.assign({}, r, { color: DEPOSIT_NOTE_TEXT_COLOR }) spreads all existing run properties (including wordBreak: 'break-all' on oversized tokens) and then overrides with color. wordBreak is never clobbered. Trace:

  • Short token: { text: t }{ text: t, color: '#6b7280' } — identical grey coloring to before
  • Long token: { text: t, wordBreak: 'break-all' }{ text: t, wordBreak: 'break-all', color: '#6b7280' } — break-all preserved alongside the grey color
  • Fallback (empty input): [{ text: '' }]{ text: '', color: '#6b7280' } — harmless empty grey run, same as the previous single-run approach

Color value

DEPOSIT_NOTE_TEXT_COLOR = '#6b7280' (verified in shared.ts:15) — unchanged from before. No new color introduced. Hardcoded hex is the established convention for pdfmake output; CSS custom properties don't apply here.

Font and size

Both body and meta run under the 'tableCell' style (8pt body font). The suffix continues to override color only — no font-size or line-height difference from before or from body runs.

Test helper updates

splitUsageCell in both overviewPdf.test.ts and realRender.test.ts correctly relaxes the "at most one grey run" invariant to "N contiguous grey runs forming the tail of the run array." The two new assertions are well-chosen:

  1. lastGrey !== runs.length - 1 — the grey block must be the tail (not interleaved into body prose)
  2. lastGrey - firstGrey !== greyIndexes.length - 1 — the grey block must be contiguous (no body-color gap within it)

Both are structural invariants the production code genuinely maintains, and the reassembly of metaRun.text = greyIndexes.map(i => runs[i].text).join('') faithfully reconstructs the full suffix string.

Render note (informational)

Per my review protocol for reportPdf/ PRs, a render-and-rasterize via a scratch Jest test + pdftoppm would confirm the edge-case visually (long token in grey suffix breaking mid-character). For this specific fix, code inspection is sufficient for approval — the color application and wordBreak preservation are deterministic and fully traceable above. A render is most valuable when the behavior is ambiguous from code alone; it is not ambiguous here.

Findings

No findings. Nothing flagged.


VERDICT: APPROVED

@steilerDev

Copy link
Copy Markdown
Owner Author

[product-architect]

Architecture/contract review of PR #2002 (fix/1968-usage-cell-overflow).

Summary

The production change is correct — I verified the behaviour against pdfmake's own source rather than trusting the types, and the two questions I expected to be risky (per-run wordBreak + color coexistence, and the '\n' separator surviving its own run) both come out clean. The comment update is correct and actually repairs a pre-existing inconsistency. The test-helper relaxation is sound.

The blocker is coverage: this PR relaxed the only invariant that observed run structure and added nothing that observes the new structure. Reverting the fix leaves all 168 tests green.


Blocking

H1 — The fix has zero regression coverage; issue AC1 is not satisfied

No test in this PR (or on beta) exercises a meta suffix containing a single unbroken token wider than the Usage column. Existing areaText fixtures are either short ('Ground Floor') or long-but-multi-token (twentyLeafAreaText(), comma-joined). Revert overviewPdf.ts:700-703 to the pre-fix runs.push({ text, color: DEPOSIT_NOTE_TEXT_COLOR }) and every one of the 95 + 73 tests still passes, because:

  • a single grey run trivially satisfies the new contiguous-at-tail check, and
  • both splitUsageCell helpers now discard the per-run wordBreak flag — overviewPdf.test.ts:204-207 synthesizes { text: <joined>, color: GREY }, and realRender.test.ts:330 keeps only the joined metaRaw text.

So the property the fix establishes is currently unobservable by any assertion. #1968 AC1 requires the no-overflow property measured against the real embedded font, and AC2 requires the all-tokens-fit case to be byte-unchanged — neither is asserted. This also trips the repo's test-file-parity rule (modified production file, no new test).

The fixtures and helpers to do this already exist, so the ask is small:

  1. realRender.test.ts — add a case feeding WORST_CASE_TOKENS.allCapsGermanCompound (or .mwRun) as areaText through the existing renderCellScopeRow helper (see the case at ~line 2475), then assert on the raw grey runs that at least one carries wordBreak: 'break-all' and that concatenating every grey run reproduces the areaText verbatim — mirroring the assertions at realRender.test.ts:1920-1930.
  2. overviewPdf.test.ts — add a unit case with an over-wide single-token areaText/attachmentsNote asserting the raw cell runs carry both color: GREY and wordBreak: 'break-all' on the oversized token; and a companion asserting a short areaText produces no wordBreak on any grey run (that is AC2's unchanged-baseline half, and it is the assertion that keeps the threshold honest).
  3. Either extend both splitUsageCell helpers to additionally return the raw grey-run array, or read cell.text directly in the new tests. What must not stand is wordBreak being unobservable in a module whose entire point is per-token break-all.

Non-blocking

M1 — as Content is unnecessary (medium)

Verified with tsc --strict: { ...r, color } where r: Content does fail with TS2698 "Spread types may only be created from object types" (so the diagnosis behind commit dcad620f is right), but Object.assign({}, r, { color: DEPOSIT_NOTE_TEXT_COLOR }) is already assignable to Content with no assertion at all. Drop the as Content at overviewPdf.ts:702. An unneeded type assertion in this module is a liability — it will silently absorb a future shape mismatch instead of failing the build.

M2 — A cleaner type-safe alternative, if you want one (medium)

Narrowing buildUsageTextRuns' return type to its actual shape removes both the spread restriction and the cast. Verified compiling under --strict:

type TextRun = { text: string; wordBreak?: 'break-all' };
export function buildUsageTextRuns(text: string, safeTokenCharsForColumn: number): TextRun[]
// then, with no cast and no Object.assign:
runs.push(...metaRuns.map((r) => ({ ...r, color: DEPOSIT_NOTE_TEXT_COLOR })));

TextRun[] is assignable to Content[], so buildHeaderCell, the vendor/usage cells, and the tests' widening casts all keep compiling. It also stops the signature over-promising: the current Content[] return type advertises that this helper might return a column/table/stack node, which it never can. Optional — Object.assign is correct as written.

L1 — Two stale doc comments left behind (low)

This PR is already editing both helper comment blocks, so these belong in the same change:

  • overviewPdf.test.ts:162-169 — "they are appended to the SAME Usage cell ... as one trailing grey run prefixed with '\n'".
  • realRender.test.ts:289-294 — "a long suffix now spans SEVERAL rows — one grey run per row, always the last run of its own cell."

Both now describe one-or-more contiguous grey runs.

L2 — Record the font-size coupling (low)

The meta suffix inherits tableCell's TABLE_BODY_FONT_SIZE (8pt, pageGeometry.ts:104-106) — it deliberately does not set DEPOSIT_NOTE_FONT_SIZE the way the deposit/split labels in the AllocatedAmount cell do (overviewPdf.ts:720-740). That is why usageSafeTokenChars, derived from BODY_WORST_CASE_CHAR_WIDTH_PT, is the right budget here. Worth one line in buildUsageCell's comment: if anyone later adds fontSize: DEPOSIT_NOTE_FONT_SIZE to these runs to match those labels, the threshold stops matching the rendered size.


Verified correct

Q2 — comment update. Correct, and it fixes a pre-existing inconsistency: the block header at overviewPdf.ts:451 already said "the two exceptions called out at the end" on beta while the tail list enumerated three. Dropping the grey-suffix bullet makes header and list agree. The per-channel entry (468-474) and the tail list (485-489) are now mutually consistent, with invoiceNumber and markerText as the two remaining channels.

Q3 — invariant relaxation. Correct, and still load-bearing. buildUsageCell appends per-segment in order and packUsageCellRows only ever emits meta segment(s) after a packed row's body segments, so grey-at-tail holds; every run produced from a meta segment gets the colour, so the grey block cannot have internal gaps. The relaxed check would still catch an interleaved meta segment or a body run mis-coloured grey — it is weaker only in the one dimension the fix deliberately changed, which is the right way to relax an invariant. Cosmetic nit: run the contiguity check before the tail check so the message is more specific when both fail.

Q4 — meta-run colouring edge cases. Checked against pdfmake's source, no defect found:

  • TextBreaker.getBreaks (node_modules/pdfmake/src/TextBreaker.js:124-127) resolves wordBreak per item via StyleContextStack.getStyleProperty, and copyStyle(item) copies color onto every produced word — so wordBreak: 'break-all' and color coexist on one run correctly. Object.assign preserves wordBreak (own enumerable prop), so a flagged token keeps its flag after colouring.
  • The '\n' separator now lives in a run of its own, and it survives. splitWords('\n') yields { text: '', lineEnd: true } (TextBreaker.js:43-46), and getLastWord returns null when the last word has lineEnd (TextBreaker.js:88-90), so the cross-run noNewLine merge at TextBreaker.js:135-141 is skipped after it. Line.addInline sets newLineForced and isForceContinue stays false, so the suffix still starts on its own line. Calling this out explicitly because it was the one way this change could have gone wrong invisibly: had noNewLine landed on the '\n' word, LayoutBuilder.js:1388's || isForceContinue would have pulled the following token onto the same line and silently swallowed the separator — and no current test would have caught that either (same root cause as H1).
  • Empty-run behaviour is unchanged: a meta segment whose text reduces to '' still emits exactly one grey run (buildUsageTextRuns returns [{ text: '' }]), same as before, so AC3 is unaffected in both directions.

Architecture/contract compliance. No schema, API-contract, or ADR surface is touched. ADR-034's width rule (max(horizontalRatio) <= 1) and its dontBreakRows/height-bound section are unaffected — this change is horizontal-only and adds no vertical growth for fixtures without an over-wide token. No wiki update required for this PR.


VERDICT: CHANGES_REQUIRED

Blocking: H1 — add the two regression tests (over-wide single-token areaText gets wordBreak: 'break-all' on its grey run; short areaText gets none), and make wordBreak observable on grey runs rather than discarded by the splitUsageCell helpers. Without them the fix is provably unobserved by the suite and #1968 AC1/AC2 are unmet.

M1/M2/L1/L2 are non-blocking; M1 (drop the redundant as Content) and L1 (two stale comments) are cheap enough to fold into the same push.

…n runs

Product-architect H1: reverting the production hunk left all tests green
because splitUsageCell synthesized a new { text, color } object and
discarded wordBreak. Add two levels of coverage:

overviewPdf.test.ts (unit):
- Return greyRuns[] from splitUsageCell (raw runs preserving wordBreak)
- space-containing areaText produces > 1 grey runs (proves per-token split)
- over-wide single token in areaText gets wordBreak: break-all on grey run

realRender.test.ts (real pdfmake render):
- 'W'.repeat(30) in areaText: metaText round-trips losslessly (AC1) and
  the grey run carries wordBreak: break-all (proves fix path exercised)

Also fix two stale "one grey run per row" doc comments → "one or more".

Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
@steilerDev

Copy link
Copy Markdown
Owner Author

[product-architect] Re-review (round 2) — PR #2002, fix/1968-usage-cell-overflowbeta

H1 — RESOLVED (independently verified, not taken on trust)

Round 1's blocker was that the fix had zero effective regression coverage: splitUsageCell synthesized a fresh { text, color } object and discarded wordBreak, so reverting the production hunk left every test green. Because this is exactly the class of change my memory flags as needing a revert test (a fix whose test-side companion relaxes an invariant), I re-ran it myself rather than accepting the claim.

I reverted the production hunk in client/src/lib/reportPdf/overviewPdf.ts back to the pre-fix single-run emit:

runs.push({ text, color: DEPOSIT_NOTE_TEXT_COLOR });

All three new regression tests fail, each on the assertion that encodes the fix:

Test Assertion Reverted result
overviewPdf.test.ts — space-containing meta suffix produces multiple grey runs greyRuns.length > 1 received 1 — FAIL
overviewPdf.test.ts — over-wide space-free token gets wordBreak: break-all greyRuns.some(r => r.wordBreak === 'break-all') received false — FAIL
realRender.test.ts — over-wide areaText token carries break-all on its grey run same, read off the real rendered doc definition received false — FAIL

Restored, both suites are green: 171/171 (97 overviewPdf + 74 realRender), matching the reported counts.

The root cause of the round-1 gap is genuinely fixed, not papered over: greyRuns now returns the raw run objects (greyIndexes.map((i) => runs[i]!)), so no pdfmake property is dropped on the way out, and the realRender test bypasses the helper entirely to read the run array off the rendered definition. Two independent levels, both with a proven revert signal.

On the invariant relaxation itself — this is the right shape. It drops the assertion that was merely incidentally true ("exactly one grey run") while keeping the two properties that are actually load-bearing for every reader of these cells: grey runs are contiguous and tail-anchored, each with its own explicit diagnostic message. Relaxing to "grey runs exist somewhere" would have been the failure mode here, and it was avoided.

Vertical-height check (the non-obvious risk of this fix). Applying break-all converts horizontal overflow into extra wrapped lines, which in this table interacts with the dontBreakRows silent-drop defect (ADR-034 — an over-tall unbreakable row is discarded with no throw and no warning). I checked whether the fix can push a Usage row past its budget: it cannot. packUsageCellRows packs the cell's whole content stream against MAX_SAFE_USAGE_CHUNK_CHARS, a character budget derived from a per-line character count (44 lines x 16 chars/line). Pre-fix, an over-wide unbroken token consumed one line while still counting its full length against that budget — i.e. it sat under the budgeted line usage. Post-fix it consumes ceil(len / safeTokenChars) lines, which is exactly what the budget already assumed for those characters. The bound is not weakened; the fix moves the actual behaviour toward the budget's assumption rather than past it.

M1 — as Content cast: not blocking, but it is unnecessary rather than unavoidable

Confirming your read that this need not block. One correction to the premise, though: the cast is not load-bearing. I removed it and type-checked the client project — no error at that line. I ran a positive control (a deliberate const __probe: number = "not-a-number" in the same function) which tsc reported at overviewPdf.ts:691, proving the file was genuinely being checked and the clean result was not a false negative.

So Object.assign({}, r, { color: DEPOSIT_NOTE_TEXT_COLOR }) type-checks on its own — it is only the spread form ({ ...r }) that fails, with TS2698, because pdfmake's Content union includes string. Object.assign's generic signature has no such constraint.

The cast is also harmless at runtime: buildUsageTextRuns provably only ever returns { text } / { text, wordBreak } object literals, never a bare string, so the widened union it silences cannot occur here. Nothing is being masked.

Two ways to clear it, your call — neither blocks this PR:

  1. Simply delete as Content (zero-risk, verified clean).
  2. Better: narrow buildUsageTextRuns's return type from Content[] to a concrete run type (e.g. { text: string; wordBreak?: 'break-all' }[]). That removes the need for a cast at this call site, and makes the documented "concatenating every returned run's text reproduces text exactly" invariant visible to the type system instead of resting only on the JSDoc.

M2 — new, non-blocking: ADR-034's horizontal-overflow rule has no enforcement anywhere

This PR closes a horizontal overflow channel, so it is the natural place to notice that the assertion ADR-034 names as the bar for exactly this is not implemented. Rule #1 states max(node.positions[].horizontalRatio) <= 1 is "the only assertion that actually proves 'nothing ran off the page'". A grep for horizontalRatio across client/ returns zero hits.

The tests here therefore assert the mechanism (wordBreak: 'break-all' is present on the run) rather than the outcome (nothing advanced past the printable right edge). The mechanism assertion is real, proven coverage — the revert test above demonstrates it flips — so this is a proxy, not a vacuous check, and it does not block. But it means the fix's actual goal is verified one level removed from the ADR's own stated standard.

This is pre-existing debt, not introduced by this PR (the rule was corrected into ADR-034 during the #1914 pass without a corresponding test). I'll own filing the follow-up to implement rule #1 as a reusable render assertion, since it is my ADR text that sets the bar. Once it exists, this PR's fixture is the obvious first case to run it against.

I1 — informational: documentation consistency verified

  • The channel-enumeration comment is correctly and completely updated: the grey-suffix bullet is removed, "Three channels" → "Two channels", the remaining two bullets match the count, and the header's "see the two exceptions called out at the end" (line 451) stays consistent. No stale cross-reference left behind.
  • Both stale "one grey run per row" doc comments were updated in overviewPdf.test.ts and realRender.test.ts.
  • No wiki update is owed for this PR. I checked both directions: no production consumer reads these cells (grep for DEPOSIT_NOTE_TEXT_COLOR / META_GREY / #6b7280 outside the two test files hits only the definition and the write sites), no e2e/ test inspects PDF run structure, and no wiki page — ADR-034 included — documents the single-grey-run property. The "recorded-not-fixed" channel list lives in the code comment, which the PR updated in place. So Schema/API-Contract/ADR pages are unaffected and the wiki stays accurate.
  • npx prettier --check and npx eslint are both clean on all three changed files (prettier is not CI-gated in this repo, so I verified it directly).

Verified in this review

  • Revert test on all three new regression tests (fail reverted / pass restored), plus full-suite green at 171/171
  • as Content necessity, with a tsc positive control to rule out a false-negative typecheck
  • Vertical row-height bound against the dontBreakRows silent-drop hazard
  • Absence of any other consumer of the relaxed single-grey-run invariant (production, e2e, and wiki)
  • Comment/documentation consistency, lint, and formatting

No blocking items remain. M1 and M2 are non-blocking follow-ups; M2 I'll file and own.

VERDICT: APPROVED

Product-architect round-2 review (#2002): revert-test rule (re-run it,
don't grade the description), horizontalRatio gap tracked as #2003,
Object.assign cast confirmed redundant, worktree agent-memory path trap
documented.

Co-Authored-By: Claude product-architect <noreply@anthropic.com>
@steilerDev
steilerDev merged commit 329812f into beta Aug 5, 2026
31 of 33 checks passed
@steilerDev
steilerDev deleted the fix/1968-usage-cell-overflow branch August 5, 2026 07:02
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.14.0-beta.9 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.14.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant