fix(ios): stop using black-translucent so the home-screen app reaches the bottom edge

Measured on an iPhone Air home-screen install (via /diag): screen 812,
innerHeight/100dvh/100svh 751, 100lvh/100vh 812, top inset 61. With
apple-mobile-web-app-status-bar-style=black-translucent iOS sizes the
layout viewport as screen minus the top inset, anchored at the top, so
the shell ended 61px above the bottom edge (a dead band) and every
position:fixed;bottom:* element floated too high. herdr-web-ui leaves
the meta tag absent and fills the screen.

- remove the status-bar-style meta from all page heads (needs the icon
  removed and re-added: iOS bakes it in at install time)
- 100dvh fallbacks next to every 100vh (WebKit reports 100vh/100lvh
  overflowing in default-style standalone installs)
- add startup images for iPhone Air (420x912) and iPhone 16/17 Pro
  (402x874); the installed app was reporting a 375x812 screen
- /diag: dead-band and unambiguous header/bar metrics, loud edge markers
- tests/pwaHead.test.js guards the head, the splash set and 100vh
- bump ASSET_VERSION and service worker cache
This commit is contained in:
igor 2026-10-13 13:33:20 +13:00
parent 9cc9517f50
commit f7325ea7cb
14 changed files with 109 additions and 14 deletions

View file

@ -523,6 +523,9 @@ Desktop and mobile do not have separate editor implementations. Both use the sam
### PWA shell
Cache shell/static assets only. Do not cache note/resource/API responses in ways that break shared-browser safety.
### iOS home-screen status bar (do NOT use black-translucent)
Pages must not set `apple-mobile-web-app-status-bar-style` (leave it absent, as herdr-web-ui does). With `black-translucent`, an iOS standalone app sizes `innerHeight`/`100dvh`/`100svh` as *screen height minus the top safe-area inset*, anchored at the top, while `100lvh`/`100vh` still report the full screen. The shell then ends that many points above the bottom edge (a dead band painted in the page background) and every `position: fixed; bottom: …` element (FAB, bottom sheets, backdrops) floats too high. Measured on an iPhone Air: screen 812, `innerHeight` 751, top inset 61. Absent meta = content starts below the status bar, bottom edge lands on the hardware, safe-area-top is 0. iOS bakes the value in at install time, so after changing it the icon must be removed and re-added. `tests/pwaHead.test.js` guards this, that every `apple-touch-startup-image` file exists and matches its media query, and that each `100vh` has a `100dvh` fallback (WebKit reports `100vh`/`100lvh` overflowing in default-style standalone installs). Newer iPhones need their own splash entry in `app/templates/shared.js` + `scripts/generatePwaAssets.mjs` (iPhone Air 420×912 @3x, 16/17 Pro 402×874 @3x are included).
### Mobile-first navigation without SPA rewrite
Mobile uses a folders screen, notes screen, and editor screen implemented in SSR + htmx + inline JS. Do not introduce a client router or framework state layer to solve mobile flow problems.

View file

@ -88,13 +88,13 @@ const handle = async (url, request, response, ctx) => {
<style>
:root { color-scheme: dark; }
body { margin: 0; font-family: system-ui, sans-serif; background: #000; color: #fff; }
.resource-viewer-page { min-height: 100vh; display: flex; flex-direction: column; }
.resource-viewer-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
.resource-viewer-page-bar { display: flex; align-items: center; gap: 10px; padding: calc(env(safe-area-inset-top, 0px) + 10px) 12px 10px; background: #111827; border-bottom: 1px solid rgba(255,255,255,0.12); }
.resource-viewer-page-btn { appearance: none; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); color: #fff; border-radius: 10px; padding: 8px 14px; font: inherit; }
.resource-viewer-page-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; }
.resource-viewer-page-body { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: #000; }
.resource-viewer-page-img { max-width: 100%; max-height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); object-fit: contain; }
.resource-viewer-page-frame { width: 100%; height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); border: 0; background: #fff; }
.resource-viewer-page-img { max-width: 100%; max-height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); max-height: calc(100dvh - 64px - env(safe-area-inset-top, 0px)); object-fit: contain; }
.resource-viewer-page-frame { width: 100%; height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); height: calc(100dvh - 64px - env(safe-area-inset-top, 0px)); border: 0; background: #fff; }
.resource-viewer-page-text { max-width: 100%; width: 100%; box-sizing: border-box; padding: 16px; margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; font-size: 14px; color: #e5e7eb; background: #111; overflow: auto; max-height: calc(100vh - 64px - env(safe-area-inset-top, 0px)); }
.resource-viewer-page-unsupported { display: flex; flex-direction: column; align-items: center; gap: 16px; color: #9ca3af; padding: 32px; text-align: center; }
</style>

View file

@ -40,14 +40,15 @@ const diagPage = (options = {}) => {
<meta name="theme-color" content="#2a3446" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Joplock device diagnostics</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;overscroll-behavior:none;background:#14181f;color:#e8ecf2;font:13px/1.35 -apple-system,system-ui,sans-serif}
html,body{height:100%;overflow:hidden;overscroll-behavior:none;background:#7a1010;color:#e8ecf2;font:13px/1.35 -apple-system,system-ui,sans-serif}
body{position:fixed;inset:0;width:100%}
#shell{position:fixed;left:0;right:0;top:0;display:flex;flex-direction:column;background:#1b2230;overflow:hidden}
#shell::after{content:'';position:absolute;left:0;right:0;bottom:0;height:5px;background:#ff2bd6;z-index:5;pointer-events:none}
#shell::before{content:'';position:absolute;left:0;right:0;top:0;height:3px;background:#19e6ff;z-index:5;pointer-events:none}
#shell.shell-current{bottom:0}
#shell.shell-vv{height:var(--app-height,100dvh)}
#hdr{flex:none;height:calc(44px + env(safe-area-inset-top,0px));padding:env(safe-area-inset-top,0px) 8px 0;display:flex;align-items:center;gap:8px;background:#2a3446;border-bottom:1px solid #3a475e}
@ -90,7 +91,8 @@ details p{padding:2px 0}
<p>2. Tap the top box (iframe, like the rich editor), wait 3s. Dismiss the keyboard (Done / tap outside), wait 3s. Repeat for the single-line field and the big box. While the keyboard is up, look: does the header stay at the top? Is the bottom bar right above the keyboard?</p>
<p>3. Rotate to landscape, wait 3s; tap a field, wait 3s; rotate back.</p>
<p>4. Background the app 10s and return (<b>visible</b>).</p>
<p>5. Optional: type a note in the label menu and tap Send, e.g. when something looks wrong.</p>
<p>5. Colours help in screenshots: <b>magenta</b> line = bottom edge of the app area, <b>cyan</b> line = top edge, <b>dark red</b> = page background showing through where the app does NOT reach. Please screenshot (a) idle and (b) keyboard up, and paste them in chat.</p>
<p>6. Optional: type a note in the label menu and tap Send, e.g. when something looks wrong.</p>
</details>
<pre id="out">measuring&hellip;</pre>
</div>
@ -148,6 +150,10 @@ function snap(){
put('hdrScreenY',Math.round(hd.top-vTop));
// gap between the bottom bar and the bottom of the visible area (>=0 = bar visible; negative = under the keyboard)
put('barGap',Math.round(vBottom-bar.bottom));put('editH',Math.round(ed.height));
put('hdrTop',Math.round(hd.top));put('barBottom',Math.round(bar.bottom));put('vvBottom',Math.round(vBottom));
put('hdrVisible',hd.bottom>vTop&&hd.top<vBottom);put('barVisible',bar.top>=vTop-1&&bar.bottom<=vBottom+1);
// portrait, no keyboard: how far short of the window's bottom the shell ends (the iOS dead band)
put('bandBottom',Math.round(window.outerHeight-sh.bottom));
put('appHeightVar',(getComputedStyle(root).getPropertyValue('--app-height')||'').trim()||'none');
put('focus',ae?(ae.id||ae.tagName):'none');put('kbGuess',visible);
put('kbApi',!!navigator.virtualKeyboard);put('kbApiH',navigator.virtualKeyboard&&navigator.virtualKeyboard.boundingRect?Math.round(navigator.virtualKeyboard.boundingRect.height):-1);

View file

@ -10,7 +10,7 @@ const {
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
const ASSET_VERSION = '20261012listclear';
const ASSET_VERSION = '20261012dvhfix';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
@ -51,7 +51,6 @@ const layoutPage = (options = {}) => {
<meta name="theme-color" content="#0b0b0b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Joplock" />
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
@ -96,7 +95,6 @@ const layoutPage = (options = {}) => {
<meta name="theme-color" content="#08110b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Joplock" />
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
@ -375,7 +373,6 @@ const loggedOutPage = () => `<!DOCTYPE html>
<meta name="theme-color" content="#08110b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Joplock" />
<link rel="manifest" href="/manifest.webmanifest?v=${ASSET_VERSION}" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />

View file

@ -27,6 +27,10 @@ const escapeJsString = value => `${value || ''}`
.replaceAll('</', '<\\/');
const appleSplashLinks = [
['1260x2736.png', 'screen and (device-width: 420px) and (device-height: 912px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
['2736x1260.png', 'screen and (device-width: 420px) and (device-height: 912px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
['1206x2622.png', 'screen and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
['2622x1206.png', 'screen and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
['1320x2868.png', 'screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],
['2868x1320.png', 'screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'],
['1290x2796.png', 'screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'joplock-shell-v86-20261012listclear';
const CACHE_NAME = 'joplock-shell-v87-20261012dvhfix';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/cm-extras.js', '/prism.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
self.addEventListener('install', event => {

View file

@ -905,6 +905,7 @@ body.nav-dragging-folder.nav-drag-nested .nav-drop-root { display: block; }
box-shadow: 0 10px 30px var(--shadow);
/* a very short window: scroll the menu instead of letting items fall off the screen */
max-height: calc(100vh - 16px);
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
@ -2743,6 +2744,7 @@ body.editor-markdown-mode .editor-toolbar {
justify-content: center;
min-height: 100vh;
font-family: 'Cascadia Mono', monospace;
min-height: 100dvh;
}
.login-card { text-align: center; max-width: 360px; width: 100%; }
@ -2826,6 +2828,7 @@ select.login-input option {
.settings-page {
min-height: 100vh;
padding: calc(28px + env(safe-area-inset-top, 0px)) 20px calc(28px + env(safe-area-inset-bottom, 0px));
min-height: 100dvh;
background: var(--bg);
}
@ -4003,6 +4006,7 @@ body.resizing .mobile-screen-right { transition: none !important; }
max-width: 420px;
width: calc(100% - 32px);
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
@ -4014,6 +4018,7 @@ body.resizing .mobile-screen-right { transition: none !important; }
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
overflow-y: auto;
}
.admin-modal-header {

View file

@ -8,7 +8,8 @@
//
// Flags: HDR-OFF header not at the top of the visible screen (page panned)
// BAR-HID bottom bar below the visible area (under the keyboard)
// BAR-GAP more than 2px of empty space under the bottom bar
// BAR-GAP more than 2px of empty space under the bottom bar (keyboard down)
// DEAD-BAND shell ends above the window's bottom edge (iOS black-translucent viewport bug; keyboard down)
// NO-SAFE standalone but every safe-area inset is 0 (insets not applied/black-translucent off)
const { spawnSync } = require('node:child_process');
@ -49,7 +50,8 @@ const flags = r => {
const out = [];
if (Math.abs(r.hdrScreenY) > 1) out.push('HDR-OFF');
if (r.barGap < 0) out.push('BAR-HID');
if (r.barGap > 2) out.push('BAR-GAP');
if (r.barGap > 2 && !r.kbGuess) out.push('BAR-GAP');
if (r.bandBottom > 2 && !r.kbGuess) out.push('DEAD-BAND');
if (r.standalone && !(r.safeT || r.safeB || r.safeL || r.safeR)) out.push('NO-SAFE');
return out;
};
@ -72,7 +74,8 @@ if (asJson) {
`vv ${r.vvW}x${r.vvH} top=${r.vvTop} s=${r.vvScale}`,
`lvh=${r.p100lvh} dvh=${r.p100dvh} svh=${r.p100svh}`,
`safe T${r.safeT} B${r.safeB} L${r.safeL} R${r.safeR}`,
`hdrY=${r.hdrScreenY} barGap=${r.barGap} shellH=${r.shellH}`,
`hdrY=${r.hdrScreenY} barGap=${r.barGap} band=${r.bandBottom ?? '?'} shellH=${r.shellH} outerH=${r.outerH}`,
`scr ${r.screenW}x${r.screenH}`,
`appH=${r.appHeightVar}`,
`focus=${r.focus} kb=${r.kbGuess ? 'Y' : 'n'}${r.kbApi ? `/api${r.kbApiH}` : ''}`,
flags(r).join(',') || 'ok',

View file

@ -9,6 +9,11 @@ const publicDir = path.join(rootDir, 'public');
const splashDir = path.join(publicDir, 'apple-splash');
const splashScreens = [
// iPhone Air (420x912 @3x) and iPhone 16/17 Pro (402x874 @3x)
{ width: 1260, height: 2736 },
{ width: 2736, height: 1260 },
{ width: 1206, height: 2622 },
{ width: 2622, height: 1206 },
{ width: 1320, height: 2868 },
{ width: 2868, height: 1320 },
{ width: 1290, height: 2796 },

72
tests/pwaHead.test.js Normal file
View file

@ -0,0 +1,72 @@
'use strict';
// Why these exist: with apple-mobile-web-app-status-bar-style=black-translucent, an iOS
// home-screen app sizes innerHeight / 100dvh / 100svh as (screen height - top safe-area inset),
// anchored at the top, while 100lvh/100vh still report the real screen. The app stops that
// many points above the bottom edge (a dead band under it) and every `position: fixed;
// bottom: ...` element floats too high. Measured on an iPhone Air: screen 812, innerHeight
// 751, top inset 61. Leaving the meta tag out gives the normal layout (content below the
// status bar, bottom edge on the hardware), which is what herdr-web-ui ships. iOS bakes the
// value in at install time, so changing it needs the icon removed and added again.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const templates = require('../app/templates');
const publicDir = path.join(__dirname, '..', 'public');
const stylesSrc = fs.readFileSync(path.join(publicDir, 'styles.css'), 'utf8');
const user = { id: 'u1', email: 'a@example.com', isAdmin: false };
test('no real page head asks iOS for the black-translucent status bar', () => {
const pages = {
login: templates.layoutPage({ user: null }),
app: templates.layoutPage({ user, navContent: '', editorContent: '', settings: {} }),
diag: templates.diagPage({}),
};
for (const [name, html] of Object.entries(pages)) {
assert.doesNotMatch(html, /apple-mobile-web-app-status-bar-style/, `${name} page must not set a status bar style`);
assert.doesNotMatch(html, /black-translucent/, `${name} page must not use black-translucent`);
}
});
test('home-screen app meta that must stay is still present', () => {
const html = templates.layoutPage({ user: null });
assert.match(html, /<meta name="apple-mobile-web-app-capable" content="yes"/);
assert.match(html, /<meta name="mobile-web-app-capable" content="yes"/);
assert.match(html, /viewport-fit=cover/);
assert.match(html, /<link rel="manifest"/);
});
test('every apple-touch-startup-image link points at a real file with matching pixel size', () => {
const html = templates.layoutPage({ user: null });
const links = [...html.matchAll(/<link rel="apple-touch-startup-image" href="\/apple-splash\/(\d+)x(\d+)\.png" media="([^"]+)"/g)];
assert.ok(links.length >= 24, `expected the splash set, got ${links.length}`);
for (const [, w, h, media] of links) {
const file = path.join(publicDir, 'apple-splash', `${w}x${h}.png`);
assert.ok(fs.existsSync(file), `${w}x${h}.png is referenced but missing`);
const dw = Number(/device-width: (\d+)px/.exec(media)[1]);
const dh = Number(/device-height: (\d+)px/.exec(media)[1]);
const dpr = Number(/pixel-ratio: (\d+)/.exec(media)[1]);
const portrait = /portrait/.test(media);
assert.deepEqual([Number(w), Number(h)], portrait ? [dw * dpr, dh * dpr] : [dh * dpr, dw * dpr], `${w}x${h} does not match its media query: ${media}`);
}
const sizes = links.map(l => `${l[1]}x${l[2]}`);
assert.ok(sizes.includes('1260x2736'), 'iPhone Air portrait splash (420x912 @3x)');
assert.ok(sizes.includes('1206x2622'), 'iPhone 16/17 Pro portrait splash (402x874 @3x)');
});
test('every 100vh in the stylesheet has a 100dvh fallback line next to it (iOS home-screen apps overflow on 100vh)', () => {
const lines = stylesSrc.split('\n');
const missing = [];
lines.forEach((line, i) => {
if (!/100vh/.test(line)) return;
if (/^\s*\.app \{/.test(line)) return; // desktop shell, display:none in the mobile shell
const near = lines.slice(Math.max(0, i - 1), i + 3).join('\n');
if (!/100dvh/.test(near)) missing.push(`${i + 1}: ${line.trim()}`);
});
assert.deepEqual(missing, []);
});