Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions scripts/test-viewport.mjs
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,6 +29,7 @@ function VersionPeek({ onDismiss }) {
return (
<button className="versionpeek" onClick={onDismiss}>
build {commit} · {built.toLocaleDateString()} {built.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
<br />view {window.innerHeight} · screen {screen.height} · app {getComputedStyle(document.documentElement).getPropertyValue('--app-h') || 'css'}
</button>
)
}
Expand Down
18 changes: 11 additions & 7 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -392,15 +396,15 @@ 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);
}
.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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/lib/viewport.js
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading