From c7e1b6a056e46d66777407d3c03a591bd6567bea Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:12:21 -0400 Subject: [PATCH 1/2] Fix: take the mobile safe area from CSS, not a one-time read iOS reports its safe-area insets after the first paint and again after a rotation. The mobile layout read them once during boot, got zero, and built the phone home screen against an 18px fallback, so the first row sat under the status bar. The mobile resize path ignored the event that carried the real value. The insets now live in the stylesheet, the layout measures the box the stylesheet produced, and a probe sized by the insets drives a rebuild when they change. Settings gains the top inset it never had. --- CHANGELOG.md | 5 ++ ui/css/admin.css | 4 +- ui/css/dashboard.css | 28 +++++++++ ui/js/dashboard.js | 27 +++++++++ ui/js/mobile-metrics.js | 34 ++++------- ui/js/ui.js | 90 +++++++++++++++------------- ui/test/mobile-metrics.test.mjs | 101 ++++++++++++++++++++++---------- 7 files changed, 195 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b494bd0..d8e21bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A widget settings Fetch marks a blocked private address as a warning, the same as a badge test, instead of a hard error. +- Phone home screen no longer starts under the status bar in a home-screen + install. The first row clears the clock and the island. + +- Settings reserves the status bar area on a phone. + ## [1.6.0] - 2026-08-13 ### Added diff --git a/ui/css/admin.css b/ui/css/admin.css index 2e0bcbfa..5c821e71 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -466,7 +466,9 @@ html{background:var(--bg-color,var(--bg-outer))} not. */ .mtabbar{display:none} .m-back{display:none} -html.is-mobile .adm-outer{padding:0;display:block;min-height:100dvh} +/* The status bar overlaps the page in a home-screen install. The sticky section + header scrolls under it without this. */ +html.is-mobile .adm-outer{padding:env(safe-area-inset-top) 0 0;display:block;min-height:100dvh} html.is-mobile .adm{max-width:none;width:100%;min-height:100dvh;border:none;border-radius:0} html.is-mobile .sb{display:none} /* No sidebar on mobile, so nothing for the divider to divide. */ diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index 3aba6a6e..a71643ac 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -506,6 +506,34 @@ } .sra { margin-inline-start:auto; color:rgba(255,255,255,.25); font-size:0.8125rem } + /* ── Mobile safe area ── + iOS reports an inset after the first paint and again after a rotation. + These stay in CSS so the browser keeps them current. Nothing reads them + into JavaScript. */ + body.is-mob { + --sa-top: max(env(safe-area-inset-top), calc(18px * var(--sc,1))); + --sa-bottom: max(env(safe-area-inset-bottom), calc(8px * var(--sc,1))); + } + /* Sized by the insets, and observed. A change to either fires the layout + rebuild. */ + .sa-probe { + position:fixed; top:0; left:0; width:1px; visibility:hidden; pointer-events:none; + height:calc(env(safe-area-inset-top) + env(safe-area-inset-bottom)); + } + .mob-page { + flex:0 0 100vw; width:100vw; height:var(--pgh,100dvh); overflow:hidden; + padding:var(--sa-top) var(--sm,18px) calc(var(--sa-bottom) + var(--dh,108px) + var(--dz,52px)); + } + /* The box the layout measures. */ + .mob-grid { + width:100%; height:100%; + display:grid; + grid-template-columns:repeat(4,1fr); grid-template-rows:repeat(6,1fr); + gap:var(--gap); + justify-content:center; align-content:start; + } + .mob-cell { display:flex; align-items:center; justify-content:center; min-width:0; min-height:0; } + /* ── Folder overlay — mobile ── */ .folder-overlay-mobile { position:fixed; inset:0; z-index:800; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index f6c1d2f2..900e1e78 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -732,6 +732,33 @@ async function boot() { buildLayout(); }, MOB); + /* iOS reports its safe-area insets after the first paint, and again after a + rotation. The probe is sized by them, so its box changing is the signal + that the space the layout was measured against has moved. */ + if (typeof ResizeObserver === 'function') { + const probe = document.createElement('div'); + probe.className = 'sa-probe'; + document.body.appendChild(probe); + let _sat, + _saH = -1, + _saFirst = true; + new ResizeObserver(entries => { + const h = entries[0].contentRect.height; + if (h === _saH) return; + _saH = h; + /* Observing delivers the current size at once, and the layout being built + now already has it. */ + if (_saFirst) { + _saFirst = false; + return; + } + clearTimeout(_sat); + _sat = setTimeout(() => { + if (MOB) buildLayout(); + }, 100); + }).observe(probe); + } + /* A rotation that does not cross the breakpoint still changes how much fits, and the mobile layout is measured. Debounced, and only while it is the layout in use: on a phone the keyboard opening resizes the viewport too. */ diff --git a/ui/js/mobile-metrics.js b/ui/js/mobile-metrics.js index 35131f61..6264426d 100644 --- a/ui/js/mobile-metrics.js +++ b/ui/js/mobile-metrics.js @@ -1,39 +1,31 @@ /* Every size must derive from sc. Raw px here, or CSS without var(--sc), breaks - rendering at one physical size whatever page scale the browser reports. */ + rendering at one physical size whatever page scale the browser reports. + + Safe-area insets are not here. The stylesheet holds them, and the layout + measures the box the stylesheet produced. iOS reports an inset after the + first paint, so a value read into JavaScript is stale from the moment it is + taken. */ const BASE_VW = 393; -export function mobileMetrics(vw, vh, insetTop = 0, insetBottom = 0) { +export function mobileMetrics(vw) { const sc = vw / BASE_VW; const sm = Math.round(18 * sc); - const sb = Math.max(Math.round(insetTop), Math.round(18 * sc)); - const safe = Math.max(Math.round(insetBottom), Math.round(8 * sc)); const dh = Math.round(108 * sc); const pillH = Math.round(34 * sc); const pillGap = Math.round(10 * sc); /* Reserving less lets the pill sit on top of the last row. */ const dz = pillGap + pillH + Math.round(8 * sc); - const avail = vh - sb - safe - dh - dz; - return { - sc, - sm, - sb, - safe, - dh, - pillH, - pillGap, - dz, - avail, - rh: avail / 6, - cw: (vw - sm * 2) / 4, - }; + return { sc, sm, dh, pillH, pillGap, dz }; } +/* Both are distances above the bottom safe-area inset, which CSS adds. */ + export function pillBottom(m) { - return m.safe + m.dh + m.pillGap; + return m.dh + m.pillGap; } -/* Distance from the viewport bottom to the first pixel the grid may paint. */ +/* Distance from the reserved bottom to the first pixel the grid may paint. */ export function contentBottom(m) { - return m.safe + m.dh + m.dz; + return m.dh + m.dz; } diff --git a/ui/js/ui.js b/ui/js/ui.js index 4f2b98f5..c356c59f 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -32,20 +32,6 @@ const st = () => _state; const widgetReg = () => _state?.widgetReg || Object.create(null); const mkWrap = (item, sz, r, isz, cls) => _mkWrap(item, sz, r, isz, cls, breg); -/* Zero without viewport-fit=cover in the page meta. */ -function readSafeInsets() { - const p = mk('div'); - p.style.cssText = - 'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;' + - 'padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);'; - document.body.appendChild(p); - const cs = getComputedStyle(p); - const top = parseFloat(cs.paddingTop) || 0; - const bottom = parseFloat(cs.paddingBottom) || 0; - p.remove(); - return { top, bottom }; -} - /* A tap outside a widget lands in the parent document. Widgets expose window.__clearActive. */ function clearMobWidgets(exceptWin) { @@ -554,9 +540,42 @@ export function buildMobile() { st().BEL.clear(); const vw = innerWidth, vh = innerHeight; - const { top: insetTop, bottom: insetBottom } = readSafeInsets(); - const { sc, sm, sb, safe, dh, pillH, pillGap, dz, avail, rh, cw } = mobileMetrics(vw, vh, insetTop, insetBottom); - document.body.style.setProperty('--sc', String(sc)); + const { sc, sm, dh, pillH, pillGap, dz } = mobileMetrics(vw); + const COLS = 4, + ROWS = 6; + const gap = Math.round(sm * 0.5); + css(document.body, { + '--sc': String(sc), + '--sm': sm + 'px', + '--dh': dh + 'px', + '--dz': dz + 'px', + '--gap': gap + 'px', + '--pgh': vh + 'px', + }); + /* ── Mobile layout: single-pass 4×6 grid bin-packing ── + Footprints in cells: icon/folder 1×1, small 2×2, medium 4×2, large 4×4, + xlarge 4×6. */ + const strip = el('pages'); + strip.innerHTML = ''; + + function mkPage() { + const p = mk('div'); + p.className = 'mob-page'; + const g = mk('div'); + g.className = 'mob-grid'; + p.appendChild(g); + return { page: p, grid: g }; + } + + /* Every size below comes from this box, so the safe-area insets reach the + layout as the space the stylesheet already reserved. Reading the insets + instead would take a value iOS has not reported yet. */ + const firstPage = mkPage(); + strip.appendChild(firstPage.page); + const gridBox = firstPage.grid.getBoundingClientRect(); + const cw = gridBox.width / COLS, + rh = gridBox.height / ROWS; + const rh2 = (gridBox.height - gap * (ROWS - 1)) / ROWS; /* exact row height incl. gaps */ const maxIsz = Math.round(74 * sc); const isz = Math.round(Math.min(cw * 0.9, rh * 0.8, maxIsz)); const ir = Math.round(isz * 0.225), @@ -564,15 +583,6 @@ export function buildMobile() { const dock = items() .filter(i => i.type === 'app' && i.dock && !i.hidden) .slice(0, 4); - /* ── Mobile layout: single-pass 4×6 grid bin-packing ── - Footprints in cells: icon/folder 1×1, small 2×2, medium 4×2, large 4×4, - xlarge 4×6. */ - const strip = el('pages'); - strip.innerHTML = ''; - const COLS = 4, - ROWS = 6; - const gap = Math.round(sm * 0.5); - const rh2 = (avail - gap * (ROWS - 1)) / ROWS; /* exact row height incl. gaps */ const showLabel = S().showLabels?.ios === true; const wBR = Math.round(ir * 1.3); @@ -695,24 +705,21 @@ export function buildMobile() { return item.type === 'folder' ? mFolder(item, cw, rh2, isz, ir, im, sc) : mIcon(item); } - pages.forEach(placements => { - const p = mk('div'); - p.className = 'mob-page'; - p.style.cssText = - `flex:0 0 100vw;width:100vw;height:${vh}px;overflow:hidden;box-sizing:border-box;` + - `display:grid;grid-template-columns:repeat(${COLS},1fr);grid-template-rows:repeat(${ROWS},${rh2}px);` + - `column-gap:${gap}px;row-gap:${gap}px;justify-content:center;align-content:start;` + - `padding:${sb}px ${sm}px ${safe + dh + dz}px;`; + pages.forEach((placements, i) => { + /* The first page is already in the document: it is what was measured. */ + const { page, grid } = i === 0 ? firstPage : mkPage(); placements.forEach(({ item, c, r, w, h }) => { const cell = mk('div'); - cell.style.cssText = - `grid-column:${c + 1}/span ${w};grid-row:${r + 1}/span ${h};` + - `display:flex;align-items:center;justify-content:center;min-width:0;min-height:0;`; + cell.className = 'mob-cell'; + css(cell, { 'grid-column': `${c + 1}/span ${w}`, 'grid-row': `${r + 1}/span ${h}` }); cell.appendChild(item.type === 'widget' ? makeWidgetCard(item) : makeIconEl(item)); - p.appendChild(cell); + grid.appendChild(cell); }); - strip.appendChild(p); + if (i > 0) strip.appendChild(page); }); + /* Nothing was placed, so the measuring page would show as a blank first + page. */ + if (!pages.length) firstPage.page.remove(); const dw = el('dots'); dw.style.cssText = 'display:none'; @@ -724,7 +731,7 @@ export function buildMobile() { const dockIconSz = Math.round(Math.min(isz, ((dockW - Math.round(28 * sc)) / 4) * 0.85)); const dockIr = Math.round(dockIconSz * 0.225), dockIm = Math.round(dockIconSz * 0.64); - dk.style.cssText = `position:fixed;left:50%;bottom:${safe}px;-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${Math.round(14 * sc)}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`; + dk.style.cssText = `position:fixed;left:50%;bottom:var(--sa-bottom);-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${Math.round(14 * sc)}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`; dk.innerHTML = ''; dock.forEach(item => { const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' }); @@ -741,9 +748,8 @@ export function buildMobile() { _pdotGap = Math.round(5 * sc), _pdotPad = Math.round(14 * sc); const pillDotsW = pages.length * (_pdotSz + _pdotGap) - _pdotGap + _pdotPad * 2; - const pillBottom = safe + dh + pillGap; const pill = el('mob-search-pill'); - pill.style.cssText = `position:fixed;left:50%;bottom:${pillBottom}px;-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:-webkit-flex;display:flex;z-index:500;`; + pill.style.cssText = `position:fixed;left:50%;bottom:calc(var(--sa-bottom) + ${dh + pillGap}px);-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:-webkit-flex;display:flex;z-index:500;`; const pillNew = /** @type {HTMLElement} */ (pill.cloneNode(true)); const pillNewDots = q('.msp-dots', pillNew); diff --git a/ui/test/mobile-metrics.test.mjs b/ui/test/mobile-metrics.test.mjs index a305d93b..062037f4 100644 --- a/ui/test/mobile-metrics.test.mjs +++ b/ui/test/mobile-metrics.test.mjs @@ -1,13 +1,19 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { mobileMetrics, pillBottom, contentBottom } from '../js/mobile-metrics.js'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); + /* Page scale changes innerWidth, so these are the widths one phone can report. */ const WIDTHS = [320, 375, 393, 414, 430, 786, 1024]; test('the reserved bottom zone always clears the pill', () => { for (const vw of WIDTHS) { - const m = mobileMetrics(vw, 852); + const m = mobileMetrics(vw); const pillTop = pillBottom(m) + m.pillH; assert.ok( contentBottom(m) >= pillTop, @@ -17,53 +23,88 @@ test('the reserved bottom zone always clears the pill', () => { }); test('a 36px reserve is what let the pill overlap the last row', () => { - const m = mobileMetrics(393, 852); + const m = mobileMetrics(393); const oldDz = Math.round(36 * m.sc); assert.ok(m.dz > oldDz); - assert.ok(m.safe + m.dh + oldDz < pillBottom(m) + m.pillH); + assert.ok(m.dh + oldDz < pillBottom(m) + m.pillH); }); test('sc is 1 at the base width and scales linearly', () => { - assert.equal(mobileMetrics(393, 852).sc, 1); - assert.equal(mobileMetrics(786, 1704).sc, 2); + assert.equal(mobileMetrics(393).sc, 1); + assert.equal(mobileMetrics(786).sc, 2); }); test('every metric scales with sc', () => { - const a = mobileMetrics(393, 852); - const b = mobileMetrics(786, 852); - for (const k of ['sm', 'sb', 'safe', 'dh', 'pillH', 'pillGap', 'dz']) { + const a = mobileMetrics(393); + const b = mobileMetrics(786); + for (const k of ['sm', 'dh', 'pillH', 'pillGap', 'dz']) { assert.equal(b[k], a[k] * 2, `${k} did not double`); } }); -test('avail shrinks as the reserve grows', () => { - const m = mobileMetrics(393, 852); - assert.equal(m.avail, 852 - m.sb - m.safe - m.dh - m.dz); - assert.ok(m.avail > 0); +/* ── The insets belong to the stylesheet ──────────────────────────────────── */ + +/* iOS reports an inset after the first paint. A value read into JavaScript at + build time is the zero it reported before, and nothing re-reads it. */ +test('no metric is computed from a safe-area inset', () => { + const src = read('js/mobile-metrics.js'); + assert.doesNotMatch(src, /env\(/, 'the insets belong in CSS'); + assert.equal(mobileMetrics(393).sb, undefined, 'a top reserve here would compete with the CSS one'); + assert.equal(mobileMetrics(393).safe, undefined); + assert.equal(mobileMetrics(393).avail, undefined, 'the available height is measured, not computed'); +}); + +test('the layout never reads an inset out of the document', () => { + const ui = read('js/ui.js'); + assert.doesNotMatch(ui, /safe-area-inset/, 'reading the inset back returns 0 on iOS until it is reported'); + assert.doesNotMatch(ui, /readSafeInsets/); +}); + +test('the stylesheet holds the insets, with the scaled minimum as the floor', () => { + const css = read('css/dashboard.css'); + assert.match(css, /--sa-top:\s*max\(env\(safe-area-inset-top\), calc\(18px \* var\(--sc,1\)\)\)/); + assert.match(css, /--sa-bottom:\s*max\(env\(safe-area-inset-bottom\), calc\(8px \* var\(--sc,1\)\)\)/); }); -test('safe-area insets set the top and bottom reserves', () => { - const m = mobileMetrics(393, 852, 59, 34); - assert.equal(m.sb, 59); - assert.equal(m.safe, 34); +test('the page reserves the insets and the grid fills what is left', () => { + const css = read('css/dashboard.css'); + const page = css.match(/\.mob-page\s*{[^}]*}/)[0]; + assert.match(page, /padding:var\(--sa-top\) var\(--sm,18px\) calc\(var\(--sa-bottom\) \+ var\(--dh,108px\) \+ var\(--dz,52px\)\)/); + const grid = css.match(/\.mob-grid\s*{[^}]*}/)[0]; + assert.match(grid, /height:100%/); + assert.match(grid, /grid-template-rows:repeat\(6,1fr\)/, 'a row height in px would need the inset in JavaScript'); }); -test('reserves fall back to a scaled minimum when insets are zero', () => { - const m = mobileMetrics(393, 695, 0, 0); - assert.equal(m.sb, 18); - assert.equal(m.safe, 8); - const m2 = mobileMetrics(786, 695, 0, 0); - assert.equal(m2.sb, 36); - assert.equal(m2.safe, 16); +test('the layout sizes itself from the measured grid box', () => { + const ui = read('js/ui.js'); + assert.match(ui, /firstPage\.grid\.getBoundingClientRect\(\)/); + assert.match(ui, /cw = gridBox\.width \/ COLS/); + assert.match(ui, /rh = gridBox\.height \/ ROWS/); +}); + +test('the dock and the pill sit above the CSS inset', () => { + const ui = read('js/ui.js'); + assert.match(ui, /bottom:var\(--sa-bottom\)/, 'the dock'); + assert.match(ui, /bottom:calc\(var\(--sa-bottom\) \+ \$\{dh \+ pillGap\}px\)/, 'the pill'); +}); + +/* ── The rebuild trigger ──────────────────────────────────────────────────── */ + +test('a probe sized by the insets drives the rebuild', () => { + const css = read('css/dashboard.css'); + const probe = css.match(/\.sa-probe\s*{[^}]*}/)[0]; + assert.match(probe, /height:calc\(env\(safe-area-inset-top\) \+ env\(safe-area-inset-bottom\)\)/); + const dash = read('js/dashboard.js'); + assert.match(dash, /new ResizeObserver\([\s\S]{0,600}?\)\.observe\(probe\)/); + assert.match(dash, /if \(MOB\) buildLayout\(\)/); }); -test('a smaller inset than the minimum does not shrink the reserve', () => { - const m = mobileMetrics(393, 852, 4, 2); - assert.equal(m.sb, 18); - assert.equal(m.safe, 8); +test('the first observation does not rebuild what is being built', () => { + const dash = read('js/dashboard.js'); + assert.match(dash, /_saFirst/, 'observing delivers the current size at once'); }); -test('cw fits four columns inside the side margins', () => { - const m = mobileMetrics(393, 852); - assert.equal(m.cw * 4 + m.sm * 2, 393); +test('admin reserves the top inset too', () => { + const css = read('css/admin.css'); + assert.match(css, /html\.is-mobile \.adm-outer\{padding:env\(safe-area-inset-top\) 0 0/); }); From e254edc2bcc51e8da5e81a3f802186593fa372e7 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:22:35 -0400 Subject: [PATCH 2/2] Format the mobile metrics test --- CHANGELOG.md | 10 +++++----- ui/test/mobile-metrics.test.mjs | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e21bc5..eaf9d450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Phone home screen no longer starts under the status bar in a home-screen + install. The first row clears the clock and the island. + +- Settings reserves the status bar area on a phone. + - Notification badges inside a folder no longer get trimmed at the page edge or bleed into the next page. @@ -34,11 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A widget settings Fetch marks a blocked private address as a warning, the same as a badge test, instead of a hard error. -- Phone home screen no longer starts under the status bar in a home-screen - install. The first row clears the clock and the island. - -- Settings reserves the status bar area on a phone. - ## [1.6.0] - 2026-08-13 ### Added diff --git a/ui/test/mobile-metrics.test.mjs b/ui/test/mobile-metrics.test.mjs index 062037f4..a6eb6777 100644 --- a/ui/test/mobile-metrics.test.mjs +++ b/ui/test/mobile-metrics.test.mjs @@ -69,7 +69,10 @@ test('the stylesheet holds the insets, with the scaled minimum as the floor', () test('the page reserves the insets and the grid fills what is left', () => { const css = read('css/dashboard.css'); const page = css.match(/\.mob-page\s*{[^}]*}/)[0]; - assert.match(page, /padding:var\(--sa-top\) var\(--sm,18px\) calc\(var\(--sa-bottom\) \+ var\(--dh,108px\) \+ var\(--dz,52px\)\)/); + assert.match( + page, + /padding:var\(--sa-top\) var\(--sm,18px\) calc\(var\(--sa-bottom\) \+ var\(--dh,108px\) \+ var\(--dz,52px\)\)/, + ); const grid = css.match(/\.mob-grid\s*{[^}]*}/)[0]; assert.match(grid, /height:100%/); assert.match(grid, /grid-template-rows:repeat\(6,1fr\)/, 'a row height in px would need the inset in JavaScript');