fix(auth): keep form within visible band on small viewports

This commit is contained in:
heaven 2026-04-25 23:43:11 +03:00
parent 6ab905a7c3
commit dbda8728a8
2 changed files with 55 additions and 8 deletions

View file

@ -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);
};
}, []);

View file

@ -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)',