From f9d7e7cf0a609599f824299fdce1c0e9ffa87814 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:09:11 -0400 Subject: [PATCH 1/2] Fix: keep folder badges inside their own page Each folder page was exactly the width of its icon grid and sat flush against the next one in a single strip, so a badge overhanging the right-hand column rendered in the next page's space. Inset every page by the badge overhang, clip each page, and take the inset out of the box padding so the icons keep their positions. Focus the mobile folder overlay itself instead of letting the trap fall back to the first app link, and reset the plain focus outline on that link so WebKit cannot paint its own ring. --- CHANGELOG.md | 5 ++ ui/css/dashboard.css | 5 +- ui/js/ui.js | 33 ++++++++---- ui/test/dialog.test.mjs | 6 ++- ui/test/folder-overlay.test.mjs | 95 +++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 ui/test/folder-overlay.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5f8044..be6f3224 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 +- Notification badges inside a folder no longer get trimmed at the page edge or + bleed into the next page. + +- Opening a folder on a phone no longer puts a focus ring on the first app. + - A long error in Admin now stays on screen long enough to read, up to fifteen seconds. diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index cee9d598..d040aa6d 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -608,9 +608,12 @@ .dyn-fold-sheen { position:absolute; inset:0; pointer-events:none; z-index:2; border-radius:inherit; background:linear-gradient(135deg,rgba(255,255,255,.12) 0%,transparent 55%); } .dyn-clip { overflow:hidden; flex-shrink:0; width:var(--gw); height:var(--gh); } .dyn-strip { display:flex; height:var(--gh); will-change:transform; transition:transform .3s cubic-bezier(.4,0,.2,1); } - .dyn-page-grid { flex-shrink:0; display:grid; gap:var(--gap); justify-content:center; align-content:start; width:var(--gw); height:var(--gh); grid-template-columns:repeat(3,var(--fiw)); grid-template-rows:repeat(3,var(--fiw)); } + .dyn-page-grid { flex-shrink:0; display:grid; gap:var(--gap); justify-content:center; align-content:start; width:var(--gw); height:var(--gh); padding:var(--ovh,0); overflow:hidden; grid-template-columns:repeat(3,var(--fiw)); grid-template-rows:repeat(3,var(--fiw)); } .dyn-dot { border-radius:50%; cursor:pointer; flex-shrink:0; width:var(--dsz); height:var(--dsz); } .dyn-dots-row { display:flex; justify-content:center; align-items:center; flex-shrink:0; width:100%; } + /* WebKit paints its own focus ring on a script-focused anchor. Only + :focus-visible is styled below. */ + .folder-overlay-mobile:focus, .dyn-fold-anchor:focus { outline:none; } .dyn-fold-anchor { display:flex; flex-direction:column; align-items:center; gap:4px; text-decoration:none; -webkit-tap-highlight-color:transparent; } .dyn-fold-inner-label { font-weight:400; color:rgba(255,255,255,.90); text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:0 1px 3px rgba(0,0,0,.6); font-size:var(--lfs); width:var(--fiw); } .dyn-title-mob { position:absolute; pointer-events:none; font-size:var(--tfs); } diff --git a/ui/js/ui.js b/ui/js/ui.js index 15464acf..5052b7e9 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -332,7 +332,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { } let folderOverlayMob = null; -export function openFolderMobile(folder, isz, _ir, _im, _sc) { +export function openFolderMobile(folder, isz, _ir, _im, sc) { if (folderOverlayMob) { folderOverlayMob.remove(); folderOverlayMob = null; @@ -349,6 +349,7 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) { ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder'); + ov.tabIndex = -1; let releaseMobTrap = null; function closeMob() { @@ -374,6 +375,11 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) { folderIm = Math.round(folderIconW * 0.64); const gridInnerW = folderIconW * 3 + gap * 2, gridH = folderIconW * 3 + gap * 2; + /* A badge sits 7px outside its icon corner. Without this inset the outer + icons' badges cross into the next page and the viewport shows them. */ + const badgeOvh = Math.min(Math.ceil(7 * (sc || 1)), padH, padVT, padVB); + const pageW = gridInnerW + badgeOvh * 2, + pageH = gridH + badgeOvh * 2; const dotSz = Math.round(7 * ptScale); const dotsZoneH = pages.length > 1 ? Math.round(26 * ptScale) : 0; const boxH = padVT + gridH + padVB + dotsZoneH; @@ -405,29 +411,35 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) { '--bh': boxH + 'px', '--top': boxTop + 'px', '--br': boxR + 'px', - '--pt': padVT + 'px', - '--ph': padH + 'px', - '--pb': padVB + 'px', + '--pt': padVT - badgeOvh + 'px', + '--ph': padH - badgeOvh + 'px', + '--pb': padVB - badgeOvh + 'px', }); const clipW = mk('div'); clipW.className = 'dyn-clip'; - css(clipW, { '--gw': gridInnerW + 'px', '--gh': gridH + 'px' }); + css(clipW, { '--gw': pageW + 'px', '--gh': pageH + 'px' }); const strip = mk('div'); strip.className = 'dyn-strip'; - css(strip, { '--gh': gridH + 'px', width: pages.length * gridInnerW + 'px' }); + css(strip, { '--gh': pageH + 'px', width: pages.length * pageW + 'px' }); let dotEls = []; function gotoPage(n) { curPage = Math.max(0, Math.min(pages.length - 1, n)); - strip.style.transform = strip.style.webkitTransform = `translateX(-${curPage * gridInnerW}px)`; + strip.style.transform = strip.style.webkitTransform = `translateX(-${curPage * pageW}px)`; dotEls.forEach((d, j) => d.classList.toggle('on', j === curPage)); } function buildPage(apps) { const p = mk('div'); p.className = 'dyn-page-grid'; - css(p, { '--gw': gridInnerW + 'px', '--gh': gridH + 'px', '--gap': gap + 'px', '--fiw': folderIconW + 'px' }); + css(p, { + '--gw': pageW + 'px', + '--gh': pageH + 'px', + '--gap': gap + 'px', + '--fiw': folderIconW + 'px', + '--ovh': badgeOvh + 'px', + }); for (let i = 0; i < 9; i++) { const child = apps[i]; if (child) { @@ -459,7 +471,7 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) { dotsEl = mk('div'); dotsEl.className = 'folder-dots dyn-dots-row'; css(dotsEl, { - padding: `${Math.round(18 * ptScale)}px 0 ${Math.round(4 * ptScale)}px`, + padding: `${Math.max(0, Math.round(18 * ptScale) - badgeOvh)}px 0 ${Math.round(4 * ptScale)}px`, gap: Math.round(7 * ptScale) + 'px', }); dotEls = pages.map((_, i) => { @@ -531,8 +543,7 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) { ); document.body.appendChild(ov); folderOverlayMob = ov; - /* After the overlay is in the document: the trap focuses its first control. */ - releaseMobTrap = trapFocus(ov, { onClose: closeMob }); + releaseMobTrap = trapFocus(ov, { onClose: closeMob, initialFocus: ov }); } export function buildMobile() { diff --git a/ui/test/dialog.test.mjs b/ui/test/dialog.test.mjs index 4e7557e1..841af74d 100644 --- a/ui/test/dialog.test.mjs +++ b/ui/test/dialog.test.mjs @@ -387,7 +387,11 @@ test('all four dialogs use the shared behaviour', () => { const sites = [ ['js/spotlight.js', /wrapTab\(e, ov\)/, 'the search overlay'], ['js/ui.js', /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk \}\)/, 'the desktop folder overlay'], - ['js/ui.js', /releaseMobTrap = trapFocus\(ov, \{ onClose: closeMob \}\)/, 'the mobile folder overlay'], + [ + 'js/ui.js', + /releaseMobTrap = trapFocus\(ov, \{ onClose: closeMob, initialFocus: ov \}\)/, + 'the mobile folder overlay', + ], ['js/dashboard.js', /trapFocus\(ov, \{ closeOnEscape: false, initialFocus: pw \}\)/, 'the setup prompt'], ]; for (const [file, pattern, what] of sites) { diff --git a/ui/test/folder-overlay.test.mjs b/ui/test/folder-overlay.test.mjs new file mode 100644 index 00000000..e08c515f --- /dev/null +++ b/ui/test/folder-overlay.test.mjs @@ -0,0 +1,95 @@ +/* Regression tests for the mobile folder overlay. + + Two defects, both in the overlay that opens when a folder is tapped on a + phone. + + A badge sits outside its icon's top-right corner by design. The pages of a + folder were laid out flush against each other in one strip, each page exactly + as wide as three icons and two gaps, and one viewport clipped the strip. A + badge on the right-hand column therefore rendered in the next page's space: + trimmed while its own page was shown, and visible as a sliver at the left + edge after a swipe. The top row lost the same 7px upwards. + + The fix insets each page by the overhang and lets the page clip itself, so a + badge stays inside the page it belongs to. The inset comes out of the box + padding, so the icons do not move. + + The second defect: the overlay trapped focus without saying what to focus, + the trap fell back to the first focusable element, and that is the first app + link. iOS Safari paints its own focus ring for a script-driven focus on an + anchor, which the stylesheet only styles for :focus-visible. Opening a folder + also should not move focus onto an item nobody asked for. + + Source-shape tests: the geometry is written in JavaScript against a real + layout, which is not available here. */ + +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'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); + +const ui = read('js/ui.js'); +const css = read('css/dashboard.css'); + +const overlay = ui.slice(ui.indexOf('export function openFolderMobile('), ui.indexOf('export function buildMobile(')); +assert.ok(overlay.length > 0, 'openFolderMobile was renamed or moved'); + +test('the page is inset by the badge overhang', () => { + assert.match( + overlay, + /badgeOvh = Math\.min\(Math\.ceil\(7 \* \(sc \|\| 1\)\)/, + 'the inset must follow the badge scale', + ); + assert.match(overlay, /pageW = gridInnerW \+ badgeOvh \* 2/); + assert.match(overlay, /pageH = gridH \+ badgeOvh \* 2/); +}); + +test('the inset comes out of the box padding, so the icons keep their place', () => { + assert.match(overlay, /'--pt': padVT - badgeOvh/); + assert.match(overlay, /'--ph': padH - badgeOvh/); + assert.match(overlay, /'--pb': padVB - badgeOvh/); +}); + +test('the inset never exceeds the padding it is taken from', () => { + assert.match(overlay, /Math\.min\(Math\.ceil\(7 \* \(sc \|\| 1\)\), padH, padVT, padVB\)/); +}); + +test('the icon grid itself is unchanged', () => { + assert.match(overlay, /folderIconW = Math\.min\(Math\.floor\(\(innerW - gap \* 2\) \/ 3\), isz\)/); + assert.match(css, /\.dyn-page-grid\s*{[^}]*grid-template-columns:repeat\(3,var\(--fiw\)\)/); +}); + +test('the strip is measured and moved in padded pages', () => { + assert.match(overlay, /width: pages\.length \* pageW \+ 'px'/); + assert.match(overlay, /translateX\(-\$\{curPage \* pageW\}px\)/); + assert.doesNotMatch(overlay, /curPage \* gridInnerW/, 'a page step shorter than the page leaves the seam visible'); +}); + +test('the viewport is one padded page', () => { + assert.match(overlay, /css\(clipW, \{ '--gw': pageW \+ 'px', '--gh': pageH \+ 'px' \}\)/); +}); + +test('a page clips its own overflow', () => { + const rule = css.match(/\.dyn-page-grid\s*{[^}]*}/); + assert.ok(rule, '.dyn-page-grid rule not found'); + assert.match(rule[0], /overflow:hidden/, 'without this the badge crosses into the next page'); + assert.match(rule[0], /padding:var\(--ovh,0\)/); + assert.match(overlay, /'--ovh': badgeOvh \+ 'px'/); +}); + +test('the overlay takes focus itself, not the first app', () => { + assert.match(overlay, /ov\.tabIndex = -1/); + assert.match(overlay, /trapFocus\(ov, \{ onClose: closeMob, initialFocus: ov \}\)/); + assert.doesNotMatch(overlay, /trapFocus\(ov, \{ onClose: closeMob \}\)/); +}); + +test('the native focus ring cannot render on a folder app link', () => { + assert.match(css, /\.folder-overlay-mobile:focus, \.dyn-fold-anchor:focus \{ outline:none; \}/); + const reset = css.indexOf('.dyn-fold-anchor:focus {'); + const visible = css.indexOf('.dyn-fold-anchor:focus-visible'); + assert.ok(visible > reset, 'the accent ring must be declared after the reset or it never applies'); +}); From 1087d8cd708eb3a933a6d2750a4f8006bd670469 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:37:12 -0400 Subject: [PATCH 2/2] Fix: focus the desktop folder overlay, not its first app Safari draws its own focus ring for a script-driven focus on an anchor, and only :focus-visible is styled. The desktop overlay had the same trap and the same missing plain-focus rule as the mobile one. --- CHANGELOG.md | 3 ++- ui/css/dashboard.css | 3 ++- ui/js/ui.js | 3 ++- ui/test/dialog.test.mjs | 6 +++++- ui/test/folder-overlay.test.mjs | 24 ++++++++++++++++++++---- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6f3224..1b494bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Notification badges inside a folder no longer get trimmed at the page edge or bleed into the next page. -- Opening a folder on a phone no longer puts a focus ring on the first app. +- Opening a folder no longer puts a focus ring on the first app, on a phone or + on the desktop. - A long error in Admin now stays on screen long enough to read, up to fifteen seconds. diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index d040aa6d..3aba6a6e 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -613,7 +613,8 @@ .dyn-dots-row { display:flex; justify-content:center; align-items:center; flex-shrink:0; width:100%; } /* WebKit paints its own focus ring on a script-focused anchor. Only :focus-visible is styled below. */ - .folder-overlay-mobile:focus, .dyn-fold-anchor:focus { outline:none; } + .folder-overlay-mobile:focus, .dyn-fold-anchor:focus, + .folder-overlay:focus, .folder-icon-link:focus { outline:none; } .dyn-fold-anchor { display:flex; flex-direction:column; align-items:center; gap:4px; text-decoration:none; -webkit-tap-highlight-color:transparent; } .dyn-fold-inner-label { font-weight:400; color:rgba(255,255,255,.90); text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:0 1px 3px rgba(0,0,0,.6); font-size:var(--lfs); width:var(--fiw); } .dyn-title-mob { position:absolute; pointer-events:none; font-size:var(--tfs); } diff --git a/ui/js/ui.js b/ui/js/ui.js index 5052b7e9..4f2b98f5 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -170,6 +170,7 @@ export function openFolderDesktop(folder) { ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder'); + ov.tabIndex = -1; const _prevFocus = /** @type {HTMLElement} */ (document.activeElement); let releaseDeskTrap = null; const outer = mk('div'); @@ -234,7 +235,7 @@ export function openFolderDesktop(folder) { folderOverlay = ov; /* The dashboard behind stays focusable while covered, so the overlay needs a Tab trap. Attach it after the overlay is in the document. */ - releaseDeskTrap = trapFocus(ov, { closeOnEscape: false, onClose: closeDesk }); + releaseDeskTrap = trapFocus(ov, { closeOnEscape: false, onClose: closeDesk, initialFocus: ov }); } function mFolder(item, cw, rh, isz, ir, im, sc) { diff --git a/ui/test/dialog.test.mjs b/ui/test/dialog.test.mjs index 841af74d..1a9f1820 100644 --- a/ui/test/dialog.test.mjs +++ b/ui/test/dialog.test.mjs @@ -386,7 +386,11 @@ test('trapFocus tolerates a missing root', async () => { test('all four dialogs use the shared behaviour', () => { const sites = [ ['js/spotlight.js', /wrapTab\(e, ov\)/, 'the search overlay'], - ['js/ui.js', /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk \}\)/, 'the desktop folder overlay'], + [ + 'js/ui.js', + /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk, initialFocus: ov \}\)/, + 'the desktop folder overlay', + ], [ 'js/ui.js', /releaseMobTrap = trapFocus\(ov, \{ onClose: closeMob, initialFocus: ov \}\)/, diff --git a/ui/test/folder-overlay.test.mjs b/ui/test/folder-overlay.test.mjs index e08c515f..43a4f910 100644 --- a/ui/test/folder-overlay.test.mjs +++ b/ui/test/folder-overlay.test.mjs @@ -88,8 +88,24 @@ test('the overlay takes focus itself, not the first app', () => { }); test('the native focus ring cannot render on a folder app link', () => { - assert.match(css, /\.folder-overlay-mobile:focus, \.dyn-fold-anchor:focus \{ outline:none; \}/); - const reset = css.indexOf('.dyn-fold-anchor:focus {'); - const visible = css.indexOf('.dyn-fold-anchor:focus-visible'); - assert.ok(visible > reset, 'the accent ring must be declared after the reset or it never applies'); + assert.match(css, /\.folder-overlay-mobile:focus, \.dyn-fold-anchor:focus,/); + assert.match(css, /\.folder-overlay:focus, \.folder-icon-link:focus \{ outline:none; \}/); + for (const [reset, visible] of [ + ['.dyn-fold-anchor:focus,', '.dyn-fold-anchor:focus-visible'], + ['.folder-icon-link:focus ', '.folder-icon-link:focus-visible'], + ]) { + assert.ok( + css.indexOf(visible) > css.indexOf(reset), + `${visible}: the accent ring must be declared after the reset or it never applies`, + ); + } +}); + +/* Desktop Safari showed the same ring the phone did: same trap, same missing + plain-focus rule. */ +test('the desktop overlay takes focus itself too', () => { + const desktop = ui.slice(ui.indexOf('export function openFolderDesktop('), ui.indexOf('function mFolder(')); + assert.match(desktop, /ov\.tabIndex = -1/); + assert.match(desktop, /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk, initialFocus: ov \}\)/); + assert.doesNotMatch(desktop, /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk \}\)/); });