From c46684800c9b9c965cda6d1fa9308da0cdcb9034 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sat, 25 Apr 2026 23:43:11 +0300 Subject: [PATCH] fix(auth): keep form within visible band on small viewports --- src/app/pages/auth/AuthLayout.tsx | 58 +++++++++++++++++++++++++++---- src/app/pages/auth/styles.css.ts | 5 ++- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index d720522f..d185246f 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -249,27 +249,65 @@ export function AuthLayout() { const footer = footerRef.current; if (!page || !mascot || !cardContent || !cardBody || !footer) return undefined; + const rootEl = document.getElementById('root'); let frameId = 0; + let padTop = 0; + let padBottom = 0; + + // env(safe-area-inset-*) only changes on rotate / fullscreen toggle; both + // fire window.resize. Recompute lazily, not on every RAF. + const recomputePadding = (): void => { + if (!rootEl) return; + const s = getComputedStyle(rootEl); + padTop = parseFloat(s.paddingTop) || 0; + padBottom = parseFloat(s.paddingBottom) || 0; + }; + recomputePadding(); + const update = (): void => { cancelAnimationFrame(frameId); frameId = requestAnimationFrame(() => { + // Capacitor WebView can publish env(safe-area-inset-*) one frame after + // mount; cheaper to refresh per RAF than to miss the first cold paint. + recomputePadding(); const s = getComputedStyle(page); const footerHeight = footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight); const bottomGap = readPx(s, '--vojo-modal-gap'); + // AuthLayout is height: 100dvh inside #root which has env(safe-area-inset-*) + // padding. AuthLayout overflows #root's content area by the safe-area + // amounts and gets clipped by body { overflow: hidden }. Subtract #root's + // vertical padding from pageHeight so the form fits the visible band. + const rawPageHeight = window.visualViewport?.height ?? page.clientHeight; + const pageHeight = Math.max(0, rawPageHeight - padTop - padBottom); + + const anchorRatio = readNum(s, '--vojo-anchor-ratio', 0.58); + + // Compact mode: form + footer don't fit in the viewport at all. + // When they do fit, the form naturally docks at the mascot anchor and + // the mascot peeks above through the glassmorphism — that's the design, + // not a bug. We only drop the mascot when the math has nowhere to put + // both. Triggers on keyboard up, very small windows, landscape, etc. + const compact = cardBody.offsetHeight + footerHeight + bottomGap > pageHeight; + + const mascotTopOffset = compact ? 0 : readPx(s, '--vojo-mascot-top'); + const mascotHeight = compact ? 0 : mascot.offsetHeight; + const minTop = compact ? 0 : readPx(s, '--vojo-modal-min-top'); + const layout = calculateModalLayout({ - pageHeight: page.clientHeight, - mascotTopOffset: readPx(s, '--vojo-mascot-top'), - mascotHeight: mascot.offsetHeight, + pageHeight, + mascotTopOffset, + mascotHeight, modalHeight: cardBody.offsetHeight, footerHeight, - anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58), - minTop: readPx(s, '--vojo-modal-min-top'), + anchorRatio, + minTop, bottomGap, }); page.style.setProperty('--vojo-modal-top', `${layout.top}px`); page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`); cardContent.classList.toggle(css.AuthCardContentScrollable, layout.constrained); + mascot.classList.toggle(css.AuthMascotHidden, compact); }); }; @@ -279,12 +317,18 @@ export function AuthLayout() { ro.observe(mascot); ro.observe(cardBody); ro.observe(footer); - window.addEventListener('resize', update); + const onWindowResize = (): void => { + recomputePadding(); + update(); + }; + window.addEventListener('resize', onWindowResize); + window.visualViewport?.addEventListener('resize', update); return () => { cancelAnimationFrame(frameId); ro.disconnect(); - window.removeEventListener('resize', update); + window.removeEventListener('resize', onWindowResize); + window.visualViewport?.removeEventListener('resize', update); }; }, []); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 3b16ed95..f31ee929 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -10,7 +10,6 @@ import { DefaultReset, toRem, color } from 'folds'; export const AuthLayout = style({ width: '100%', height: '100dvh', - minHeight: '100dvh', position: 'relative', display: 'flex', flexDirection: 'column', @@ -65,6 +64,10 @@ export const AuthMascot = style({ transform: 'translateX(-50%)', }); +export const AuthMascotHidden = style({ + display: 'none', +}); + export const AuthMascotVideo = style({ display: 'block', width: 'var(--vojo-mascot-size)',