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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion ui/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
28 changes: 28 additions & 0 deletions ui/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions ui/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
34 changes: 13 additions & 21 deletions ui/js/mobile-metrics.js
Original file line number Diff line number Diff line change
@@ -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;
}
90 changes: 48 additions & 42 deletions ui/js/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -554,25 +540,49 @@ 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),
im = Math.round(isz * 0.64);
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);

Expand Down Expand Up @@ -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';
Expand All @@ -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' });
Expand All @@ -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);
Expand Down
Loading
Loading