fix(ui): kill horizontal overflow in dialogs and cut the worst modal copy (#1732)

* fix(dialogs): kill horizontal overflow in dialogs and cut the worst modal copy

Overflow hardening:
- DialogTitle/DialogDescription and SheetTitle/SheetDescription get
  break-words at the primitive, so long unbroken interpolated strings
  (emails, product names, org numbers) can no longer widen any dialog.
- AccountCombobox's non-flat dropdown is portaled to document.body with
  viewport-clamped geometry (new pure helper account-combobox-position.ts,
  unit-tested), the same fix info-tooltip.tsx applies to TooltipContent:
  the 34rem panel inside a scrollable DialogContent was the root cause of
  sideways-scrolling dialogs. Outside-click checks the portaled node,
  position tracks scroll/resize (capture phase), wheel/touchmove stop at
  the panel so react-remove-scroll's modal lock cannot block its scrolling,
  and DialogContent/SheetContent treat data-dialog-companion nodes as
  inside interactions so clicking the panel never dismisses the dialog.
  The flat variant is unchanged.
- StrikeLinesDialog/CorrectionEntryDialog line rows switch bare 1fr grid
  tracks to minmax(0,1fr) and wrap the sm:contents-promoted AccountCombobox
  in a min-w-0 cell (SendInvoiceDialog's pattern).
- New dialog-overflow-risk ratchet in no-new-antipatterns.mjs: bare fr
  tracks in dialog hosts, whitespace-nowrap inside DialogContent regions
  outside an allowlist, and unportaled >=20rem overlays; baselined at the
  post-fix 7 files.

Copy reduction (convention 7, MatchVoucherDialog precedent):
- New shared RattelseExplainer (HelpPopover) carries the "a posted
  verifikat cannot be edited directly" framing once; CorrectionEntryDialog,
  StrikeLinesDialog, RecordateEntryDialog and CorrectMetadataDialog drop
  their permanent inline explainer boxes and keep at most one sentence
  inline (hardcoded Swedish: verifikat surface).
- SendInvoiceDialog keeps the actual addresses inline and moves the fixed
  CC/BCC framing plus the extra-address rules behind a HelpPopover
  (recipient_additional_hint replaced by recipient_help_fixed and
  recipient_help_additional in both messages files).
- HelpPopover panels gain pointer-events-auto and the companion marker so
  they are actually interactive inside modal dialogs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): mechanism-accurate rattelse copy and calmer dropdown repositioning

The shared RattelseExplainer claimed every rattelse is logged with who/when
in the verifikat's rattelsehistorik, which is only true for the inline
strike-and-replace track (StrikeLinesDialog, CorrectMetadataDialog). The
storno dialogs (CorrectionEntryDialog, RecordateEntryDialog) never write
that log: their BFL 5 kap 5 trail is the storno chain. The shared component
now keeps only the universally true framing sentence, and each dialog's
popover carries the trail sentence matching its own mechanism.

AccountCombobox's capture-phase scroll/resize handler now skips setState
when the recomputed position is shallow-equal to the current one
(isSameDropdownPosition in the pure position helper, unit-tested) and
ignores scroll events originating inside the portaled panel itself, so
scrolling the account list no longer churns re-renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:03:52 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 68ca152127
commit 834cc4d0e8
17 changed files with 799 additions and 169 deletions
+12
View File
@@ -20,5 +20,17 @@
"lib/bokslut/tax-provision/bolagsskatt-calculator.ts",
"lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts"
]
},
"dialogOverflowRisk": {
"count": 7,
"files": [
"app/(dashboard)/supplier-invoices/[id]/page.tsx",
"components/bookkeeping/JournalEntryForm.tsx",
"components/invoices/PaymentBookingDialog.tsx",
"components/invoices/SendInvoiceDialog.tsx",
"components/reports/views/index.tsx",
"components/salary/NewEmployeeDialog.tsx",
"components/transactions/InvoiceMatchDialog.tsx"
]
}
}
+137 -2
View File
@@ -82,6 +82,23 @@
* entitlement paywall live (diagnosed 2026-08-17). Read flags as values
* via lib/env/public-flags. No baseline: the count is 0, any new one is
* a hard failure.
* 11. dialog-overflow-risk: patterns that make a dialog scroll sideways.
* (a) a bare `1fr` grid track inside grid-cols-[...] in a file that
* imports DialogContent/SheetContent: per the CSS Grid spec a bare fr
* track's implicit minimum is auto (its content's min-content size), so
* the track refuses to shrink below its content and overflows the dialog
* (StrikeLinesDialog and CorrectionEntryDialog shipped this, fixed
* 2026-08-19; TransactionBookingDialog had the safe minmax(0,1fr) idiom
* all along). (b) whitespace-nowrap inside a <DialogContent>/
* <SheetContent> JSX region outside DIALOG_NOWRAP_ALLOWED (numeric
* columns inside their own overflow-x-auto wrapper are fine and
* allowlisted per file). (c) a hand-rolled absolute overlay forcing
* min-w-[>=20rem] in a file that never portals anything to
* document.body: DialogContent's overflow-y-auto computes overflow-x to
* auto as well, so an oversized non-portaled panel grows the dialog a
* horizontal scrollbar instead of repositioning (AccountCombobox's
* dropdown pre-2026-08-19). Tracked as a per-file baseline set that may
* only shrink.
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
@@ -492,6 +509,85 @@ function findFoldedPublicFlags() {
return [...new Set(findings)].sort()
}
// 11. dialog-overflow-risk. See the header comment for the three patterns.
// Files whose whitespace-nowrap cells are fixed-width numeric/tabular columns
// living inside their OWN overflow-x-auto scroll container, so they cannot
// widen the dialog itself:
// - MockDataImportDialog: CSV preview built on the Table primitive, which
// self-wraps in overflow-auto (components/ui/table.tsx).
// - PaymentFileDialog: payment-line table wrapped in an overflow-x-auto div.
const DIALOG_NOWRAP_ALLOWED = new Set([
'components/extensions/shared/MockDataImportDialog.tsx',
'components/supplier-invoices/PaymentFileDialog.tsx',
])
const DIALOG_CONTENT_IMPORT_RE =
/import\s*\{[^}]*\b(?:DialogContent|SheetContent)\b[^}]*\}\s*from\s*['"]@\/components\/ui\/(?:dialog|sheet)['"]/
const GRID_COLS_TEMPLATE_RE = /grid-cols-\[([^\]]+)\]/g
const BARE_FR_TOKEN_RE = /^\d+(?:\.\d+)?fr$/
const WIDE_MIN_W_RE = /min-w-\[(\d+(?:\.\d+)?)(rem|px)\]/g
const PORTAL_HINT_RE = /createPortal|\bPortal\b/
const OVERLAY_ABSOLUTE_RE = /\babsolute\b/
const OVERLAY_Z_RE = /\bz-(?:40|50|\[\d+\])/
/**
* Overflow-risky patterns in dialog/sheet hosts. Returns
* { file, where, rule } findings; the ratchet compares the file set.
*/
function findDialogOverflowRisks() {
const files = [
...walk(path.join(ROOT, 'app'), ['.tsx']),
...walk(path.join(ROOT, 'components'), ['.tsx']),
...walk(path.join(ROOT, 'extensions'), ['.tsx']),
]
const findings = []
for (const f of files) {
const r = rel(f)
const src = fs.readFileSync(f, 'utf8')
const lines = src.split('\n')
if (DIALOG_CONTENT_IMPORT_RE.test(src)) {
// (a) bare fr grid tracks anywhere in a dialog-hosting file.
lines.forEach((line, i) => {
for (const m of line.matchAll(GRID_COLS_TEMPLATE_RE)) {
if (m[1].split('_').some((token) => BARE_FR_TOKEN_RE.test(token))) {
findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'bare-fr-grid-track' })
}
}
})
// (b) whitespace-nowrap inside the <DialogContent>/<SheetContent>
// region. Line-based depth tracking is a heuristic, but dialog JSX in
// this repo keeps the tags on their own lines.
if (!DIALOG_NOWRAP_ALLOWED.has(r)) {
let depth = 0
lines.forEach((line, i) => {
if (/<(?:Dialog|Sheet)Content\b/.test(line)) depth++
if (depth > 0 && line.includes('whitespace-nowrap')) {
findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'nowrap-in-dialog' })
}
const closes = (line.match(/<\/(?:Dialog|Sheet)Content>/g) || []).length
depth = Math.max(0, depth - closes)
})
}
}
// (c) a hand-rolled absolute overlay forcing a >=20rem minimum width in a
// file that never portals anything: inside a scrollable DialogContent
// that minimum becomes a horizontal scrollbar on the dialog.
if (!PORTAL_HINT_RE.test(src) && OVERLAY_ABSOLUTE_RE.test(src) && OVERLAY_Z_RE.test(src)) {
lines.forEach((line, i) => {
for (const m of line.matchAll(WIDE_MIN_W_RE)) {
const value = parseFloat(m[1])
if ((m[2] === 'rem' && value >= 20) || (m[2] === 'px' && value >= 320)) {
findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'unportaled-wide-overlay' })
}
}
})
}
}
return findings.sort((a, b) => a.where.localeCompare(b.where))
}
// Dependencies pinned to an EXACT version on purpose, because a bump broke prod
// and must not silently return via `npm update`, a dependabot bump, or a manual
// install. Any drift (in package.json OR the lockfile) fails CI. See DECISIONS.md.
@@ -834,8 +930,11 @@ const current = {
extensionRoutes: findExtensionRouteFindings(ROOT),
offLadderRadii: findOffLadderRadii(),
foldedPublicFlags: findFoldedPublicFlags(),
dialogOverflowRisk: findDialogOverflowRisks(),
}
const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort()
const isUpdate = process.argv.includes('--update')
if (isUpdate) {
@@ -849,6 +948,10 @@ if (isUpdate) {
count: current.ledgerScanningReports.length,
files: current.ledgerScanningReports,
},
dialogOverflowRisk: {
count: dialogOverflowFiles.length,
files: dialogOverflowFiles,
},
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
console.log(
@@ -1068,6 +1171,31 @@ if (newLedgerScans.length) {
)
}
// 1e3. dialog-overflow-risk: per-file ratchet, a finding in a file outside
// the baseline set is a NEW violation. Grandfathered files stay until fixed.
const dialogOverflowBaseline = new Set(baseline.dialogOverflowRisk?.files ?? [])
const newDialogOverflow = current.dialogOverflowRisk.filter(
(finding) => !dialogOverflowBaseline.has(finding.file),
)
const fixedDialogOverflow = [...dialogOverflowBaseline].filter(
(file) => !dialogOverflowFiles.includes(file),
)
if (newDialogOverflow.length) {
failed = true
console.error(
`\n✗ dialog-overflow-risk: ${newDialogOverflow.length} overflow-risky pattern(s) in new dialog/sheet file(s):`,
)
newDialogOverflow.forEach((finding) => console.error(` ${finding.where} (${finding.rule})`))
console.error(
' → bare-fr-grid-track: a bare 1fr track refuses to shrink below its content; use\n' +
' minmax(0,1fr), plus min-w-0 on the cell when a combobox/long text lives in it.\n' +
' nowrap-in-dialog: give the table/row its own overflow-x-auto wrapper, then allowlist\n' +
' the file in DIALOG_NOWRAP_ALLOWED in this script with a reason.\n' +
' unportaled-wide-overlay: portal the panel to document.body with viewport-clamped\n' +
' geometry, like AccountCombobox\'s dropdown or info-tooltip.tsx.',
)
}
// 2. naive-ore-round: count may not increase.
if (current.naiveOreRound > baseline.naiveOreRound.count) {
failed = true
@@ -1079,11 +1207,18 @@ if (current.naiveOreRound > baseline.naiveOreRound.count) {
}
// Report ratchet-down progress (informational, never fails).
if (fixedAuthFiles.length || fixedLedgerScans.length || current.naiveOreRound < baseline.naiveOreRound.count) {
if (
fixedAuthFiles.length ||
fixedLedgerScans.length ||
fixedDialogOverflow.length ||
current.naiveOreRound < baseline.naiveOreRound.count
) {
console.log('\n✓ Progress since baseline:')
if (fixedAuthFiles.length) console.log(` raw-route-auth: -${fixedAuthFiles.length} file(s)`)
if (fixedLedgerScans.length)
console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`)
if (fixedDialogOverflow.length)
console.log(` dialog-overflow-risk: -${fixedDialogOverflow.length} file(s)`)
if (current.naiveOreRound < baseline.naiveOreRound.count)
console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
@@ -1101,5 +1236,5 @@ if (failed) {
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s)).`,
)