From ae28765dd25b8018b54ab28400efd2492e8d85bf Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Tue, 29 Sep 2026 01:22:09 -0400 Subject: [PATCH] fix(layout): size the installed app to the screen, not the viewport iOS reports iOS home-screen apps can lay out shorter than the screen at launch, so the tab bar, Now Playing and sheets stopped above a black band. Installed, size to screen.height and reach fixed layers down by the gap. Adds a regression test and viewport numbers to the long-press build readout. --- scripts/test-viewport.mjs | 44 +++++++++++++++++++++++++++++++++++++++ src/App.jsx | 4 +++- src/index.css | 18 +++++++++------- src/lib/viewport.js | 22 ++++++++++++++++++++ src/main.jsx | 3 +++ 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 scripts/test-viewport.mjs create mode 100644 src/lib/viewport.js diff --git a/scripts/test-viewport.mjs b/scripts/test-viewport.mjs new file mode 100644 index 0000000..f828c1b --- /dev/null +++ b/scripts/test-viewport.mjs @@ -0,0 +1,44 @@ +// Simulates the iOS home-screen bug: the web view lays out 48px shorter than +// the screen. Installed, the tab bar must still reach the screen bottom. +import { chromium, devices } from 'playwright' +import { createServer } from 'vite' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const server = await createServer({ root: resolve(here, '..'), server: { port: 5194, strictPort: true }, logLevel: 'error' }) +await server.listen() +const browser = await chromium.launch() +let failed = false +try { + for (const standalone of [true, false]) { + const ctx = await browser.newContext({ ...devices['iPhone 13'], viewport: { width: 390, height: 796 }, screen: { width: 390, height: 844 } }) + if (standalone) await ctx.addInitScript(() => Object.defineProperty(navigator, 'standalone', { get: () => true })) + const page = await ctx.newPage() + await page.goto('http://localhost:5194', { waitUntil: 'networkidle' }) + const want = standalone ? 844 : 796 + const check = async (name, sel) => { + const bottom = await page.evaluate((s) => Math.round(document.querySelector(s).getBoundingClientRect().bottom), sel) + const ok = bottom === want + failed ||= !ok + console.log(`${ok ? 'PASS' : 'FAIL'} ${standalone ? 'installed' : 'browser'}: ${name} bottom ${bottom}, want ${want}`) + } + await check('tab bar', '.dock') + await page.getByLabel('Library options').click() + await page.waitForTimeout(500) + await check('sheet overlay', '.overlay, .sheet-overlay, [class*=overlay]') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Cancel' }).click().catch(() => {}) + await page.waitForTimeout(500) + await page.locator('.row__main').first().click() + await page.waitForTimeout(500) + await page.locator('.mini').click() + await page.waitForTimeout(700) + await check('now playing', '.now') + await ctx.close() + } +} finally { + await browser.close() + await server.close() +} +if (failed) process.exit(1) diff --git a/src/App.jsx b/src/App.jsx index 369c234..af08457 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,7 +17,8 @@ const TABS = [ ] // Hidden build readout: long-press the tab bar to see which deploy is running -// (commit + build time), to confirm the PWA auto-updated. Tap dismisses. +// (commit + build time), to confirm the PWA auto-updated, plus the viewport +// heights iOS reports. Tap dismisses. function VersionPeek({ onDismiss }) { useEffect(() => { const t = setTimeout(onDismiss, 5000) @@ -28,6 +29,7 @@ function VersionPeek({ onDismiss }) { return ( ) } diff --git a/src/index.css b/src/index.css index fdf8621..fe49841 100644 --- a/src/index.css +++ b/src/index.css @@ -55,13 +55,17 @@ html, body, #root { /* svh is the small viewport (always <= visible), so the dock can never sit - below the fold at first paint; vh/dvh are fallbacks. */ + below the fold at first paint; vh/dvh are fallbacks. Installed, --app-h + is the real screen height (see lib/viewport.js). */ height: 100vh; height: 100dvh; - height: 100svh; + height: var(--app-h, 100svh); margin: 0; } +/* Anything the layout doesn't cover paints as app background, never black. */ +html { background: var(--bg); } + body { /* black shows only in the gap above Now Playing, like an iOS card stack */ background: #000; @@ -268,7 +272,7 @@ svg { display: block; flex-shrink: 0; } font: 500 13.5px/1.35 var(--font); text-align: center; cursor: pointer; z-index: 5; animation: toast-in var(--dur-fade) var(--ease-out); } -.versionpeek { color: var(--dim); font-size: 11.5px; white-space: nowrap; } +.versionpeek { color: var(--dim); font-size: 11.5px; white-space: nowrap; line-height: 1.5; } @keyframes toast-in { from { opacity: 0; transform: translate(-50%, 6px); } } /* import progress under the top bar */ @@ -392,7 +396,7 @@ svg { display: block; flex-shrink: 0; } /* ── sheets (grouped, like an iOS action sheet) ──────────────── */ .sheet-overlay { - position: fixed; inset: 0; z-index: 60; + position: fixed; inset: 0; bottom: calc(0px - var(--app-gap, 0px)); z-index: 60; background: rgba(0, 0, 0, .45); animation: fade var(--dur-fade) ease; transition: opacity var(--dur-fade); @@ -400,7 +404,7 @@ svg { display: block; flex-shrink: 0; } .sheet-overlay.is-closing { opacity: 0; } @keyframes fade { from { opacity: 0; } } .sheet { - position: fixed; left: 8px; right: 8px; bottom: calc(var(--sab) + 8px); z-index: 61; + position: fixed; left: 8px; right: 8px; bottom: calc(var(--sab) + 8px - var(--app-gap, 0px)); z-index: 61; display: flex; flex-direction: column; gap: 8px; max-height: calc(100% - var(--sat) - 40px); will-change: transform; @@ -449,7 +453,7 @@ svg { display: block; flex-shrink: 0; } /* ── centered modal (new playlist name, confirm) ─────────────── */ .modal-overlay { - position: fixed; inset: 0; z-index: 70; + position: fixed; inset: 0; bottom: calc(0px - var(--app-gap, 0px)); z-index: 70; background: rgba(0, 0, 0, .55); display: flex; align-items: flex-start; justify-content: center; padding: calc(var(--sat) + 14vh) 24px 24px; @@ -476,7 +480,7 @@ svg { display: block; flex-shrink: 0; } /* ── Now Playing ─────────────────────────────────────────────── */ .now { - position: fixed; left: 0; right: 0; top: calc(var(--sat) + 7px); bottom: 0; z-index: 50; + position: fixed; left: 0; right: 0; top: calc(var(--sat) + 7px); bottom: calc(0px - var(--app-gap, 0px)); z-index: 50; border-radius: 14px 14px 0 0; overflow: hidden; isolation: isolate; display: flex; flex-direction: column; align-items: stretch; padding: 0 24px calc(max(var(--sab), 12px) + 12px); diff --git a/src/lib/viewport.js b/src/lib/viewport.js new file mode 100644 index 0000000..732fa9d --- /dev/null +++ b/src/lib/viewport.js @@ -0,0 +1,22 @@ +// iOS home-screen apps can lay out shorter than the screen at launch (vh, dvh, +// svh and innerHeight all under-report), leaving a black band under the tab +// bar. A standalone app always fills the screen, so size to the screen there. +const standalone = () => + navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches + +function fit() { + if (!standalone()) return + const portrait = window.innerHeight >= window.innerWidth + const screenH = portrait ? Math.max(screen.width, screen.height) : Math.min(screen.width, screen.height) + const h = Math.max(window.innerHeight, screenH) + const root = document.documentElement.style + root.setProperty('--app-h', `${h}px`) + // Fixed layers anchor to the short viewport, so they reach down by the gap. + root.setProperty('--app-gap', `${h - window.innerHeight}px`) +} + +export function fitViewport() { + fit() + for (const ev of ['resize', 'orientationchange', 'pageshow']) window.addEventListener(ev, fit) + document.addEventListener('visibilitychange', fit) +} diff --git a/src/main.jsx b/src/main.jsx index 2c8d52c..55ba07d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -7,6 +7,9 @@ import './index.css' import { seedIfEmpty, removeSamples } from './lib/seed' import { requestPersistentStorage } from './lib/db' import { registerServiceWorker } from './lib/pwa' +import { fitViewport } from './lib/viewport' + +fitViewport() // In dev we seed sample tones to develop against; in the real (prod) app there // are no samples — and we clean up any that were seeded by earlier builds.