Mobile signing pages — replace 100vh with 100dvh so the URL bar stops eating the bottom buttons
Renumbered T-119 → T-120 (2026-06-25, renumber-on-merge). A parallel agent landed a different T-119 ("BankTransactions table column-width refactor") on main while PR #813 was open. Per the AGENTS.md "whoever merges second renumbers" rule, this task moved to T-120 at merge time; the in-code
T-119UID comments across the touched files were updated toT-120to match. Commit messages stay as historicalT-119(immutable). README next-free bumped to T-121.
Why (owner, 2026-06-25)¶
The WOPC and IR56M signing pages were tuned to fit exactly within a mobile viewport — bottom buttons aligned to the edge of the screen, no scroll. On real devices the buttons "disappear": scroll activates and the buttons sit just below the browser's URL bar / bottom toolbar.
"the size of the webpage for mobile devices are still not right as scrolling is still enabled for those pages as even though screens of mobile devices are 16:9 or 9:16, certain elements that URL bar and function side bars that are part of the browser took more space than we previously assumed causing the webpage to enabled scrolling, and causing buttons that were perfectly aligned to the edge of the screen to disappear."
Why it happens (plain language)¶
On mobile, the 100vh CSS unit doesn't mean "the height of the visible
window." It means "the height the window would have if the URL bar
and bottom toolbar were collapsed." In practice the chrome IS visible
most of the time, so a page sized height: 100vh is taller than the
visible area — scroll appears, and anything anchored to the bottom of
the page sits below the URL bar.
The fix lives in two newer CSS units that browsers all support now:
- 100dvh (dynamic) — actual visible height, updates live as chrome
appears/disappears. The cleanest drop-in for 100vh.
- 100svh (small) — height when chrome is at maximum (most
conservative); 100lvh (large) — when chrome is collapsed
(equivalent to old 100vh). Useful for "lock to the smallest
guaranteed area regardless of state."
What shipped¶
- 12 signing-page client files under
app/wopc/sign/[requestId]/**andapp/ir56m/sign/[requestId]/**— every'100vh'literal replaced with'100dvh'. Page heights now track the actually-visible viewport. - Two new route layouts
(
app/wopc/sign/[requestId]/layout.tsx,app/ir56m/sign/[requestId]/layout.tsx) — single source of truth for: viewport: { …, viewportFit: 'cover', userScalable: false }so iOS draws into the safe-area zones instead of letter-boxing, and zoom can't skew touch placement on the drawing surfaces.- A scoped
<style>block that lockshtml, body { overflow: hidden; height: 100dvh; overscroll-behavior: none }while a signing route is mounted. Belt-and-braces: even if an inner element briefly overflows during a chrome transition, the root doesn't scroll, so the bottom buttons stay visible. Scoping is automatic — Next App Router unmounts the layout (and the style with it) on navigation away from the signing flow. viewportFit: 'cover'added to the existing per-page viewport exports (editor/draw/chop× WOPC +editor/draw× IR56M) so theiruserScalable: falsesettings still apply but they also draw under the iOS notch / home-indicator zones.
Why it's built this way (decisions that aren't the obvious choice)¶
- Layout-scoped CSS, not a global rule.
overflow: hiddenonhtml, bodywould break every other page that needs to scroll (Records, Accounting, the dashboard). Putting the rule inside the signing-flow layout's<style>mounts/unmounts it with the layout itself — no leak to other routes. 100dvhover100svh.svhwould guarantee no scroll even at the worst-case chrome state, but it wastes vertical space (the smallest-of-all-cases is rarely the current one).dvhupdates live, so the layout is always pixel-perfect to what's visible. Theoverflow: hiddenon the root is the safety net for the ~milliseconds-long chrome-transition windows wheredvhis technically stale.- Viewport in layout AND in per-page (for editor/draw/chop). The layout's viewport applies to every page that doesn't export its own; the per-page exports stay because their docstrings explain WHY each drawing page locks scaling specifically. Removing them would lose the documented rationale.
Out of scope (deliberate)¶
env(safe-area-inset-bottom)padding on page-roots. Most signing pages have ≥24px bottom padding which already clears the ~34px iOS home-indicator zone. If a button still gets covered on a specific device, the fix is per-page (addpaddingBottom: 'max(24px, env(safe-area-inset-bottom))'to the offending root). Not worth a blanket touch-up now.- JS-based
--vhfallback for iOS < 15.4.dvhshipped in iOS Safari 15.4 (March 2022); the user base on iOS 15.3 or older is vanishingly small in 2026. If we ever see a real report, the workaround is a ~10-lineresizelistener that updates a CSS custom property.
Decision log¶
2026-06-25 — T-120 opened + shipped¶
- ✅ Attestation (Accounting (Diagnostics)): read
AGENTS.md(tip26330aac); board scan by scope — T-047 is the WOPC editor / chop feature work (different concern, same routes); no covering task for the viewport-unit bug. New task. - Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
- Owner direction (verbatim, 2026-06-25): the prompt quoted in the "Why" section above.
- Tests + tsc:
npx tsc --noEmitclean. No runtime test added — this is a CSS/viewport change that needs real-device verification (vitest can't simulate mobile chrome behaviour). - Verification asked of owner: open the signing flow on a mobile browser (Safari iOS + Chrome Android cover the typical cases) → the buttons that previously hid below the URL bar should sit just above it. Scrolling the page should produce zero movement.
- Blast radius: scoped strictly to
app/wopc/sign/[requestId]/**andapp/ir56m/sign/[requestId]/**. The<style>blocks only mount while one of those routes is active. No other page is affected — the rest of the app still uses normal scroll-enabled layouts.
2026-06-28 — desktop preview panes can scroll inside the locked route shell¶
- ✅ Attestation (Codex local session): read
AGENTS.md; checked the board by scope, not UID — this is a follow-up to the T-120 signing-route viewport lock, not a new task. Tracking T-120. - Source: Codex local session ·
/Users/gutchumi/dev/ArtifactoftheEstablisher-codex-wopc-pdfjs - Owner follow-up (verbatim, 2026-06-28):
"The sign WOPC and IR56M page that shows the form was shown beyond the 16:9-ish screen when it's being opened on a computer, and scrolling wasn't allowed."
- Diagnosis: T-120 correctly locked
html/bodyscroll for mobile capture steps, but the preview pages still used content-sized roots (minHeight: 100dvh) and preview panes withmaxHeight: 100%. On a desktop-height viewport, the rendered PDF could grow taller than the visible area while the route layout blocked page scroll. - What changed: WOPC and IR56M entry previews now use fixed
height: 100dvhroute shells with internal, height-bounded PDF panes. WOPC and IR56M final signed-preview steps also keep the footer fixed while the main content/PDF pane scrolls internally; IR56M keeps the distribution controls reachable below the bounded preview pane. - Decision: keep the root scroll lock. It still protects the actual signing/capture steps from mobile browser chrome, while the document preview panes now own their own scroll where scrolling is appropriate.
- Blast radius: WOPC/IR56M signing preview UI only:
app/wopc/sign/[requestId]/client.tsx,app/ir56m/sign/[requestId]/client.tsx,app/wopc/sign/[requestId]/preview/client.tsx, andapp/ir56m/sign/[requestId]/preview/client.tsx.
2026-06-25 — drive-by: fix {{merchant}} clobber for vendor-matched txs¶
Owner reported tx EgxavxxoBveNmSdF78Cx (matched to GL 5000 via vendor
fingerprint "OMNISALE") rendered as
[5000 - Audio Software & Plugins] // 778229 — {{merchant}} slot
blank even though the doc has gl.5000.merchant = "OmniSale GmbH".
Root cause: enrichTransactionDisplayName's tokenData first spreads
safeExpenseMetadata (which correctly carries merchant: "OmniSale
GmbH" from the adapter), then unconditionally overwrites it with
transaction.merchant?.name — the bank-side enrichment object. For
this row the enrichment object has rawName: "PAYPAL *OMNISALE" and
NO name field, so merchant?.name is undefined and the override
clobbers the good value with nothing.
The convention elsewhere in tokenData is "expenseMetadata wins,
transaction-side is the fallback" (line 879 does it for payee). Line
920 had it inverted for merchant / merchantCategory. Fix is a
one-line reorder to match the convention. After the fix the tx renders
as [5000 - Audio Software & Plugins] OmniSale GmbH // 778229 (live-
verified against prod).
The receipt-lane (T-117) and billing-lanes (workspace/gcp) still win
over this default via their dedicated token loaders, which merge in
after tokenData — so this fix only changes behaviour for txs that
were previously blank.
Folded into the same PR as the mobile-viewport fix because it's a one-
liner and the touched file (lib/accounting/transactions.ts) is
already in scope of related display-name work.