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

This commit is contained in:
v.lagerev 2026-04-25 23:43:11 +03:00
parent e6623b2784
commit c46684800c
2 changed files with 55 additions and 8 deletions

View file

@ -249,27 +249,65 @@ export function AuthLayout() {
const footer = footerRef.current; const footer = footerRef.current;
if (!page || !mascot || !cardContent || !cardBody || !footer) return undefined; if (!page || !mascot || !cardContent || !cardBody || !footer) return undefined;
const rootEl = document.getElementById('root');
let frameId = 0; 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 => { const update = (): void => {
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => { 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 s = getComputedStyle(page);
const footerHeight = const footerHeight =
footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight); footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight);
const bottomGap = readPx(s, '--vojo-modal-gap'); 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({ const layout = calculateModalLayout({
pageHeight: page.clientHeight, pageHeight,
mascotTopOffset: readPx(s, '--vojo-mascot-top'), mascotTopOffset,
mascotHeight: mascot.offsetHeight, mascotHeight,
modalHeight: cardBody.offsetHeight, modalHeight: cardBody.offsetHeight,
footerHeight, footerHeight,
anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58), anchorRatio,
minTop: readPx(s, '--vojo-modal-min-top'), minTop,
bottomGap, bottomGap,
}); });
page.style.setProperty('--vojo-modal-top', `${layout.top}px`); page.style.setProperty('--vojo-modal-top', `${layout.top}px`);
page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`); page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`);
cardContent.classList.toggle(css.AuthCardContentScrollable, layout.constrained); 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(mascot);
ro.observe(cardBody); ro.observe(cardBody);
ro.observe(footer); ro.observe(footer);
window.addEventListener('resize', update); const onWindowResize = (): void => {
recomputePadding();
update();
};
window.addEventListener('resize', onWindowResize);
window.visualViewport?.addEventListener('resize', update);
return () => { return () => {
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId);
ro.disconnect(); 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({ export const AuthLayout = style({
width: '100%', width: '100%',
height: '100dvh', height: '100dvh',
minHeight: '100dvh',
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -65,6 +64,10 @@ export const AuthMascot = style({
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
}); });
export const AuthMascotHidden = style({
display: 'none',
});
export const AuthMascotVideo = style({ export const AuthMascotVideo = style({
display: 'block', display: 'block',
width: 'var(--vojo-mascot-size)', width: 'var(--vojo-mascot-size)',