feat(reports): length limits on the report wizard's editable override fields (#1941) - #2033
Conversation
… fields The step-5 override fields had no length limit anywhere, so a user got no signal that a field has a practical presentation limit until they saw the result in a PDF they had already sent to their bank. - Add four optional props to the shared EditableField -- maxLength and three pre-translated hint strings -- so any consumer can opt in and none is forced to. Absent props keep today's unbounded behaviour exactly. - Enforce via the native maxLength attribute, with no truncation anywhere in the value pipeline. A value arriving via props already over the limit is displayed in full: over-limit values come from AI-generated cover-letter content or derived usage text, never from the user typing past the cap, so the state is styled as informational and carries no aria-invalid, no error colour and no blocking. - Expose the constraint two ways rather than one: an always-mounted static description announced on focus, before the user is anywhere near the limit, and a separate polite live region carrying text only while at the limit. The visible counter is aria-hidden and appears at 90% -- it is a sighted-user cue, and on its own it would leave screen-reader users with nothing. - Refactor ariaDescribedBy from a ternary to a filtered-array composition, which is a precondition of the above since the ternary cannot compose two ids. The editedHintId gate is deliberately left label-only: dense mode already conveys edited state through the accessible name, so describing it there would announce "edited" twice. Each of the seven limits carries its own anchor in a comment rather than a bare number -- the server caps it mirrors, or the derivation bracketing it. usageText is 500 rather than the initially suggested 150 because the baseline is derived, not typed: a single budget line already admits a legal 500-char value, so a lower cap would make the over-limit state the routine state. No server-side work: these overrides live only in the wizard reducer and never reach the server, so the matching-server-bound criterion is vacuous by determination rather than unmet. Fixes #1941 Co-Authored-By: Claude frontend-developer <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com> Co-Authored-By: Claude translator <noreply@anthropic.com> Co-Authored-By: Claude ux-designer <noreply@anthropic.com> Co-Authored-By: Claude product-owner <noreply@anthropic.com>
|
[product-architect] VERDICT: APPROVE. No critical or high findings. Everything below is medium or lower and belongs in follow-ups, not in this branch. CI is fully green (Static Analysis, all 6 Jest shards, all 16 E2E shards, Docker, Trailer Check). I verified independently rather than re-deriving the ACs (dev-team-lead owns those) or the spacing (ux-designer owns that). Four judgement calls were asked of me; I agree with three and want to correct the framing of the fourth. 1. Is
|
- flex gap + child margin are additive, not collapsing - cohesive prop groups modelled as N independent optionals - "leaves N chars for X" comments invite guard tests that pin a fiction - ADR-034 pass gains an input-cap counterpart to line 148 Co-Authored-By: Claude product-architect <noreply@anthropic.com>
The comment stated three things that are not true: that 650 is a hard ceiling, that 150 characters of headroom remain for the derived suffix, and implicitly that exceeding the budget risks content loss. packUsageCellRows paginates losslessly, so exceeding the budget costs a continuation row rather than content. The 150-character headroom is unenforceable -- attachmentsNote has no maxLength and areaText is aggregate-unbounded, which is precisely why the bound sits at the whole-cell level. And since #1973 the budget is computed per subset, with 650 acting only as the one-sided clamp on that computation rather than a fixed value. Names the real invariant, USAGE_TEXT_MAX_LENGTH < usageChunkCharsForWidth( USAGE_WIDTH_7COL), and points at #1950 for its guard test rather than asserting it in prose. Also drops a line-number anchor that was already off by one, in favour of the symbol -- this reference family has gone stale three times. Refs #1941 Co-Authored-By: Claude frontend-developer <noreply@anthropic.com>
|
🎉 This PR is included in version 2.14.0-beta.21 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
🎉 This PR is included in version 2.14.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
The step-5 override fields had no length limit anywhere — not in the editor, not on the server. A user got no signal that a field has a practical presentation limit until they saw the result in a PDF they had already sent to their bank.
EditableField(maxLengthplus three pre-translated hint strings), so any consumer can opt in and none is forced to. Absent props keep today's unbounded behaviour exactly — the pre-existing suite passes unmodified.maxLengthattribute, with no truncation anywhere in the value pipeline.aria-hiddenand appears at 90% — it is a sighted-user cue, and on its own it would leave screen-reader users with nothing.Two things that are easy to get backwards
The over-limit state is not an error. An over-limit value arrives from AI-generated cover-letter content or from derived usage text — never from the user typing past the cap, which the native attribute prevents. So it carries no
aria-invalid, no error colour, no blocking, and the copy deliberately avoids "please"/"must" in both locales. AC4 requires such a value to display in full, which needs no logic at all — only the discipline not to add a defensive.slice().editedHintIdstays label-only. TheariaDescribedByternary was refactored into a filtered-array composition, which is a precondition of the above since a ternary cannot compose two ids. But the edited-hint gate was deliberately not extended to dense mode: dense mode already conveys edited state through the accessible name, so describing it there would announce "edited" twice. There is a dedicated regression test whose only job is to stop a future well-meaning "fix".The limits, and why
usageTextis 500Each of the seven carries its own anchor in a comment rather than a bare number — the server cap it mirrors, or the derivation bracketing it.
reference100 (invoices.invoiceNumber),subject/signature200 (vendors.name/areas.name),sender/recipient300,body4000,usageText500.usageTextis 500, not the initially suggested 120–150, because the baseline is derived, not typed:getUsageText()joins item names and budget-line descriptions each independently capped at 500 server-side, so a single budget line already admits a legal 500-character value. At 150, an ordinary invoice would load over-limit and the exception state would become the routine state — the counter would be permanent orange furniture on normal invoices, defeating its own 90% gating while nominally satisfying it. 500 is bracketed below by that floor and above byMAX_SAFE_USAGE_CHUNK_CHARS(650), leaving 150 for the derived suffix.bodyis 4000 with no architect round needed:buildCoverLetterContent()emits plain flowing paragraphs with no table, nodontBreakRowsand no fixed-height container, so pdfmake paginates natively and an over-long body makes more pages — it never clips. The realistic runaway is the AI path, not a verbose human.Two premises corrected before implementation
attachmentsNote, the field in the issue's original title, is dead code (removed in217cb408). The issue was amended to rev 2 around the fields that actually exist. The underlying gap is real; only the example was gone.usageText. Tests are written against those rather than an impossible fixture.The matching-server-bound criterion is vacuous by determination: overrides live only in the wizard reducer and never reach the server. It is recorded as such, with an explicit prohibition against building a validator for fields that never arrive.
A regression caught in review
The
.metaRowwrapper was initially gated onshowCounter || isEdited. Because.metaRow'smargin-topstacks additively on.container's flexgap— they do not collapse — and because every call site passesmaxLengthwhile the counter only appears at 90%, the ordinary case (edited, nowhere near the cap) was rendering 12px where #1932 shipped 8px. A 50% spacing increase on normal edits, to already-approved chrome.Gated on
showCounteralone, the no-counter case is now byte-identical to pre-#1941 markup, confirmed by the ux-designer against the CSS. Reviewing their own spec, they then found the same additive-margin bug in the counter-showing case and hadmargin-topdropped entirely, so.container's gap is the single source of spacing throughout the component.A pre-existing test had asserted the button was a direct child of
.containerand was initially updated to match the new wrapper. It has been reverted — the test was right and the production gate was wrong — and a companion test now pins the wrapped shape too, which is the assertion whose absence let the gating bug through.Test evidence
100% statements/branches/functions/lines on both
EditableField.tsxandReportContentEditor.tsx; 158 tests passing;tsc --noEmitclean. Every negative assertion is paired with a positive control using the identical selector, so it cannot pass vacuously. jsdom was checked empirically rather than assumed: it does not clampfireEvent.changeagainstmaxlength, so AC1 asserts attribute presence and documents the limitation instead of asserting a truncation that does not occur.Fixes #1941
🤖 Generated with Claude Code