diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b494bd0..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. 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..a6eb6777 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,91 @@ 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/); });