`);
- return;
- }
- matches.forEach(item => l.appendChild(mkRow(item, state.items.indexOf(item))));
- return;
- }
- const inFolder = new Set(state.items.filter(i => i.type === 'folder').flatMap(f => f.children || []));
- state.items.forEach((item, idx) => {
- if (item.type !== 'folder' && inFolder.has(item.id)) return;
- l.appendChild(mkRow(item, idx));
- if (item.type === 'folder' && !collapsedFolders.has(item.id)) {
- (item.children || []).forEach((childId, ci) => {
- const childItem = state.items.find(i => i.id === childId);
- if (!childItem) return;
- l.appendChild(
- mkRow(childItem, state.items.indexOf(childItem), { indent: true, childIdx: ci, folderId: item.id }),
- );
- });
- const addRow = document.createElement('button');
- addRow.type = 'button';
- addRow.className = 'fp-add';
- setHtml(addRow, html`+ ${t('folder.addAppToFolder')}`);
- addRow.onclick = () => openFolderPicker(null, item.id);
- l.appendChild(addRow);
- }
- });
-}
-function _syncFilterUI() {
- const s = inp('al-search');
- if (s) s.value = _flt.q;
- qa('#al-filter .chip').forEach(c => {
- const on = c.dataset.flt === _flt.type;
- c.classList.toggle('on', on);
- c.setAttribute('aria-pressed', String(on));
- });
-}
-
function showListView() {
- el('dash-list-view').style.display = '';
- el('dash-edit-view').style.display = 'none';
+ el('dash-list-view').classList.remove('d-none');
+ el('dash-edit-view').classList.add('d-none');
}
function showEditView() {
- el('dash-list-view').style.display = 'none';
- el('dash-edit-view').style.display = '';
+ el('dash-list-view').classList.add('d-none');
+ el('dash-edit-view').classList.remove('d-none');
el('cp')?.scrollTo?.(0, 0);
q('.cp')?.scrollTo?.(0, 0);
}
@@ -661,7 +223,7 @@ function buildAddNewCard() {
/* Prepended after the builder runs, so the builder's reset cannot wipe it. */
function _renderEditBody() {
const body = el('ev-body');
- body.innerHTML = '';
+ body.replaceChildren();
if (state.ctype === 'widget') buildWidgetForm(body, state._evItem);
else if (state.ctype === 'folder') buildFolderForm(body, state._evItem);
else buildAppForm(body, state._evItem);
@@ -735,11 +297,17 @@ function openModal(idx) {
async function _evDelete(item, idx) {
if (!item) return;
- if (item.type === 'folder') {
- if (!confirm(t('confirm.deleteFolder', { name: item.label }))) return;
- } else {
- if (!confirm(t('confirm.remove', { name: item.label || item.id }))) return;
- }
+ const isFolder = item.type === 'folder';
+ const ok = await confirmText({
+ title: t('common.delete'),
+ text: isFolder
+ ? t('confirm.deleteFolder', { name: item.label })
+ : t('confirm.remove', { name: item.label || item.id }),
+ confirmLabel: t('common.delete'),
+ cancelLabel: t('common.cancel'),
+ destructive: true,
+ });
+ if (!ok) return;
const before = snapshotItems(state.items);
state.items.forEach(f => {
if (f.type === 'folder') f.children = (f.children || []).filter(id => id !== item.id);
@@ -751,13 +319,13 @@ async function _evDelete(item, idx) {
const s = inp('al-search');
if (s)
s.addEventListener('input', () => {
- _flt.q = s.value.trim();
+ filter.q = s.value.trim();
render();
});
qa('#al-filter .chip').forEach(c => {
c.addEventListener('click', () => {
- _flt.type = c.dataset.flt;
- _syncFilterUI();
+ filter.type = c.dataset.flt;
+ syncFilterUI();
render();
});
});
@@ -819,12 +387,7 @@ function openFolderPicker(appId, targetFolderId = null) {
const ri = document.createElement('span');
ri.className = 'fp-ic';
ri.style.background = rc(app.color);
- if (app.iconUrl) {
- const img = document.createElement('img');
- img.alt = '';
- img.src = resolveIcon(app.iconUrl);
- ri.appendChild(img);
- } else ri.textContent = (app.label || '?')[0];
+ paintIcon(ri, app.iconUrl, (app.label || '?')[0]);
const nm = document.createElement('span');
nm.className = 'fp-nm';
setUserText(nm, app.label || app.id);
@@ -914,7 +477,7 @@ async function doSave(orig) {
if (state._autoForm && state._autoFormType === state._wtype && state._widgetReg[state._wtype]) {
const missing = state._autoForm.validate();
if (missing.length) {
- toast(missing[0] + ' is required', 'err');
+ toast(t('toast.fieldRequired', { field: missing[0] }), 'err');
return;
}
item = {
@@ -934,7 +497,7 @@ async function doSave(orig) {
} else if (state._wtype === 'custom') {
const url = inp('f-url')?.value?.trim();
if (!url) {
- toast('URL required', 'err');
+ toast(t('toast.urlRequired'), 'err');
return;
}
const ifo = {};
@@ -961,7 +524,7 @@ async function doSave(orig) {
} else if (state.ctype === 'folder') {
const label = inp('f-fname')?.value?.trim();
if (!label) {
- toast('Name required', 'err');
+ toast(t('toast.nameRequired'), 'err');
return;
}
/* An app belongs to one folder, or the dashboard renders it twice. */
@@ -1024,9 +587,9 @@ async function doSave(orig) {
/* The editor stays open on a failed write, with the form intact. */
if (!(await saveOrRevert(before))) return;
closeModal();
- toast(replaced ? 'Updated' : 'Added');
+ toast(t(replaced ? 'toast.updated' : 'toast.added'));
} catch (e) {
- toast('Error: ' + e.message, 'err');
+ toast(t('toast.error', { err: e.message }), 'err');
}
}
@@ -1071,8 +634,8 @@ function initAllInlineEdits() {
onCommit() {
const bars = el('sec-pw-bars');
const hint = el('sec-pw-hint');
- if (bars) bars.style.display = 'none';
- if (hint) hint.style.display = 'none';
+ if (bars) bars.classList.add('d-none');
+ if (hint) hint.classList.add('d-none');
},
});
const pwInp = el('sec-pw');
@@ -1082,12 +645,8 @@ function initAllInlineEdits() {
() => {
const bars = el('sec-pw-bars');
const hint = el('sec-pw-hint');
- if (bars) {
- bars.style.display = 'flex';
- }
- if (hint) {
- hint.style.display = 'block';
- }
+ bars?.classList.remove('d-none');
+ hint?.classList.remove('d-none');
wirePasswordStrength('sec-pw', 'sec-pw-bars', 'sec-pw-hint');
},
{ once: true },
@@ -1142,7 +701,7 @@ async function initVersion() {
if (aEl) aEl.textContent = t('about.version', { v });
if (d.updateAvailable) {
const dot = el('about-update-dot');
- if (dot) dot.style.display = 'flex';
+ dot?.classList.remove('d-none');
if (aEl && d.latest) {
const lv = String(d.latest).replace(/^v/i, '');
setHtml(
@@ -1169,7 +728,7 @@ function initSecToggle() {
if (!en) return;
function apply(on) {
if (pwRow) pwRow.classList.toggle('d-none', !on);
- if (pwHint) pwHint.style.display = on ? '' : 'none';
+ if (pwHint) pwHint.classList.toggle('d-none', !on);
}
apply(en.checked);
en.addEventListener('change', () => apply(en.checked));
@@ -1658,6 +1217,7 @@ el('imp-foreign').onchange = async e => {
el('btn-add').onclick = () => openModal(null);
+initList({ openModal, openFolderPicker, save: saveOrRevert });
initNav();
initAllInlineEdits();
initSecToggle();
@@ -1674,13 +1234,13 @@ setReauthHandler(requireLogin);
checkAuth(load).then(ok => {
if (!ok) return;
load().catch(e => {
- toast('Could not load config. Is the API container running? (' + e.message + ')', 'err');
+ toast(t('toast.configLoadFailed', { err: e.message }), 'err');
const al = el('al');
if (al) {
/* An inline onclick is blocked by the CSP. */
setHtml(
al,
- html`
Failed to load dashboard config.
`,
+ html`
${t('home.loadFailed')}
`,
);
q('.retry-btn', al)?.addEventListener('click', () => location.reload());
}
diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js
index 87bfb81..7a12df6 100644
--- a/public/js/badge-logic.js
+++ b/public/js/badge-logic.js
@@ -249,7 +249,9 @@ export function computeBadgeVisual({
place. NAMED does not hold those token values and never decided this. */
const color = bg ? (needsDark(bg) ? '#1c1c1e' : '#ffffff') : '';
- if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '!', unit: '', color: NAMED.red });
+ /* No value: the mark is what the badge already draws, and the row exists for
+ the reason beside it. */
+ if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '', unit: '', color: NAMED.red });
for (const f of fired) rows.push(f);
if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: safeColor(staticBdg.color) });
@@ -260,7 +262,29 @@ export function computeBadgeVisual({
}
const nextColor = more ? rows[1].color : '';
- return { cls, txt, num, unit, bg, aria, color, title: reason, more, nextColor, rows: more ? rows : [] };
+ /* The popover exists to show what the badge cannot. Several values qualify,
+ and so does a failure, whose reason is otherwise only in the name. A single
+ healthy badge does not: it would repeat what is already drawn. */
+ const popover = more > 0 || Boolean(health);
+ /* A badge ignores the pointer so a click reaches the tile under it. One that
+ has something to open must not, or it can never be hovered. Separate from
+ has-more, which draws the second pill and is about having extra values. */
+ if (popover) cls += ' has-pop';
+
+ return {
+ cls,
+ txt,
+ num,
+ unit,
+ bg,
+ aria,
+ color,
+ title: reason,
+ more,
+ nextColor,
+ popover,
+ rows: popover ? rows : [],
+ };
}
/** Identity of a rendered badge, for skipping DOM writes that change nothing.
diff --git a/public/js/badge-popover.js b/public/js/badge-popover.js
index 702d076..3ecc8ad 100644
--- a/public/js/badge-popover.js
+++ b/public/js/badge-popover.js
@@ -26,7 +26,10 @@ function ensurePop() {
pop.className = 'badge-pop';
pop.id = 'badge-pop';
pop.setAttribute('role', 'tooltip');
- pop.hidden = true;
+ /* auto, so the browser gives the top layer, dismissal on a click outside and
+ Escape. It cannot position it: anchor positioning is Safari 26, well above
+ the support floor, so place() stays. */
+ pop.setAttribute('popover', 'auto');
/* Interactive now, so a click on it must not reach the tile underneath. */
pop.addEventListener('pointerenter', cancelClose);
pop.addEventListener('pointerleave', scheduleClose);
@@ -39,7 +42,7 @@ function ensurePop() {
control nested in a link is unreachable.
@param {HTMLElement} badge @returns {HTMLElement|null} */
function tileOf(badge) {
- return /** @type {HTMLElement|null} */ (badge.closest('a, [role="button"]'));
+ return /** @type {HTMLElement|null} */ (badge.closest('a, button, [role="button"]'));
}
/** @param {HTMLElement} badge */
@@ -81,7 +84,7 @@ function open(badge) {
line.append(dot, name, val);
p.appendChild(line);
}
- p.hidden = false;
+ p.showPopover();
openFor = badge;
openedAt = Date.now();
place(badge);
@@ -97,7 +100,7 @@ export function closeBadgePopover() {
openTimer = 0;
clearTimeout(closeTimer);
closeTimer = 0;
- if (pop) pop.hidden = true;
+ if (pop?.matches(':popover-open')) pop.hidePopover();
openFor = null;
if (describedEl) {
describedEl.removeAttribute('aria-describedby');
@@ -126,23 +129,20 @@ function cancelClose() {
closeTimer = 0;
}
+/* Escape and a click outside are the browser's now. Scrolling and resizing are
+ not: light dismiss does not cover them, and the popover is positioned by hand
+ against a badge that has moved. */
let globalsWired = false;
function wireGlobals() {
if (globalsWired) return;
globalsWired = true;
- document.addEventListener('keydown', e => {
- if (e.key === 'Escape' && openFor) closeBadgePopover();
- });
- window.addEventListener('scroll', () => closeBadgePopover(), true);
+ window.addEventListener('scroll', () => closeBadgePopover(), { capture: true, passive: true });
window.addEventListener('resize', () => closeBadgePopover());
- document.addEventListener(
- 'pointerdown',
- e => {
- const target = /** @type {Node} */ (e.target);
- if (openFor && pop && !pop.contains(target) && !openFor.contains(target)) closeBadgePopover();
- },
- true,
- );
+ /* The browser dismisses it without telling this module, which would then
+ think it is still open. */
+ ensurePop().addEventListener('toggle', e => {
+ if (/** @type {any} */ (e).newState === 'closed' && openFor) closeBadgePopover();
+ });
}
/** Attach or remove the popover on one badge. Null rows remove it.
diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js
index 0ed5f07..81d1916 100644
--- a/public/js/dashboard-logic.js
+++ b/public/js/dashboard-logic.js
@@ -19,41 +19,6 @@ function fingerprint(c) {
return JSON.stringify(c?.items?.map(i => `${i?.id}|${i?.label}|${i?.href}`)) + JSON.stringify(c?.settings);
}
-/** One day. */
-const WALLPAPER_TTL_MS = 24 * 60 * 60 * 1000;
-
-/** @param {any} bg the background settings @returns {string} */
-function wallpaperKey(bg) {
- return `${bg?.type || ''}|${bg?.collection || ''}`;
-}
-
-/** The still-valid wallpaper URL held for these background settings, if any.
-
- @param {string|null} stored the raw stored entry
- @param {any} bg the background settings the page was built from
- @param {number} now
- @returns {string|null} */
-export function readWallpaperCache(stored, bg, now) {
- if (typeof stored !== 'string' || !stored) return null;
- let entry;
- try {
- entry = JSON.parse(stored);
- } catch {
- return null;
- }
- if (!entry || typeof entry.url !== 'string' || !entry.url) return null;
- if (entry.key !== wallpaperKey(bg)) return null;
- if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null;
- /* A clock moved backwards would otherwise hold one photo forever. */
- if (now < entry.at || now - entry.at >= WALLPAPER_TTL_MS) return null;
- return entry.url;
-}
-
-/** @param {string} url @param {any} bg @param {number} now @returns {string} */
-export function writeWallpaperCache(url, bg, now) {
- return JSON.stringify({ url, key: wallpaperKey(bg), at: now });
-}
-
/** The page index to open on, clamped to the pages that exist.
@param {string|null} stored @param {number} totalPages @returns {number} */
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 92ca2d9..b75cd3b 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -8,7 +8,7 @@ import {
widgetSrc,
cardPreset,
uniqueTitle,
-} from '/js/widget-types.js?v=6a5e1619';
+} from '/js/widget-types.js?v=a1b61636';
import {
el,
mk,
@@ -22,28 +22,22 @@ import {
setUserText,
teardownWidgets,
titleWhenTruncated,
-} from '/js/utils.js?v=8ca7ce3c';
-import { initSpotlight } from '/js/spotlight.js?v=fe9e1b53';
+} from '/js/utils.js?v=d949e985';
+import { initSpotlight } from '/js/spotlight.js?v=687fae26';
import { html, setHtml, raw } from '/js/html.js?v=c71f8903';
-import { initI18n, t, currentLang } from '/js/i18n.js?v=83239bf4';
+import { initI18n, t, currentLang } from '/js/i18n.js?v=e644a5c5';
import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7';
import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f';
-import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=88773f11';
-import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=41a929ac';
-import { formatNumber } from '/js/format-number.js?v=e2165e12';
-import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=08aae50f';
-import {
- configChanged,
- landingAfterSetup,
- readWallpaperCache,
- writeWallpaperCache,
- restorePage,
-} from '/js/dashboard-logic.js?v=a0604f3b';
-import { trapFocus } from '/js/dialog.js?v=05935547';
-import { jitter } from '/js/jitter.js?v=4edf48f2';
-import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75';
+import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=82e0bf6b';
+import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2';
+import { formatNumber } from '/js/format-number.js?v=4a5ccef4';
+import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3';
+import { configChanged, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=74ffcb7e';
+import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
+import { jitter } from '/js/jitter.js?v=4eeef4c9';
+import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d';
import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf';
-import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=38adb276';
+import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
/* Recomputed, never stored: the window can cross the breakpoint after load. */
let MOB = isMobileLayout();
@@ -105,8 +99,7 @@ let _lastTouch = 0;
the boot veil up forever. Generous: this only has to beat a hang. */
const BOOT_TIMEOUT_MS = 15000;
-const PAGE_STORE = 'dash_page',
- WALLPAPER_STORE = 'dash_wallpaper';
+const PAGE_STORE = 'dash_page';
/** @param {string} key @returns {string|null} */
function storeGet(key) {
@@ -168,7 +161,7 @@ function bupd(id) {
const act = item?.monitoring?.activity || {};
const isFolder = item?.type === 'folder';
- const { cls, txt, num, unit, bg, aria, color, title, more, nextColor, rows } = computeBadgeVisual({
+ const { cls, txt, num, unit, bg, aria, color, nextColor, popover, rows } = computeBadgeVisual({
health: s.health,
activity: s.activity,
activityStale: !!s.activityStale,
@@ -185,7 +178,7 @@ function bupd(id) {
format: formatNumber,
});
- const sig = badgeSignature({ cls, txt, unit, bg, aria, color, title, nextColor, rows });
+ const sig = badgeSignature({ cls, txt, unit, bg, aria, color, nextColor, rows });
els.forEach(el => {
if (BSIG.get(el) === sig) return;
@@ -210,7 +203,7 @@ function bupd(id) {
el.setAttribute('aria-hidden', 'true');
el.removeAttribute('role');
el.removeAttribute('aria-label');
- const tile = /** @type {HTMLElement|null} */ (el.closest('a, [role="button"]'));
+ const tile = /** @type {HTMLElement|null} */ (el.closest('a, button, [role="button"]'));
const tileName = tile?.dataset.tileName;
if (tile && tileName) tile.setAttribute('aria-label', aria ? `${tileName}, ${aria}` : tileName);
/* Never the `background` shorthand. It resets background-clip, and the
@@ -220,11 +213,11 @@ function bupd(id) {
el.style.color = color;
if (nextColor) el.style.setProperty('--badge-next', nextColor);
else el.style.removeProperty('--badge-next');
- wireBadgePopover(el, more ? rows : null);
- /* Assigned, never interpolated. An upstream error string must not become
- markup. */
- if (title) el.title = title;
- else el.removeAttribute('title');
+ /* No title attribute. The popover carries the reason, and a badge that can
+ be hovered would otherwise draw the browser's own tooltip beside it.
+ Removed rather than skipped: one set by an earlier paint would persist. */
+ el.removeAttribute('title');
+ wireBadgePopover(el, popover ? rows : null);
});
}
@@ -389,7 +382,7 @@ function buildDesktop() {
const pages = paginate();
totalPages = pages.length;
const strip = el('pages');
- strip.innerHTML = '';
+ strip.replaceChildren();
pages.forEach(pageItems => {
const p = mk('div');
p.className = 'page';
@@ -400,10 +393,10 @@ function buildDesktop() {
strip.appendChild(p);
});
const dots = el('dots');
- dots.innerHTML = '';
+ dots.replaceChildren();
pages.forEach((_, i) => dots.appendChild(mkDot(i, pages.length, 0, goTo)));
const dk = el('dock');
- dk.innerHTML = '';
+ dk.replaceChildren();
dock.forEach(item => dk.appendChild(mkDock(item)));
}
@@ -492,7 +485,7 @@ function syncMobPages() {
if (domCount <= totalPages) return; /* no overflow pages, nothing to fix */
totalPages = domCount;
const dots = el('dots');
- dots.innerHTML = '';
+ dots.replaceChildren();
for (let i = 0; i < domCount; i++) dots.appendChild(mkDot(i, domCount, pg, goTo));
const pillDots = q('.msp-dots');
if (pillDots) {
@@ -576,12 +569,12 @@ async function applyBg() {
root.style.setProperty('--bg-size', fit === 'fit' ? 'contain' : 'cover');
sampleWallpaper(url, brightness, fit);
} else if (bg.type === 'unsplash') {
- let url = readWallpaperCache(storeGet(WALLPAPER_STORE), bg, Date.now());
+ let url = loadWallpaper(bg);
if (!url) {
const r = await fetch('/api/wallpaper', { cache: 'no-store' });
const d = await r.json();
url = d.url || null;
- if (url) storeSet(WALLPAPER_STORE, writeWallpaperCache(url, bg, Date.now()));
+ if (url) saveWallpaper(url, bg);
}
if (url) {
const shown = url;
@@ -653,14 +646,13 @@ const EYE =
function showSetupPrompt() {
return new Promise(resolve => {
- const ov = document.createElement('div');
+ const ov = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
ov.className = 'setup-prompt';
+ ov.setAttribute('aria-labelledby', 'setup-title');
setHtml(
ov,
- html`
${t('setup.title')}
${t('setup.sub')}
`,
+ html`
${t('setup.title')}
${t('setup.sub')}
`,
);
- document.body.appendChild(ov);
-
const pw = qi('#setup-pw', ov);
const pw2 = qi('#setup-pw2', ov);
const rev = qi('#setup-reveal', ov);
@@ -697,15 +689,17 @@ function showSetupPrompt() {
rev.title = label;
};
- let releaseTrap = trapFocus(ov, { closeOnEscape: false, initialFocus: pw });
- const close = () => {
- if (releaseTrap) {
- releaseTrap();
- releaseTrap = null;
- }
+ document.body.appendChild(ov);
+ ov.showModal();
+ pw.focus();
+ /* Escape must not dismiss this: there is nothing usable behind it, and Skip
+ is the deliberate way past. */
+ ov.addEventListener('cancel', e => e.preventDefault());
+ ov.addEventListener('close', () => {
ov.remove();
resolve();
- };
+ });
+ const close = () => ov.close();
skip.onclick = async () => {
skip.disabled = true;
@@ -950,17 +944,13 @@ async function boot() {
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. */
let _rt;
- window.addEventListener(
- 'orientationchange',
- () => {
- clearTimeout(_rt);
- _rt = setTimeout(() => {
- if (MOB) buildLayout();
- resampleBg();
- }, 150);
- },
- { passive: true },
- );
+ screen.orientation.addEventListener('change', () => {
+ clearTimeout(_rt);
+ _rt = setTimeout(() => {
+ if (MOB) buildLayout();
+ resampleBg();
+ }, 150);
+ });
/* The desktop tile size follows the viewport, so a resize can change how many
rows fit. Rebuild only when the slot count actually moves, not on every
diff --git a/public/js/dialog.js b/public/js/dialog.js
deleted file mode 100644
index 7ad5bd9..0000000
--- a/public/js/dialog.js
+++ /dev/null
@@ -1,110 +0,0 @@
-// @ts-check
-/* The page behind an overlay is still focusable while covered, so Tab must wrap
- inside it. Escape and focus restoration belong with the trap. */
-
-const FOCUSABLE = [
- 'a[href]',
- 'button:not([disabled])',
- 'input:not([disabled]):not([type="hidden"])',
- 'select:not([disabled])',
- 'textarea:not([disabled])',
- '[tabindex]:not([tabindex="-1"])',
-].join(',');
-
-/** The elements inside `root` that a Tab press can reach, in document order.
- @param {Element} root @returns {HTMLElement[]} */
-export function focusableWithin(root) {
- if (!root || typeof root.querySelectorAll !== 'function') return [];
- return /** @type {HTMLElement[]} */ ([...root.querySelectorAll(FOCUSABLE)]).filter(el => {
- /* offsetParent is null for a hidden element and for position:fixed. */
- const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null;
- if (style && (style.visibility === 'hidden' || style.display === 'none')) return false;
- if (el.offsetParent === null && (!style || style.position !== 'fixed')) return false;
- return el.getAttribute('aria-hidden') !== 'true';
- });
-}
-
-/** Move focus to the far end when Tab would otherwise leave `root`.
- Returns true when it handled the event.
- @param {KeyboardEvent} e @param {Element} root @returns {boolean} */
-export function wrapTab(e, root) {
- if (e.key !== 'Tab') return false;
- const f = focusableWithin(root);
- if (!f.length) return false;
- const first = f[0],
- last = f[f.length - 1];
- const active = document.activeElement;
-
- /* Focus outside the dialog, which happens when it opens with nothing
- focused. */
- if (!root.contains(active)) {
- e.preventDefault();
- first.focus();
- return true;
- }
-
- if (e.shiftKey && active === first) {
- e.preventDefault();
- last.focus();
- return true;
- }
- if (!e.shiftKey && active === last) {
- e.preventDefault();
- first.focus();
- return true;
- }
- return false;
-}
-
-/** Make `root` behave as a modal dialog until the returned function is called.
- Calling that function twice is harmless.
-
- @param {Element} root
- @param {{ onClose?: () => void, initialFocus?: HTMLElement | null,
- closeOnEscape?: boolean }} [opts]
- @returns {() => void} */
-export function trapFocus(root, opts = {}) {
- if (!root) return () => {};
- const { onClose, initialFocus, closeOnEscape = true } = opts;
- const restoreTo = /** @type {HTMLElement|null} */ (document.activeElement);
- let released = false;
-
- const onKeydown = /** @param {KeyboardEvent} e */ e => {
- if (wrapTab(e, root)) return;
- if (closeOnEscape && e.key === 'Escape') {
- e.preventDefault();
- release();
- if (onClose) onClose();
- }
- };
-
- function release() {
- if (released) return;
- released = true;
- document.removeEventListener('keydown', /** @type {EventListener} */ (onKeydown), true);
- /* Without this, closing leaves focus on nothing and the next Tab starts at
- the top of the page. */
- if (restoreTo && restoreTo.focus && restoreTo.isConnected) {
- try {
- restoreTo.focus();
- } catch {
- /* not focusable any more */
- }
- }
- }
-
- /* On the document, not on root. Clicking unfocusable text inside a dialog
- moves focus to the body, where a listener on root never sees the key. */
- document.addEventListener('keydown', /** @type {EventListener} */ (onKeydown), true);
-
- const target = initialFocus || focusableWithin(root)[0];
- if (target && target.focus) {
- try {
- target.focus();
- } catch {
- /* nothing to focus */
- }
- }
-
- return release;
-}
diff --git a/public/js/format-number.js b/public/js/format-number.js
index e4ebd0d..ffff7b9 100644
--- a/public/js/format-number.js
+++ b/public/js/format-number.js
@@ -1,15 +1,6 @@
// @ts-check
-/* Digit shape follows the reader's locale, not the interface language.
-
- Apple's guidance is explicit that numerals take "the correct localized digits
- for the user's locale and preferences", and that neither Arabic nor Persian
- always uses native digits: it depends on the country, and the reader can
- choose. An iPhone in Arabic with a Latin-numeral region shows Latin digits in
- its badges. So the language setting picks the words and the locale picks the
- digits.
-
- A number that identifies rather than counts, such as a version or a port, is
- not passed through here: those are the same string in every language. */
+/* The language picks the words, the locale picks the digits. A number that
+ identifies rather than counts is not passed through here. */
/** @type {Intl.NumberFormat|null} */
let cached = null;
@@ -48,8 +39,18 @@ export function formatNumber(value, options) {
return cached.format(value);
}
+/* Ten entries, built once per locale from the same cached formatter. Formatting
+ each character separately called Intl on every digit of every polled string. */
+let digitMap = null;
+let digitMapFor = null;
+
/** The digits of an already-formed string, for text built elsewhere.
@param {string} text @returns {string} */
export function localiseDigits(text) {
- return String(text).replace(/\d/g, d => formatNumber(Number(d)));
+ const loc = locale();
+ if (!digitMap || digitMapFor !== loc) {
+ digitMap = Array.from({ length: 10 }, (_, d) => formatNumber(d));
+ digitMapFor = loc;
+ }
+ return String(text).replace(/\d/g, d => digitMap[Number(d)]);
}
diff --git a/public/js/i18n.js b/public/js/i18n.js
index 243baa6..8d94530 100644
--- a/public/js/i18n.js
+++ b/public/js/i18n.js
@@ -91,16 +91,8 @@ async function fetchCatalog(code) {
}
}
-/* Two development locales. Neither is in the registry, so neither is offered in
- the selector; both are reached by adding ?lang= to the page URL.
-
- PSEUDO_LANG rewrites the English catalog: it accents the letters, pads the
- text by about 40% and brackets each message. Text that clips, wraps badly or
- was concatenated from fragments then shows up on any screen, in a language
- the reader can still read.
-
- KEY_LANG loads no catalog, so every string renders as its own key. It answers
- "which key is this on screen" and "what here is not translated at all". */
+/* Development locales, reached by ?lang= and absent from the registry.
+ What each one does: docs/i18n.md. */
export const PSEUDO_LANG = 'en-XA';
export const KEY_LANG = 'cimode';
diff --git a/public/js/jitter.js b/public/js/jitter.js
index 2e57ba5..caf7cea 100644
--- a/public/js/jitter.js
+++ b/public/js/jitter.js
@@ -1,7 +1,5 @@
// @ts-check
-/* Spread repeated timers, so several pollers started together do not keep
- firing on the same tick. Keep it dependency-free: it is imported inside
- widget frames as well as by the dashboard. */
+/* Keep this module free of imports. It is loaded inside widget frames. */
const SPREAD = 0.15;
diff --git a/public/js/label-contrast.js b/public/js/label-contrast.js
index 14eee75..d1eb614 100644
--- a/public/js/label-contrast.js
+++ b/public/js/label-contrast.js
@@ -138,15 +138,15 @@ export function toneForRect(grid, cols, rows, vw, vh, rect) {
/* ── The DOM half ─────────────────────────────────────────────────────────── */
-export const GRID_COLS = 32;
-export const GRID_ROWS = 18;
+const GRID_COLS = 32;
+const GRID_ROWS = 18;
/** Any CSS colour as an sRGB triple. A canvas keeps its previous fillStyle when
the value does not parse, so two seeds are needed to tell an unreadable value
from a real one.
@param {string} value @returns {[number, number, number]|null} */
-export function parseCssColor(value) {
+function parseCssColor(value) {
if (typeof value !== 'string' || !value.trim()) return null;
const read = (/** @type {string} */ seed) => {
const cv = document.createElement('canvas');
diff --git a/public/js/layout.js b/public/js/layout.js
index d6ca40c..dd0fb41 100644
--- a/public/js/layout.js
+++ b/public/js/layout.js
@@ -1,14 +1,6 @@
// @ts-check
-/* Whether to show the mobile layout, as one rule for the dashboard and Admin.
-
- The width test is a media query, not a stored innerWidth, so it answers the
- same question the stylesheets ask. The user-agent test covers phones that
- report a wider CSS viewport than 768px, where a width query alone leaves a
- phone on the desktop layout; it is qualified by portrait so a phone held
- sideways gets the wider layout it has room for.
-
- Keep this module free of imports: both entry points load it before anything
- else has run. */
+/* Keep this module free of imports. Both entry points load it first.
+ Why the test has two halves: docs/frontend.md, Layout. */
export const MOBILE_QUERY = '(max-width:768px)';
const PORTRAIT_QUERY = '(orientation:portrait)';
diff --git a/public/js/mobile-metrics.js b/public/js/mobile-metrics.js
index 0f0d5ee..70db323 100644
--- a/public/js/mobile-metrics.js
+++ b/public/js/mobile-metrics.js
@@ -1,10 +1,8 @@
-/* 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.
+/* Every size must derive from sc. A raw px here breaks 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. The platform reports an inset after the
- first paint, so a value read into JavaScript is stale from the moment it is
- taken. */
+ Never read a safe-area inset into JavaScript: it is reported after the first
+ paint, so the value is stale when it is taken. The stylesheet owns them. */
const BASE_VW = 393;
diff --git a/public/js/modal.js b/public/js/modal.js
index c5c0e81..7371999 100644
--- a/public/js/modal.js
+++ b/public/js/modal.js
@@ -1,12 +1,12 @@
// @ts-check
-/* The one modal in the admin UI: backdrop, Escape, focus trap, and focus
- returned to whatever opened it. Structure only. Callers fill the body. */
+/* Structure only; callers fill the body. */
-import { trapFocus } from '/js/dialog.js?v=05935547';
+/* A counter, not randomness. Six base-36 characters collide plausibly over a
+ long session, and an id that changes per run cannot be asserted on. */
+let _seq = 0;
+const uniqueId = prefix => `${prefix}-${++_seq}`;
/** Open a modal and return its parts. `close()` is safe to call more than once.
- `focus()` arms the trap, because the element worth focusing is usually one
- the caller has not appended yet.
@param {{ title: string, className?: string, onClose?: () => void }} opts
@returns {{ box: HTMLElement, body: HTMLElement, footer: HTMLElement,
@@ -14,19 +14,14 @@ import { trapFocus } from '/js/dialog.js?v=05935547';
addAction: (label: string, cls: string, onAct?: () => void) => HTMLButtonElement,
focus: (initial?: HTMLElement|null) => void }} */
export function openModal({ title, className, onClose }) {
- const ov = document.createElement('div');
- ov.className = 'dlg-ov' + (className ? ' ' + className : '');
-
- const box = document.createElement('div');
- box.className = 'dlg-box';
- box.setAttribute('role', 'dialog');
- box.setAttribute('aria-modal', 'true');
+ const box = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
+ box.className = 'dlg-box' + (className ? ' ' + className : '');
const hdr = document.createElement('div');
hdr.className = 'dlg-hdr';
/* Unique per open. Two ids point the second dialog's label at the first one's
heading. */
- hdr.id = 'dlg-hdr-' + Math.random().toString(36).slice(2, 8);
+ hdr.id = uniqueId('dlg-hdr');
hdr.textContent = title;
box.setAttribute('aria-labelledby', hdr.id);
@@ -36,24 +31,30 @@ export function openModal({ title, className, onClose }) {
const footer = document.createElement('div');
footer.className = 'dlg-foot';
- let release = () => {};
let closed = false;
const close = () => {
if (closed) return;
closed = true;
- release();
- ov.remove();
- if (onClose) onClose();
+ box.close();
};
- /* Both ends of the click. A text selection released past the dialog's edge
- would otherwise dismiss it. */
+ /* Escape and the close() above arrive here alike, so the caller is told once
+ however the dialog went away. */
+ box.addEventListener('close', () => {
+ closed = true;
+ box.remove();
+ if (onClose) onClose();
+ });
+
+ /* A click on the backdrop is reported against the dialog itself, because the
+ backdrop is not an element. Both ends of the click are checked: a text
+ selection released past the dialog's edge would otherwise dismiss it. */
let downOnBackdrop = false;
- ov.onmousedown = e => {
- downOnBackdrop = e.target === ov;
+ box.onmousedown = e => {
+ downOnBackdrop = e.target === box;
};
- ov.onclick = e => {
- if (e.target === ov && downOnBackdrop) close();
+ box.onclick = e => {
+ if (e.target === box && downOnBackdrop) close();
};
const addAction = (label, cls, onAct) => {
@@ -70,14 +71,13 @@ export function openModal({ title, className, onClose }) {
};
box.append(hdr, body, footer);
- ov.appendChild(box);
- document.body.appendChild(ov);
+ document.body.appendChild(box);
+ box.showModal();
+ /* The element worth focusing is usually one the caller appends after opening,
+ so showModal's own choice is corrected here. */
const focus = initial => {
- /* Arming twice is a caller re-rendering the body. The previous trap must
- go, or its listener outlives the dialog. */
- release();
- release = trapFocus(box, { onClose: close, initialFocus: initial });
+ if (initial) initial.focus();
};
return { box, body, footer, close, addAction, focus };
@@ -102,6 +102,18 @@ export function confirmModal({ title, body, confirmLabel, cancelLabel, destructi
});
}
+/** confirmModal for a plain sentence, which is what a replaced confirm() asks.
+
+ @param {{ title: string, text: string, confirmLabel: string, cancelLabel: string,
+ destructive?: boolean }} opts
+ @returns {Promise} */
+export function confirmText({ title, text, confirmLabel, cancelLabel, destructive }) {
+ const lead = document.createElement('p');
+ lead.className = 'dlg-lead';
+ lead.textContent = text;
+ return confirmModal({ title, body: lead, confirmLabel, cancelLabel, destructive });
+}
+
/** A modal asking for one line of text, resolving to the trimmed value or null.
@param {{ title: string, label: string, placeholder?: string,
diff --git a/public/js/settings-icon.js b/public/js/settings-icon.js
new file mode 100644
index 0000000..f49f776
--- /dev/null
+++ b/public/js/settings-icon.js
@@ -0,0 +1,6 @@
+// @ts-check
+/* Inlined, not a file under /icons: that path is a user volume, and a file of
+ the same name left there would shadow the bundled artwork. */
+
+export const SETTINGS_ICON =
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB0IiBoZWlnaHQ9IjEwMjRwdCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsPSIjZjJmMmY3IiBzdHJva2U9IiNmMmYyZjciIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gMzU1LjU1IDM5OC43NyBDIDM2NC45OSAzOTcuNjIgMzc0LjUxIDM5OC4xMCAzODQuMDAgMzk4LjAwIEMgNDc0LjY3IDM5OC4wMCA1NjUuMzQgMzk4LjAxIDY1Ni4wMSAzOTcuOTkgQyA2NzcuMTEgMzk3Ljk1IDY5OC4yMiA0MDMuODMgNzE2LjE1IDQxNC45OSBDIDc0Ni42MiA0MzMuNjAgNzY3LjMwIDQ2Ny4zMiA3NjkuNjYgNTAzLjAwIEMgNzcyLjYwIDUzOC4xNiA3NTcuNzIgNTc0LjA5IDczMS4wMCA1OTcuMDcgQyA3MTQuMjIgNjExLjcyIDY5My4wNCA2MjEuMjcgNjcwLjk0IDYyNC4wNSBDIDY2MS42NyA2MjUuMzQgNjUyLjMwIDYyNC45NCA2NDIuOTggNjI1LjAwIEMgNTU3LjMxIDYyNS4wMCA0NzEuNjQgNjI1LjAwIDM4NS45NyA2MjUuMDAgQyAzNzMuMzAgNjI0LjkyIDM2MC41MCA2MjUuNjAgMzQ3Ljk5IDYyMy4xMCBDIDMxNy4yMiA2MTcuNjAgMjg5LjIzIDU5OC42MiAyNzIuNjEgNTcyLjE2IEMgMjU5Ljc3IDU1Mi4wMyAyNTMuNjAgNTI3Ljc1IDI1NS4yNiA1MDMuOTMgQyAyNTYuNzAgNDgxLjI4IDI2NS4yNCA0NTkuMTcgMjc5LjMyIDQ0MS4zNyBDIDI5Ny43MyA0MTcuNzkgMzI1LjgwIDQwMi4wNiAzNTUuNTUgMzk4Ljc3IE0gNDY5LjMxIDQxMC40NyBDIDQ1Mi43OSA0MTIuMjIgNDM2LjYwIDQxNy41OCA0MjIuNzEgNDI2Ljc4IEMgNDAwLjE2IDQ0MS40MCAzODQuMDUgNDY1LjUyIDM3OS4xMCA0OTEuOTMgQyAzNzQuNjQgNTExLjQwIDM3Ny42NCA1MzEuODcgMzg0Ljc1IDU1MC4zMiBDIDM4OC4wNiA1NTcuNDAgMzkxLjMzIDU2NC41OSAzOTYuMjYgNTcwLjczIEMgNDE0Ljg1IDU5Ny42MyA0NDcuNTcgNjEzLjMwIDQ4MC4wNSA2MTMuMDEgQyA1MzguNzIgNjEzLjAwIDU5Ny4zOCA2MTMuMDQgNjU2LjA1IDYxMi45NyBDIDY4NC44OSA2MTIuMjggNzEzLjU2IDU5OS4zNyA3MzIuMDAgNTc2Ljk3IEMgNzM2LjY3IDU3MC44MyA3NDEuNTkgNTY0Ljc4IDc0NC43NCA1NTcuNjggQyA3NTAuMTEgNTQ3LjkxIDc1Mi44NSA1MzYuOTYgNzU0Ljg0IDUyNi4wNyBDIDc1Ny45MiA1MDguMzMgNzU0Ljc2IDQ5MC4wNiA3NDguNTMgNDczLjM1IEMgNzQ1LjE5IDQ2NS43NSA3NDEuNDcgNDU4LjI2IDczNi4zMiA0NTEuNzEgQyA3MjQuOTYgNDM1LjQxIDcwOC4xMyA0MjMuMzYgNjg5LjY5IDQxNi4zMSBDIDY3OC4xNiA0MTIuNTkgNjY2LjEzIDQwOS44NyA2NTMuOTYgNDEwLjAzIEMgNTk3LjI5IDQwOS45NSA1NDAuNjEgNDEwLjAzIDQ4My45NCA0MTAuMDAgQyA0NzkuMDYgNDA5Ljk4IDQ3NC4xNyA0MDkuOTQgNDY5LjMxIDQxMC40NyBaIiAvPgo8cGF0aCBmaWxsPSIjZGZkZmU0IiBzdHJva2U9IiNkZmRmZTQiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY5LjMxIDQxMC40NyBDIDQ3NC4xNyA0MDkuOTQgNDc5LjA2IDQwOS45OCA0ODMuOTQgNDEwLjAwIEMgNTQwLjYxIDQxMC4wMyA1OTcuMjkgNDA5Ljk1IDY1My45NiA0MTAuMDMgQyA2NjYuMTMgNDA5Ljg3IDY3OC4xNiA0MTIuNTkgNjg5LjY5IDQxNi4zMSBDIDcwOC4xMyA0MjMuMzYgNzI0Ljk2IDQzNS40MSA3MzYuMzIgNDUxLjcxIEMgNzQxLjQ3IDQ1OC4yNiA3NDUuMTkgNDY1Ljc1IDc0OC41MyA0NzMuMzUgQyA3NTQuNzYgNDkwLjA2IDc1Ny45MiA1MDguMzMgNzU0Ljg0IDUyNi4wNyBDIDc1Mi44NSA1MzYuOTYgNzUwLjExIDU0Ny45MSA3NDQuNzQgNTU3LjY4IEMgNzQxLjU5IDU2NC43OCA3MzYuNjcgNTcwLjgzIDczMi4wMCA1NzYuOTcgQyA3MTMuNTYgNTk5LjM3IDY4NC44OSA2MTIuMjggNjU2LjA1IDYxMi45NyBDIDU5Ny4zOCA2MTMuMDQgNTM4LjcyIDYxMy4wMCA0ODAuMDUgNjEzLjAxIEMgNDQ3LjU3IDYxMy4zMCA0MTQuODUgNTk3LjYzIDM5Ni4yNiA1NzAuNzMgQyAzOTEuMzMgNTY0LjU5IDM4OC4wNiA1NTcuNDAgMzg0Ljc1IDU1MC4zMiBDIDM3Ny42NCA1MzEuODcgMzc0LjY0IDUxMS40MCAzNzkuMTAgNDkxLjkzIEMgMzg0LjA1IDQ2NS41MiA0MDAuMTYgNDQxLjQwIDQyMi43MSA0MjYuNzggQyA0MzYuNjAgNDE3LjU4IDQ1Mi43OSA0MTIuMjIgNDY5LjMxIDQxMC40NyBNIDQ2Ny40OCA0MjIuNjYgQyA0MzYuNjggNDI2LjEzIDQwOC43NCA0NDcuMDggMzk2LjUwIDQ3NS41MSBDIDM4Ny43NyA0OTUuMjcgMzg2LjU4IDUxOC4yMSAzOTMuMjEgNTM4Ljc2IEMgMzk4LjcyIDU1Ni4xNiA0MDkuNzUgNTcxLjc1IDQyNC4zMCA1ODIuNzYgQyA0MzkuMDQgNTk0LjA3IDQ1Ny4zOSA2MDAuNTEgNDc1Ljk1IDYwMS4wMCBDIDUzMi4zMiA2MDEuMDAgNTg4LjY5IDYwMS4wMCA2NDUuMDYgNjAxLjAwIEMgNjUyLjE5IDYwMC45NCA2NTkuMzcgNjAxLjMzIDY2Ni40NiA2MDAuMjIgQyA2ODMuMTYgNTk4LjA4IDY5OS4xNCA1OTAuOTUgNzEyLjAyIDU4MC4xMSBDIDcyOC41OCA1NjYuMzIgNzM5Ljg5IDU0Ni4zMiA3NDMuMDAgNTI0Ljk4IEMgNzQ1Ljg2IDUwNi42NSA3NDIuNzYgNDg3LjQ2IDczNC4zNCA0NzAuOTQgQyA3MjYuNzQgNDU1Ljk3IDcxNC44NCA0NDMuMjMgNzAwLjQxIDQzNC42MyBDIDY4NS45NCA0MjUuODggNjY4LjkyIDQyMS41NCA2NTIuMDMgNDIxLjk5IEMgNTk1LjY2IDQyMi4wMSA1MzkuMjkgNDIyLjAwIDQ4Mi45MSA0MjIuMDAgQyA0NzcuNzYgNDIxLjk0IDQ3Mi41OSA0MjEuOTQgNDY3LjQ4IDQyMi42NiBaIiAvPgo8cGF0aCBmaWxsPSIjMzg3Zjk1IiBzdHJva2U9IiMzODdmOTUiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY3LjQ4IDQyMi42NiBDIDQ3Mi41OSA0MjEuOTQgNDc3Ljc2IDQyMS45NCA0ODIuOTEgNDIyLjAwIEMgNTM5LjI5IDQyMi4wMCA1OTUuNjYgNDIyLjAxIDY1Mi4wMyA0MjEuOTkgQyA2NjguOTIgNDIxLjU0IDY4NS45NCA0MjUuODggNzAwLjQxIDQzNC42MyBDIDcxNC44NCA0NDMuMjMgNzI2Ljc0IDQ1NS45NyA3MzQuMzQgNDcwLjk0IEMgNzQyLjc2IDQ4Ny40NiA3NDUuODYgNTA2LjY1IDc0My4wMCA1MjQuOTggQyA3MzkuODkgNTQ2LjMyIDcyOC41OCA1NjYuMzIgNzEyLjAyIDU4MC4xMSBDIDY5OS4xNCA1OTAuOTUgNjgzLjE2IDU5OC4wOCA2NjYuNDYgNjAwLjIyIEMgNjU5LjM3IDYwMS4zMyA2NTIuMTkgNjAwLjk0IDY0NS4wNiA2MDEuMDAgQyA1ODguNjkgNjAxLjAwIDUzMi4zMiA2MDEuMDAgNDc1Ljk1IDYwMS4wMCBDIDQ1Ny4zOSA2MDAuNTEgNDM5LjA0IDU5NC4wNyA0MjQuMzAgNTgyLjc2IEMgNDA5Ljc1IDU3MS43NSAzOTguNzIgNTU2LjE2IDM5My4yMSA1MzguNzYgQyAzODYuNTggNTE4LjIxIDM4Ny43NyA0OTUuMjcgMzk2LjUwIDQ3NS41MSBDIDQwOC43NCA0NDcuMDggNDM2LjY4IDQyNi4xMyA0NjcuNDggNDIyLjY2IFoiIC8+Cjwvc3ZnPg==';
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index d66a600..4e7dc18 100644
--- a/public/js/spotlight.js
+++ b/public/js/spotlight.js
@@ -1,6 +1,5 @@
-import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=8ca7ce3c';
-import { wrapTab } from '/js/dialog.js?v=05935547';
-import { t } from '/js/i18n.js?v=83239bf4';
+import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=d949e985';
+import { t } from '/js/i18n.js?v=e644a5c5';
/* Attached to the window so a re-open can undo the previous one. */
const _w = /** @type {any} */ (window);
@@ -9,14 +8,13 @@ const _w = /** @type {any} */ (window);
this module is loaded. */
export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDesktop, openFolderMobile }) {
const MOB = () => isMob();
- const ov = el('spot');
+ const ov = /** @type {HTMLDialogElement} */ (el('spot'));
const inp = inpById('sin');
const res = el('sres');
const cancelBtn = el('spot-cancel');
const live = el('sres-live');
let si = 0,
cur = [];
- let lastFocused = null;
inp.setAttribute('role', 'combobox');
inp.setAttribute('aria-autocomplete', 'list');
@@ -26,7 +24,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
res.setAttribute('aria-label', t('home.searchResults'));
const render = q => {
- res.innerHTML = '';
+ res.replaceChildren();
const apps = getItems().filter(i => i.type === 'app' || i.type === 'folder');
const qq = q.toLowerCase().trim();
cur = qq ? apps.filter(a => (a.label || a.id).toLowerCase().includes(qq)) : apps;
@@ -99,7 +97,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
step++;
if (step < srcs.length) img.src = srcs[step];
else {
- ic.innerHTML = '';
+ ic.replaceChildren();
ic.textContent = (app.label || app.id)[0].toUpperCase();
ic.style.color = '#fff';
ic.style.fontWeight = '600';
@@ -148,15 +146,9 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
}
});
- const barEl = q('.spot-bar', ov);
- if (MOB() && barEl) {
- barEl.style.cssText =
- 'display:flex;align-items:center;gap:12px;padding:16px 18px;border-radius:18px;border:none;background:rgba(118,118,128,.30);box-sizing:border-box;';
- inp.style.cssText =
- 'flex:1;background:transparent;border:0;outline:none;font-size:17px;color:rgba(255,255,255,.92);font-family:inherit;caret-color:#007aff;-webkit-appearance:none;min-height:26px;padding:0;margin:0;';
- }
- if (barEl)
- barEl.addEventListener(
+ const fieldEl = q('.spot-field', ov);
+ if (fieldEl)
+ fieldEl.addEventListener(
'touchend',
e => {
e.preventDefault();
@@ -166,20 +158,23 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
{ passive: false },
);
+ /* Set both edges. iOS scrolls a smaller visual viewport over an unchanged
+ page, so the height alone leaves the overlay off the screen. */
function _applyKbLayout() {
if (!MOB()) return;
- const vvH = window.visualViewport ? window.visualViewport.height : window.innerHeight;
- ov.style.bottom = Math.max(0, window.innerHeight - vvH) + 'px';
+ const vv = window.visualViewport;
+ if (!vv) return;
+ const top = Math.max(0, vv.offsetTop);
+ const bottom = Math.max(0, window.innerHeight - vv.height - top);
+ ov.style.top = top + 'px';
+ ov.style.bottom = bottom + 'px';
+ ov.classList.toggle('kb', bottom > 120);
}
- const trap = e => {
- wrapTab(e, ov);
- };
function open(ch) {
- lastFocused = document.activeElement;
+ if (ov.open) return;
+ ov.showModal();
ov.classList.add('on');
- ov.setAttribute('aria-hidden', 'false');
- ov.addEventListener('keydown', trap);
inp.value = ch || '';
render(inp.value);
if (MOB() && window.visualViewport) {
@@ -206,28 +201,36 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
);
}
+ /* Escape would close the dialog outright and skip the fade, so it is refused
+ and routed through close() like every other dismissal. */
+ ov.addEventListener('cancel', e => {
+ e.preventDefault();
+ close();
+ });
+
+ /* The dialog stays open for the length of the fade, so the page behind it
+ is inert until the overlay has actually gone. The browser returns focus
+ when it closes. */
+ ov.addEventListener('close', () => {
+ ov.classList.remove('on');
+ inp.value = '';
+ res.replaceChildren();
+ });
+
function close() {
+ if (!ov.open) return;
ov.classList.remove('vis');
- ov.removeEventListener('keydown', trap);
- ov.setAttribute('aria-hidden', 'true');
inp.setAttribute('aria-expanded', 'false');
inp.setAttribute('aria-activedescendant', '');
- if (lastFocused && lastFocused.focus) {
- try {
- lastFocused.focus();
- } catch {}
- lastFocused = null;
- }
if (_w._spotVpCleanup) {
_w._spotVpCleanup();
_w._spotVpCleanup = null;
}
+ ov.style.top = '';
ov.style.bottom = '';
+ ov.classList.remove('kb');
setTimeout(() => {
- if (!ov.classList.contains('vis')) ov.classList.remove('on');
- inp.value = '';
- res.innerHTML = '';
- inp.blur();
+ if (!ov.classList.contains('vis')) ov.close();
}, 220);
}
@@ -251,18 +254,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
'touchend',
e => {
e.preventDefault();
- close();
- },
- { passive: false },
- );
- }
- const mobCancelBtn = el('spot-cancel-mob-btn');
- if (mobCancelBtn) {
- mobCancelBtn.onclick = close;
- mobCancelBtn.addEventListener(
- 'touchend',
- e => {
- e.preventDefault();
+ e.stopPropagation();
close();
},
{ passive: false },
diff --git a/public/js/theme.js b/public/js/theme.js
index 6d72aef..8e00bf5 100644
--- a/public/js/theme.js
+++ b/public/js/theme.js
@@ -1,15 +1,6 @@
-/* The Settings page appearance: dark, light, or whichever the device asks for.
-
- The choice is per device, not per dashboard. It is read from localStorage and
- never written to the config, so two people opening Settings on two machines
- each get their own, and a machine keeps its own through a config import.
-
- "system" is resolved here rather than in a media query. The stylesheets carry
- one light block, selected by data-theme, and a prefers-color-scheme copy of it
- would be a second place to change every colour.
-
- ui/js/admin-theme.js repeats what this file decides so the attribute is on
- before the first paint. A parity test holds the two together. */
+/* Never write the choice to the config: it is per device.
+ Why it is resolved here rather than in a media query, and why admin-theme.js
+ repeats it: docs/frontend.md, Theme. */
export const THEME_KEY = 'sy-theme';
diff --git a/public/js/ui.js b/public/js/ui.js
index 16bf7c2..ea5cf67 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -1,5 +1,5 @@
import { iconChain } from '/js/icons.js?v=69c2b9bd';
-import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=6a5e1619';
+import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=a1b61636';
import {
mk,
clr,
@@ -11,11 +11,10 @@ import {
q,
qa,
setUserText,
-} from '/js/utils.js?v=8ca7ce3c';
-import { t, currentLang } from '/js/i18n.js?v=83239bf4';
-import { trapFocus } from '/js/dialog.js?v=05935547';
-import { toneForColor } from '/js/label-contrast.js?v=38adb276';
-import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=424d5d41';
+} from '/js/utils.js?v=d949e985';
+import { t, currentLang } from '/js/i18n.js?v=e644a5c5';
+import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
+import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=7be08bb0';
let _state = null;
export function initUI(state) {
@@ -34,6 +33,8 @@ const st = () => _state;
const widgetReg = () => _state?.widgetReg || Object.create(null);
const mkWrap = (item, sz, r, isz, cls) => _mkWrap(item, sz, r, isz, cls, breg);
+const folderName = f => (f.label ? t('type.folderNamed', { name: f.label }) : t('type.folder'));
+
/* A tap outside a widget lands in the parent document. Widgets expose
window.__clearActive. */
function clearMobWidgets(exceptWin) {
@@ -101,16 +102,13 @@ function mkMiniIcon(child, pointerEvents) {
export function mkFolder(item) {
const showLabel = S().showLabels?.desktop !== false;
const iw = showLabel ? 72 : 78;
- const a = mk('a');
+ const a = mk('button');
+ a.type = 'button';
a.className = 'icon';
- a.style.cursor = 'pointer';
- a.href = '#';
- a.setAttribute('role', 'button');
- a.setAttribute('aria-label', (item.label || t('type.folder')) + ' folder');
- a.dataset.tileName = (item.label || t('type.folder')) + ' folder';
+ a.setAttribute('aria-label', folderName(item));
+ a.dataset.tileName = folderName(item);
if (!showLabel) a.title = item.label || t('type.folder');
- a.onclick = e => {
- e.preventDefault();
+ a.onclick = () => {
openFolderDesktop(item);
};
const box = mk('div');
@@ -157,14 +155,10 @@ export function openFolderDesktop(folder) {
}
const children = (folder.children || []).map(id => items().find(i => i.id === id)).filter(Boolean);
const showLabel = S().showLabels?.desktop !== false;
- const ov = mk('div');
+ const ov = /** @type {HTMLDialogElement} */ (mk('dialog'));
ov.className = 'folder-overlay';
- ov.setAttribute('role', 'dialog');
- ov.setAttribute('aria-modal', 'true');
- ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder');
+ ov.setAttribute('aria-label', folderName(folder));
ov.tabIndex = -1;
- const _prevFocus = /** @type {HTMLElement} */ (document.activeElement);
- let releaseDeskTrap = null;
const outer = mk('div');
outer.className = 'folder-outer';
const title = mk('div');
@@ -202,33 +196,28 @@ export function openFolderDesktop(folder) {
children.forEach(c => bupd(c.id));
qa('.badge', grid).forEach(el => registeredBadges.push(el));
function closeDesk() {
+ ov.close();
+ }
+ /* Escape and the tile's own toggle arrive here alike, and a badge registered
+ to an element that has gone keeps the dashboard repainting it. */
+ ov.addEventListener('close', () => {
registeredBadges.forEach(el => BEL().forEach((_, id) => bunreg(id, el)));
- document.removeEventListener('keydown', escDesk);
- if (releaseDeskTrap) {
- releaseDeskTrap();
- releaseDeskTrap = null;
- }
ov.remove();
folderOverlay = null;
- if (_prevFocus && _prevFocus.focus) _prevFocus.focus();
- }
+ });
+ /* The overlay is the scrim, so a click reported against it is a click
+ outside the folder. */
ov.onclick = e => {
if (e.target === ov) closeDesk();
};
- const escDesk = e => {
- if (e.key === 'Escape') {
- closeDesk();
- document.removeEventListener('keydown', escDesk);
- }
- };
- document.addEventListener('keydown', escDesk);
outer.append(title, box);
ov.appendChild(outer);
document.body.appendChild(ov);
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, initialFocus: ov });
+ /* Not show(): only showModal makes the dashboard behind it inert, and the
+ tiles under the scrim were reachable by Tab and by a screen reader. */
+ ov.showModal();
+ ov.focus();
}
function mFolder(item, cw, rh, isz, ir, im, sc) {
@@ -237,8 +226,8 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
const a = document.createElement('button');
a.type = 'button';
a.className = 'dyn-mob-btn';
- a.setAttribute('aria-label', (item.label || t('type.folder')) + ' folder');
- a.dataset.tileName = (item.label || t('type.folder')) + ' folder';
+ a.setAttribute('aria-label', folderName(item));
+ a.dataset.tileName = folderName(item);
css(a, { '--rh': rh + 'px' });
let _opening = false;
function _openFolder() {
@@ -340,23 +329,21 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
let curPage = 0;
const vw = innerWidth,
vh = innerHeight;
- const ov = mk('div');
+ const ov = /** @type {HTMLDialogElement} */ (mk('dialog'));
ov.className = 'folder-overlay-mobile';
- ov.setAttribute('role', 'dialog');
- ov.setAttribute('aria-modal', 'true');
- ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder');
+ ov.setAttribute('aria-label', folderName(folder));
ov.tabIndex = -1;
- let releaseMobTrap = null;
function closeMob() {
+ ov.close();
+ }
+ /* A badge registered to an element that has gone keeps the dashboard
+ repainting it. */
+ ov.addEventListener('close', () => {
qa('.badge', ov).forEach(el => BEL().forEach((_, id) => bunreg(id, el)));
- if (releaseMobTrap) {
- releaseMobTrap();
- releaseMobTrap = null;
- }
ov.remove();
folderOverlayMob = null;
- }
+ });
const ptScale = vw / 393;
const margin = Math.round(34 * ptScale),
@@ -546,7 +533,8 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
);
document.body.appendChild(ov);
folderOverlayMob = ov;
- releaseMobTrap = trapFocus(ov, { onClose: closeMob, initialFocus: ov });
+ ov.showModal();
+ ov.focus();
}
/* Cells per widget size on the 4x6 home grid. */
@@ -574,7 +562,7 @@ export function buildMobile() {
xlarge 4×6. A footprint is a physical size, so it does not change with the
column count: a wider box gets more columns, not larger widgets. */
const strip = el('pages');
- strip.innerHTML = '';
+ strip.replaceChildren();
function mkPage() {
const p = mk('div');
@@ -758,7 +746,7 @@ export function buildMobile() {
const dw = el('dots');
dw.style.cssText = 'display:none';
- dw.innerHTML = '';
+ dw.replaceChildren();
const dk = el('dock');
dk.className = 'mdock';
@@ -775,8 +763,8 @@ export function buildMobile() {
? dock.length * dockIconSz + (dock.length - 1) * Math.round(22 * sc) + dockPad * 2
: maxDockW;
const dockW = Math.min(maxDockW, dockContentW);
- dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;-webkit-transform:translateX(-50%);transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`;
- dk.innerHTML = '';
+ dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`;
+ dk.replaceChildren();
dock.forEach(item => {
const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' });
a.className = 'dyn-dock-icon';
@@ -794,11 +782,11 @@ export function buildMobile() {
_pdotPad = Math.round(14 * sc);
const pillDotsW = pages.length * (_pdotSz + _pdotGap) - _pdotGap + _pdotPad * 2;
const pill = el('mob-search-pill');
- pill.style.cssText = `position:fixed;left:50%;bottom:${dockGap + dh + pillGap}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:${dockGap + dh + pillGap}px;transform:translateX(-50%);width:${pillSearchW}px;height:${pillH}px;display:flex;z-index:500;`;
const pillNew = /** @type {HTMLElement} */ (pill.cloneNode(true));
const pillNewDots = q('.msp-dots', pillNew);
- pillNewDots.innerHTML = '';
+ pillNewDots.replaceChildren();
const pillDotEls = pages.map((_, i) => {
const d = document.createElement('div');
d.className = 'msp-dot' + (i === 0 ? ' on' : '');
diff --git a/public/js/utils.js b/public/js/utils.js
index dc1c2e7..0966259 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -1,5 +1,6 @@
import { iconChain } from '/js/icons.js?v=69c2b9bd';
-import { toneForColor } from '/js/label-contrast.js?v=38adb276';
+import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
+import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13';
export const mk = (t, a = {}) => {
const e = document.createElement(t);
@@ -10,7 +11,7 @@ export const mk = (t, a = {}) => {
arrive in an imported config. It is assigned to a background, where a CSS
url() fetches from whatever host it names. */
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([0-9a-z%.,\s/+-]*\)|[a-z]{3,20})$/i;
-export const DEFAULT_TILE_COLOR = '#1C1C1E';
+const DEFAULT_TILE_COLOR = '#1C1C1E';
export const clr = c => {
if (!c || c === 'dark') return DEFAULT_TILE_COLOR;
if (c === 'light') return '#F2F2F7';
@@ -106,14 +107,11 @@ export const qa = (sel, root = document) => /** @type {HTMLElement[]} */ ([...ro
export const tgt = e => /** @type {HTMLInputElement} */ (e.target);
/* breg is passed in to avoid a circular import. */
-const SETTINGS_ICON =
- 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB0IiBoZWlnaHQ9IjEwMjRwdCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsPSIjZjJmMmY3IiBzdHJva2U9IiNmMmYyZjciIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gMzU1LjU1IDM5OC43NyBDIDM2NC45OSAzOTcuNjIgMzc0LjUxIDM5OC4xMCAzODQuMDAgMzk4LjAwIEMgNDc0LjY3IDM5OC4wMCA1NjUuMzQgMzk4LjAxIDY1Ni4wMSAzOTcuOTkgQyA2NzcuMTEgMzk3Ljk1IDY5OC4yMiA0MDMuODMgNzE2LjE1IDQxNC45OSBDIDc0Ni42MiA0MzMuNjAgNzY3LjMwIDQ2Ny4zMiA3NjkuNjYgNTAzLjAwIEMgNzcyLjYwIDUzOC4xNiA3NTcuNzIgNTc0LjA5IDczMS4wMCA1OTcuMDcgQyA3MTQuMjIgNjExLjcyIDY5My4wNCA2MjEuMjcgNjcwLjk0IDYyNC4wNSBDIDY2MS42NyA2MjUuMzQgNjUyLjMwIDYyNC45NCA2NDIuOTggNjI1LjAwIEMgNTU3LjMxIDYyNS4wMCA0NzEuNjQgNjI1LjAwIDM4NS45NyA2MjUuMDAgQyAzNzMuMzAgNjI0LjkyIDM2MC41MCA2MjUuNjAgMzQ3Ljk5IDYyMy4xMCBDIDMxNy4yMiA2MTcuNjAgMjg5LjIzIDU5OC42MiAyNzIuNjEgNTcyLjE2IEMgMjU5Ljc3IDU1Mi4wMyAyNTMuNjAgNTI3Ljc1IDI1NS4yNiA1MDMuOTMgQyAyNTYuNzAgNDgxLjI4IDI2NS4yNCA0NTkuMTcgMjc5LjMyIDQ0MS4zNyBDIDI5Ny43MyA0MTcuNzkgMzI1LjgwIDQwMi4wNiAzNTUuNTUgMzk4Ljc3IE0gNDY5LjMxIDQxMC40NyBDIDQ1Mi43OSA0MTIuMjIgNDM2LjYwIDQxNy41OCA0MjIuNzEgNDI2Ljc4IEMgNDAwLjE2IDQ0MS40MCAzODQuMDUgNDY1LjUyIDM3OS4xMCA0OTEuOTMgQyAzNzQuNjQgNTExLjQwIDM3Ny42NCA1MzEuODcgMzg0Ljc1IDU1MC4zMiBDIDM4OC4wNiA1NTcuNDAgMzkxLjMzIDU2NC41OSAzOTYuMjYgNTcwLjczIEMgNDE0Ljg1IDU5Ny42MyA0NDcuNTcgNjEzLjMwIDQ4MC4wNSA2MTMuMDEgQyA1MzguNzIgNjEzLjAwIDU5Ny4zOCA2MTMuMDQgNjU2LjA1IDYxMi45NyBDIDY4NC44OSA2MTIuMjggNzEzLjU2IDU5OS4zNyA3MzIuMDAgNTc2Ljk3IEMgNzM2LjY3IDU3MC44MyA3NDEuNTkgNTY0Ljc4IDc0NC43NCA1NTcuNjggQyA3NTAuMTEgNTQ3LjkxIDc1Mi44NSA1MzYuOTYgNzU0Ljg0IDUyNi4wNyBDIDc1Ny45MiA1MDguMzMgNzU0Ljc2IDQ5MC4wNiA3NDguNTMgNDczLjM1IEMgNzQ1LjE5IDQ2NS43NSA3NDEuNDcgNDU4LjI2IDczNi4zMiA0NTEuNzEgQyA3MjQuOTYgNDM1LjQxIDcwOC4xMyA0MjMuMzYgNjg5LjY5IDQxNi4zMSBDIDY3OC4xNiA0MTIuNTkgNjY2LjEzIDQwOS44NyA2NTMuOTYgNDEwLjAzIEMgNTk3LjI5IDQwOS45NSA1NDAuNjEgNDEwLjAzIDQ4My45NCA0MTAuMDAgQyA0NzkuMDYgNDA5Ljk4IDQ3NC4xNyA0MDkuOTQgNDY5LjMxIDQxMC40NyBaIiAvPgo8cGF0aCBmaWxsPSIjZGZkZmU0IiBzdHJva2U9IiNkZmRmZTQiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY5LjMxIDQxMC40NyBDIDQ3NC4xNyA0MDkuOTQgNDc5LjA2IDQwOS45OCA0ODMuOTQgNDEwLjAwIEMgNTQwLjYxIDQxMC4wMyA1OTcuMjkgNDA5Ljk1IDY1My45NiA0MTAuMDMgQyA2NjYuMTMgNDA5Ljg3IDY3OC4xNiA0MTIuNTkgNjg5LjY5IDQxNi4zMSBDIDcwOC4xMyA0MjMuMzYgNzI0Ljk2IDQzNS40MSA3MzYuMzIgNDUxLjcxIEMgNzQxLjQ3IDQ1OC4yNiA3NDUuMTkgNDY1Ljc1IDc0OC41MyA0NzMuMzUgQyA3NTQuNzYgNDkwLjA2IDc1Ny45MiA1MDguMzMgNzU0Ljg0IDUyNi4wNyBDIDc1Mi44NSA1MzYuOTYgNzUwLjExIDU0Ny45MSA3NDQuNzQgNTU3LjY4IEMgNzQxLjU5IDU2NC43OCA3MzYuNjcgNTcwLjgzIDczMi4wMCA1NzYuOTcgQyA3MTMuNTYgNTk5LjM3IDY4NC44OSA2MTIuMjggNjU2LjA1IDYxMi45NyBDIDU5Ny4zOCA2MTMuMDQgNTM4LjcyIDYxMy4wMCA0ODAuMDUgNjEzLjAxIEMgNDQ3LjU3IDYxMy4zMCA0MTQuODUgNTk3LjYzIDM5Ni4yNiA1NzAuNzMgQyAzOTEuMzMgNTY0LjU5IDM4OC4wNiA1NTcuNDAgMzg0Ljc1IDU1MC4zMiBDIDM3Ny42NCA1MzEuODcgMzc0LjY0IDUxMS40MCAzNzkuMTAgNDkxLjkzIEMgMzg0LjA1IDQ2NS41MiA0MDAuMTYgNDQxLjQwIDQyMi43MSA0MjYuNzggQyA0MzYuNjAgNDE3LjU4IDQ1Mi43OSA0MTIuMjIgNDY5LjMxIDQxMC40NyBNIDQ2Ny40OCA0MjIuNjYgQyA0MzYuNjggNDI2LjEzIDQwOC43NCA0NDcuMDggMzk2LjUwIDQ3NS41MSBDIDM4Ny43NyA0OTUuMjcgMzg2LjU4IDUxOC4yMSAzOTMuMjEgNTM4Ljc2IEMgMzk4LjcyIDU1Ni4xNiA0MDkuNzUgNTcxLjc1IDQyNC4zMCA1ODIuNzYgQyA0MzkuMDQgNTk0LjA3IDQ1Ny4zOSA2MDAuNTEgNDc1Ljk1IDYwMS4wMCBDIDUzMi4zMiA2MDEuMDAgNTg4LjY5IDYwMS4wMCA2NDUuMDYgNjAxLjAwIEMgNjUyLjE5IDYwMC45NCA2NTkuMzcgNjAxLjMzIDY2Ni40NiA2MDAuMjIgQyA2ODMuMTYgNTk4LjA4IDY5OS4xNCA1OTAuOTUgNzEyLjAyIDU4MC4xMSBDIDcyOC41OCA1NjYuMzIgNzM5Ljg5IDU0Ni4zMiA3NDMuMDAgNTI0Ljk4IEMgNzQ1Ljg2IDUwNi42NSA3NDIuNzYgNDg3LjQ2IDczNC4zNCA0NzAuOTQgQyA3MjYuNzQgNDU1Ljk3IDcxNC44NCA0NDMuMjMgNzAwLjQxIDQzNC42MyBDIDY4NS45NCA0MjUuODggNjY4LjkyIDQyMS41NCA2NTIuMDMgNDIxLjk5IEMgNTk1LjY2IDQyMi4wMSA1MzkuMjkgNDIyLjAwIDQ4Mi45MSA0MjIuMDAgQyA0NzcuNzYgNDIxLjk0IDQ3Mi41OSA0MjEuOTQgNDY3LjQ4IDQyMi42NiBaIiAvPgo8cGF0aCBmaWxsPSIjMzg3Zjk1IiBzdHJva2U9IiMzODdmOTUiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY3LjQ4IDQyMi42NiBDIDQ3Mi41OSA0MjEuOTQgNDc3Ljc2IDQyMS45NCA0ODIuOTEgNDIyLjAwIEMgNTM5LjI5IDQyMi4wMCA1OTUuNjYgNDIyLjAxIDY1Mi4wMyA0MjEuOTkgQyA2NjguOTIgNDIxLjU0IDY4NS45NCA0MjUuODggNzAwLjQxIDQzNC42MyBDIDcxNC44NCA0NDMuMjMgNzI2Ljc0IDQ1NS45NyA3MzQuMzQgNDcwLjk0IEMgNzQyLjc2IDQ4Ny40NiA3NDUuODYgNTA2LjY1IDc0My4wMCA1MjQuOTggQyA3MzkuODkgNTQ2LjMyIDcyOC41OCA1NjYuMzIgNzEyLjAyIDU4MC4xMSBDIDY5OS4xNCA1OTAuOTUgNjgzLjE2IDU5OC4wOCA2NjYuNDYgNjAwLjIyIEMgNjU5LjM3IDYwMS4zMyA2NTIuMTkgNjAwLjk0IDY0NS4wNiA2MDEuMDAgQyA1ODguNjkgNjAxLjAwIDUzMi4zMiA2MDEuMDAgNDc1Ljk1IDYwMS4wMCBDIDQ1Ny4zOSA2MDAuNTEgNDM5LjA0IDU5NC4wNyA0MjQuMzAgNTgyLjc2IEMgNDA5Ljc1IDU3MS43NSAzOTguNzIgNTU2LjE2IDM5My4yMSA1MzguNzYgQyAzODYuNTggNTE4LjIxIDM4Ny43NyA0OTUuMjcgMzk2LjUwIDQ3NS41MSBDIDQwOC43NCA0NDcuMDggNDM2LjY4IDQyNi4xMyA0NjcuNDggNDIyLjY2IFoiIC8+Cjwvc3ZnPg==';
-
export function mkWrap(item, sz, r, isz, cls, breg) {
const w = mk('div');
if (cls) w.className = cls;
const wrapBg = item.system === 'settings' ? '#027eae' : clr(item.color);
- w.style.cssText = `width:${sz}px;height:${sz}px;border-radius:${r}px;background:${wrapBg};position:relative;flex-shrink:0;overflow:visible;display:-webkit-flex;display:flex;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;box-shadow:inset 1px 1px 0 rgba(255,255,255,.18),inset -1px -1px 0 rgba(0,0,0,.14);`;
+ w.style.cssText = `width:${sz}px;height:${sz}px;border-radius:${r}px;background:${wrapBg};position:relative;flex-shrink:0;overflow:visible;display:flex;align-items:center;justify-content:center;box-shadow:inset 1px 1px 0 rgba(255,255,255,.18),inset -1px -1px 0 rgba(0,0,0,.14);`;
const g = mk('div');
g.style.cssText = `position:absolute;inset:0;border-radius:${r}px;pointer-events:none;z-index:2;background:linear-gradient(135deg,rgba(255,255,255,.10) 0%,transparent 60%);`;
w.appendChild(g);
@@ -202,6 +200,23 @@ export function safeAllow(value) {
/* Mounts the iframe at a fixed design resolution and scales it to fill `card`.
`card` must be aspect-locked to the design ratio. */
+/* Withheld: allow-top-navigation. Without it a framed page cannot redirect the
+ dashboard through top.location. Everything a real service needs is granted,
+ including its own origin, so its session and storage still work.
+
+ Only a cross-origin frame gets this. A bundled widget is served from here, so
+ it would need allow-same-origin and the attribute would withhold nothing. */
+const SANDBOX =
+ 'allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads';
+
+const isCrossOrigin = src => {
+ try {
+ return new URL(src, location.href).origin !== location.origin;
+ } catch {
+ return false;
+ }
+};
+
/** @param {HTMLElement} card
@param {{
src?: string, title?: string, design?: [number, number],
@@ -221,6 +236,7 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla
const clip = mk('div');
clip.style.cssText = 'position:absolute;inset:0;overflow:hidden;';
const ifr = mk('iframe', { src, scrolling: o.scrolling === true || o.scrolling === 'yes' ? 'yes' : 'no', title });
+ if (isCrossOrigin(src)) ifr.setAttribute('sandbox', SANDBOX);
ifr.setAttribute('allow', safeAllow(o.allow));
if (o.allowFullscreen !== false) ifr.setAttribute('allowfullscreen', '');
if (o.referrerPolicy) ifr.setAttribute('referrerpolicy', o.referrerPolicy);
diff --git a/public/js/wallpaper-cache.js b/public/js/wallpaper-cache.js
new file mode 100644
index 0000000..87d1b35
--- /dev/null
+++ b/public/js/wallpaper-cache.js
@@ -0,0 +1,58 @@
+// @ts-check
+/* The wallpaper both first-party pages show. Reads and writes one entry so the
+ dashboard and the settings pages never show different photos. */
+
+export const WALLPAPER_STORE = 'dash_wallpaper';
+
+/** One day. */
+const WALLPAPER_TTL_MS = 24 * 60 * 60 * 1000;
+
+/** @param {any} bg the background settings @returns {string} */
+function wallpaperKey(bg) {
+ return `${bg?.type || ''}|${bg?.collection || ''}`;
+}
+
+/** The still-valid wallpaper URL held for these background settings, if any.
+
+ @param {string|null|undefined} stored the raw stored entry
+ @param {any} bg the background settings the page was built from
+ @param {number} now
+ @returns {string|null} */
+export function readWallpaperCache(stored, bg, now) {
+ if (typeof stored !== 'string' || !stored) return null;
+ let entry;
+ try {
+ entry = JSON.parse(stored);
+ } catch {
+ return null;
+ }
+ if (!entry || typeof entry.url !== 'string' || !entry.url) return null;
+ if (entry.key !== wallpaperKey(bg)) return null;
+ if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null;
+ /* A clock moved backwards would otherwise hold one photo forever. */
+ if (now < entry.at || now - entry.at >= WALLPAPER_TTL_MS) return null;
+ return entry.url;
+}
+
+/** @param {string} url @param {any} bg @param {number} now @returns {string} */
+export function writeWallpaperCache(url, bg, now) {
+ return JSON.stringify({ url, key: wallpaperKey(bg), at: now });
+}
+
+/** @param {any} bg @param {number} [now] @returns {string|null} */
+export function loadWallpaper(bg, now = Date.now()) {
+ let stored = null;
+ try {
+ stored = localStorage.getItem(WALLPAPER_STORE);
+ } catch {
+ return null;
+ }
+ return readWallpaperCache(stored, bg, now);
+}
+
+/** @param {string} url @param {any} bg @param {number} [now] @returns {void} */
+export function saveWallpaper(url, bg, now = Date.now()) {
+ try {
+ localStorage.setItem(WALLPAPER_STORE, writeWallpaperCache(url, bg, now));
+ } catch {}
+}
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index bd96599..de61b94 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -2,10 +2,10 @@
back. Each builder returns { el, get, control, liveValue }. See
docs/widgets.md. */
-import { t } from '/js/i18n.js?v=83239bf4';
+import { t } from '/js/i18n.js?v=e644a5c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { wireChecklist } from '/js/admin-shared.js?v=1d330931';
-import { renderColorControl } from '/js/admin-color-control.js?v=bfd0955a';
+import { wireChecklist } from '/js/admin-shared.js?v=132c869f';
+import { renderColorControl } from '/js/admin-color-control.js?v=3a61c02f';
import {
seedCarried,
applyOptionSet,
@@ -13,17 +13,24 @@ import {
requiredFieldMissing,
groupBounds,
visibleFieldFlags,
-} from '/js/admin-logic.js?v=d17394da';
+} from '/js/admin-logic.js?v=dcf7c37d';
import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
-import { qi } from '/js/utils.js?v=8ca7ce3c';
+import { qi } from '/js/utils.js?v=d949e985';
const PE =
'';
const CHEV =
'';
+/* A counter, not randomness. Six base-36 characters collide plausibly over a
+ long session, and an id that changes per run cannot be asserted on. */
+let _seq = 0;
+const uniqueId = prefix => `${prefix}-${++_seq}`;
+
function _tag(field) {
- return field.optional ? html` (optional)` : html` *`;
+ return field.optional
+ ? html` (${t('app.optional')})`
+ : html` *`;
}
function _ieRow(field, value, inputType) {
@@ -33,7 +40,7 @@ function _ieRow(field, value, inputType) {
row.className = 'row ie-row';
setHtml(
row,
- html`${field.label}${_tag(field)}${has ? value : ph}`,
+ html`${field.label}${_tag(field)}${has ? value : ph}`,
);
const rv = row.querySelector('.rv'),
inp = qi('.row-inp', row),
@@ -94,7 +101,7 @@ function _secret(field, isSet) {
const display = isSet ? t('common.configured') : t('common.notSet');
setHtml(
row,
- html`${field.label}${_tag(field)}${display}`,
+ html`${field.label}${_tag(field)}${display}`,
);
const rv = row.querySelector('.rv'),
inp = qi('.row-inp', row),
@@ -310,8 +317,8 @@ function _select(field, value, ctx, config = {}) {
);
if (chosen && !opts.some(o => String(o.value) === chosen))
items.unshift(html``);
- if (field.optional) items.unshift(html``);
- setHtml(sel, items.length ? html`${items}` : html``);
+ if (field.optional) items.unshift(html``);
+ setHtml(sel, items.length ? html`${items}` : html``);
}
paint();
@@ -375,7 +382,7 @@ function _pills(field, value) {
row.className = 'row';
const opts = Array.isArray(field.options) ? field.options : [];
let sel = value != null ? value : field.default != null ? field.default : opts[0] ? opts[0].value : '';
- const name = 'wcf-' + field.key + '-' + Math.random().toString(36).slice(2, 7);
+ const name = uniqueId('wcf-' + field.key);
setHtml(
row,
html`${field.label}
`,
@@ -442,8 +450,7 @@ function _multiselect(field, value) {
rows. */
function _color(field, value) {
const wrap = document.createElement('div');
- const idPrefix =
- 'wcf-' + String(field.key).replace(/[^a-zA-Z0-9_-]/g, '') + '-' + Math.random().toString(36).slice(2, 7);
+ const idPrefix = uniqueId('wcf-' + String(field.key).replace(/[^a-zA-Z0-9_-]/g, ''));
const initial =
value != null && value !== '' ? String(value) : field.default != null ? String(field.default) : '#0289ff';
const ctl = renderColorControl(wrap, {
@@ -557,7 +564,7 @@ function _group(field, rows, size, ctx) {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'wcf-add-row';
- setHtml(addBtn, html`+ Add ${field.label}`);
+ setHtml(addBtn, html`${t('common.addNamed', { name: field.label })}`);
addWrap.appendChild(addBtn);
wrap.appendChild(addWrap);
const fixed = min === max;
@@ -572,7 +579,7 @@ function _group(field, rows, size, ctx) {
let rowBuilt = [];
function render() {
- rowsHost.innerHTML = '';
+ rowsHost.replaceChildren();
rowBuilt = [];
data.forEach((rowData, idx) => {
const hdr = document.createElement('p');
@@ -661,7 +668,7 @@ function _buildSimple(field, config, ctx) {
}
export function renderWidgetConfigForm(container, fields, config = {}, opts = {}) {
- container.innerHTML = '';
+ container.replaceChildren();
const built = [];
const ctx = {
widgetId: (opts && opts.widgetId) || null,
diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js
index b672bf6..2f7a86b 100644
--- a/public/js/widget-glyphs.js
+++ b/public/js/widget-glyphs.js
@@ -1,11 +1,6 @@
// @ts-check
-/* A glyph per widget type for the Settings item list, drawn on the 24-unit grid
- the other icons in admin.js use. Inline rather than a file per glyph: they are
- stroked with currentColor, so they follow the theme and the increased-contrast
- block with no rule of their own, which an cannot do.
-
- A widget names one in its manifest. A widget that names none, or names one
- that is not here, keeps the size icon. */
+/* Drawn on the same 24-unit grid as the other item-list icons, and stroked
+ with currentColor so they follow the theme. Do not replace with an . */
/** @type {Record} */
const PATHS = {
diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js
index 4f8e381..ead62f3 100644
--- a/public/js/widget-toolbox.js
+++ b/public/js/widget-toolbox.js
@@ -3,7 +3,7 @@
import { esc, html, setHtml } from '/js/html.js?v=c71f8903';
import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f';
-import { jitter } from '/js/jitter.js?v=4edf48f2';
+import { jitter } from '/js/jitter.js?v=4eeef4c9';
export { esc, html, setHtml };
@@ -237,7 +237,7 @@ export function wt(key, fallback) {
/* Digit shape follows the reader's locale. Re-exported here so a widget takes it
from the toolbox rather than reaching for toLocaleString, which is the same
thing until someone passes it a language. */
-export { formatNumber, localiseDigits } from '/js/format-number.js?v=e2165e12';
+export { formatNumber, localiseDigits } from '/js/format-number.js?v=4a5ccef4';
export function sinceLabel(ts) {
if (!ts) return '';
@@ -281,24 +281,7 @@ function _overlay(root) {
};
}
-/* Fetch on a timer and render, keeping the last good render through a transient
- failure: errorText appears only after `staleAfter` consecutive failures, or at
- once if the widget has never loaded. Pass onError to draw the error instead,
- and poll shows no overlay.
-
- opts: {
- render, (data) => void draw a successful, non-empty result
- endpoint, optional data endpoint name for fetchData
- fetch, optional async () => data (defaults to fetchData(endpoint))
- isEmpty, optional (data) => bool a successful but empty result
- onError, optional (info) => void render your own error UI
- interval = 30000, poll period in ms, or (data) => ms to vary it
- with the last successful result
- staleAfter = 2, consecutive failures tolerated before showing the error
- root = document.body, element the status message overlays
- loadingText, emptyText, errorText
- }
- Returns { stop }, which also detaches the visibility listener. */
+/* The options and the poll lifecycle are documented in docs/widgets.md. */
/* Widgets on a page the user has swiped away from keep running: the dashboard
mounts every page at once. It multiplies their poll interval through
diff --git a/public/js/widget-types.js b/public/js/widget-types.js
index fab01fa..1a8e252 100644
--- a/public/js/widget-types.js
+++ b/public/js/widget-types.js
@@ -21,7 +21,7 @@ export const WIDGET_COST = {
/* The iframe URL, from the manifest entry in `reg`. The cache version is hashed
from file content at release, never maintained by hand. */
/* A `card` inside the selected view wins over the manifest's top-level one. */
-export const CARD_PRESETS = ['dark', 'light', 'translucent'];
+const CARD_PRESETS = ['dark', 'light', 'translucent'];
export function cardPreset(item, reg) {
const entry = item?.widgetType ? reg?.[item.widgetType] : null;
diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html
index bcbdea5..5ed24f0 100644
--- a/public/widgets/backup/backup.html
+++ b/public/widgets/backup/backup.html
@@ -114,7 +114,7 @@