diff --git a/public/img/dashboard/badge-labels.png b/public/img/dashboard/badge-labels.png
index cca0b6c..db3cf1f 100644
Binary files a/public/img/dashboard/badge-labels.png and b/public/img/dashboard/badge-labels.png differ
diff --git a/public/img/dashboard/badge-list.png b/public/img/dashboard/badge-list.png
index 704406a..f6c231d 100644
Binary files a/public/img/dashboard/badge-list.png and b/public/img/dashboard/badge-list.png differ
diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js
index 4980b03..a76128f 100644
--- a/public/js/admin-app-form.js
+++ b/public/js/admin-app-form.js
@@ -1,4 +1,4 @@
-import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=b18c93ed';
+import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=26566e09';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd';
import { state } from '/js/admin-state.js?v=c23e6346';
@@ -13,9 +13,9 @@ import {
initInlineEdit,
setTogDisabled,
wireChecklist,
-} from '/js/admin-shared.js?v=8f69dad6';
-import { MAX_LABELS } from '/js/badge-logic.js?v=be6330d6';
-import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=9fded679';
+} from '/js/admin-shared.js?v=e9afcefb';
+import { MAX_LABELS } from '/js/badge-logic.js?v=c6430afc';
+import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=32111fda';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
export function buildFolderForm(body, item) {
@@ -176,6 +176,7 @@ export function buildAppForm(body, item) {
+
${t('app.labelOrderTip')}
${t('app.labelMax', { n: MAX_LABELS })}
diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js
index 1719f33..91be26e 100644
--- a/public/js/admin-auth.js
+++ b/public/js/admin-auth.js
@@ -1,7 +1,7 @@
-import { ag, ap } from '/js/admin-shared.js?v=8f69dad6';
+import { ag, ap } from '/js/admin-shared.js?v=e9afcefb';
import { t } from '/js/i18n.js?v=d056c9c5';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
-import { el, inp as inpById, qa } from '/js/utils.js?v=b18c93ed';
+import { el, inp as inpById, qa } from '/js/utils.js?v=26566e09';
export async function checkAuth(onLogin) {
try {
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index 0e34e5e..67bd024 100644
--- a/public/js/admin-color-control.js
+++ b/public/js/admin-color-control.js
@@ -1,7 +1,7 @@
-import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=8f69dad6';
+import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=e9afcefb';
import { t } from '/js/i18n.js?v=d056c9c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { qa, q } from '/js/utils.js?v=b18c93ed';
+import { qa, q } from '/js/utils.js?v=26566e09';
const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'];
export const BADGE_DEFAULT = '#1e6ef4';
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index 24170ef..1353601 100644
--- a/public/js/admin-settings.js
+++ b/public/js/admin-settings.js
@@ -1,8 +1,8 @@
-import { toast, ag, ap } from '/js/admin-shared.js?v=8f69dad6';
+import { toast, ag, ap } from '/js/admin-shared.js?v=e9afcefb';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { t } from '/js/i18n.js?v=d056c9c5';
import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=ddfc6f80';
-import { el, inp, q, qa, setUserText } from '/js/utils.js?v=b18c93ed';
+import { el, inp, q, qa, setUserText } from '/js/utils.js?v=26566e09';
/* Mirrors the server's rule: auth cannot be switched on with no password. */
let _passwordSet = false;
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index 3c64877..8e291bd 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -1,6 +1,6 @@
/* Stateless helpers shared by the admin modules. Mutable state stays out. */
import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=ddfc6f80';
-import { el, qa, q } from '/js/utils.js?v=b18c93ed';
+import { el, qa, q } from '/js/utils.js?v=26566e09';
import { t } from '/js/i18n.js?v=d056c9c5';
export const API = '';
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index bb3cbe2..03d6e59 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,6 +1,6 @@
import { state } from '/js/admin-state.js?v=c23e6346';
-import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=8f69dad6';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=63dccc19';
+import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=e9afcefb';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=a68e6a57';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import {
sizesForView,
@@ -10,7 +10,7 @@ import {
carriesTypedValues,
} from '/js/admin-logic.js?v=ddfc6f80';
import { t } from '/js/i18n.js?v=d056c9c5';
-import { q, qi, qa } from '/js/utils.js?v=b18c93ed';
+import { q, qi, qa } from '/js/utils.js?v=26566e09';
const SIZE_ICONS = {
small:
diff --git a/public/js/admin.js b/public/js/admin.js
index 2db8baf..ea78f3e 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,5 +1,5 @@
-import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=d54926e8';
-import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5e6450f0';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=aa32f5ed';
+import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=2191f31e';
import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806';
import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=ddfc6f80';
import {
@@ -10,10 +10,10 @@ import {
snapshotItems,
upsertItem,
} from '/js/admin-save-logic.js?v=48a9e055';
-import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f7078bc0';
-import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=8f69dad6';
+import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=455deefb';
+import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=e9afcefb';
import { state } from '/js/admin-state.js?v=c23e6346';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=c4b1a7b4';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=5a2dc8e8';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5';
import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd';
@@ -28,9 +28,10 @@ import {
} from '/js/import-foreign.js?v=ef4f3d44';
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75';
import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56';
-import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef';
-import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b18c93ed';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=9fded679';
+import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=db4192cd';
+import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=26566e09';
+import { widgetGlyph } from '/js/widget-glyphs.js?v=b5036986';
+import { normalizeColorInput } from '/js/admin-color-control.js?v=32111fda';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788';
/* A class rather than a bare media query. Some phones report a wider CSS
@@ -235,7 +236,9 @@ function mkRow(item, idx, { indent = false, childIdx = null, folderId = null } =
if (item.type === 'folder') {
ico.appendChild(svgNode(FOLDER_ICON));
} else if (item.type === 'widget') {
- ico.appendChild(svgNode(SIZE_ICONS[item.widgetSize] || SIZE_ICONS.medium));
+ /* The type when the widget declares one, the size otherwise. */
+ const glyph = widgetGlyph(state._widgetReg?.[item.widgetType]?.glyph);
+ ico.appendChild(svgNode(glyph || SIZE_ICONS[item.widgetSize] || SIZE_ICONS.medium));
} else if (item.iconUrl) {
const img = document.createElement('img');
img.alt = item.label || '';
diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js
index 9c7ce1b..2c45da1 100644
--- a/public/js/badge-logic.js
+++ b/public/js/badge-logic.js
@@ -1,6 +1,7 @@
// @ts-check
/* Keep this file free of imports and of module state. Translation arrives
- through an injected `translate`; a caller that passes none gets English. */
+ through an injected `translate`, and the digit shape through an injected
+ `format`; a caller that passes neither gets English and Latin digits. */
const EN = {
'status.needsAttention': 'Status: needs attention',
'status.healthy': 'Status: healthy',
@@ -85,8 +86,14 @@ export function healthReason(detail, translate) {
parts.push(_clip(detail.status, REASON_MAX));
}
- if (detail.pingError) parts.push(_clip(tr('status.pingFailed', { error: detail.pingError }), REASON_MAX));
- else if (detail.pingStatus >= 400) parts.push(tr('status.pingReturned', { status: detail.pingStatus }));
+ /* Give the room to the translated words and trim the upstream ones. The error
+ text comes from the network layer and is never translated, so clipping the
+ composed sentence cuts the half a reader can actually use. */
+ if (detail.pingError) {
+ const around = tr('status.pingFailed', { error: '' }).length;
+ const room = Math.max(8, REASON_MAX - around);
+ parts.push(_clip(tr('status.pingFailed', { error: _clip(detail.pingError, room) }), REASON_MAX));
+ } else if (detail.pingStatus >= 400) parts.push(tr('status.pingReturned', { status: detail.pingStatus }));
return parts.join(' \u2022 ');
}
@@ -147,6 +154,7 @@ export function firingLabels(labels, values) {
badgesStale?: boolean, healthStale?: boolean, activityStale?: boolean,
healthDetail?: Record,
translate?: (key: string, vars?: Record) => string,
+ format?: (value: number) => string,
}} opts */
export function computeBadgeVisual({
health,
@@ -162,8 +170,11 @@ export function computeBadgeVisual({
activityStale,
healthDetail,
translate,
+ format,
}) {
const tr = typeof translate === 'function' ? translate : (k, v) => _fallback(k, v);
+ /* Digit shape is the reader's, not the interface language's. */
+ const fmt = typeof format === 'function' ? format : v => String(v);
/* Below this the item is treated as having no activity at all. One keeps the
original behaviour of badging any count above zero. */
const min = badgeMinimum(custom);
@@ -182,12 +193,12 @@ export function computeBadgeVisual({
num = '!';
} else if (top) {
cls = 'badge on blue';
- num = top.value > 99 ? '99+' : String(top.value);
+ num = top.value > 99 ? `${fmt(99)}+` : fmt(top.value);
unit = top.unit ? top.unit.slice(0, 8) : '';
bg = top.color;
} else if (active) {
cls = 'badge on blue';
- num = activity > 99 ? '99+' : String(activity);
+ num = activity > 99 ? `${fmt(99)}+` : fmt(activity);
unit = custom.unit ? custom.unit.slice(0, 8) : '';
bg = safeColor(custom.color);
} else if (fixed) {
@@ -206,10 +217,10 @@ export function computeBadgeVisual({
/* Status text, so meaning is not carried by colour alone. */
let aria = '';
if (health) aria = tr('status.needsAttention');
- else if (top) aria = top.name ? `${top.name}: ${top.value}` : tr('status.pending', { count: String(top.value) });
+ else if (top) aria = top.name ? `${top.name}: ${fmt(top.value)}` : tr('status.pending', { count: fmt(top.value) });
else if (active)
aria = tr('status.pending', {
- count: (activity > 99 ? '99+' : String(activity)) + (custom.unit ? ' ' + custom.unit : ''),
+ count: (activity > 99 ? `${fmt(99)}+` : fmt(activity)) + (custom.unit ? ' ' + custom.unit : ''),
});
else if (fixed) aria = staticBdg.label;
else if (cls.includes('green')) aria = tr('status.healthy');
diff --git a/public/js/badge-popover.js b/public/js/badge-popover.js
index cf7a806..702d076 100644
--- a/public/js/badge-popover.js
+++ b/public/js/badge-popover.js
@@ -4,6 +4,9 @@
const HOVER_IN_MS = 320;
const COMPAT_CLICK_MS = 500;
+/* SC 1.4.13 asks that hover content be hoverable. The pointer has to cross the
+ gap between the badge and the popover without it closing underneath. */
+const HOVER_OUT_MS = 200;
/** @type {HTMLElement|null} */
let pop = null;
@@ -12,6 +15,7 @@ let openFor = null;
/** @type {HTMLElement|null} */
let describedEl = null;
let openTimer = 0;
+let closeTimer = 0;
let openedAt = 0;
const ROWS = new WeakMap();
@@ -23,6 +27,10 @@ function ensurePop() {
pop.id = 'badge-pop';
pop.setAttribute('role', 'tooltip');
pop.hidden = true;
+ /* Interactive now, so a click on it must not reach the tile underneath. */
+ pop.addEventListener('pointerenter', cancelClose);
+ pop.addEventListener('pointerleave', scheduleClose);
+ pop.addEventListener('click', e => e.stopPropagation());
document.body.appendChild(pop);
return pop;
}
@@ -42,7 +50,10 @@ function place(badge) {
const gap = 8;
let top = r.bottom + gap;
if (top + pr.height > window.innerHeight - 8) top = Math.max(8, r.top - gap - pr.height);
- let left = r.right - pr.width;
+ /* Aligned with the badge's trailing edge, which is the other side of the
+ screen in a right-to-left language. */
+ const rtl = (document.documentElement.getAttribute('dir') || 'ltr') === 'rtl';
+ let left = rtl ? r.left : r.right - pr.width;
left = Math.min(Math.max(8, left), window.innerWidth - pr.width - 8);
p.style.top = Math.round(top) + 'px';
p.style.left = Math.round(left) + 'px';
@@ -84,6 +95,8 @@ function open(badge) {
export function closeBadgePopover() {
clearTimeout(openTimer);
openTimer = 0;
+ clearTimeout(closeTimer);
+ closeTimer = 0;
if (pop) pop.hidden = true;
openFor = null;
if (describedEl) {
@@ -94,10 +107,25 @@ export function closeBadgePopover() {
/** @param {HTMLElement} badge */
function schedule(badge, delay) {
+ clearTimeout(closeTimer);
+ closeTimer = 0;
clearTimeout(openTimer);
openTimer = window.setTimeout(() => open(badge), delay);
}
+/* Cancelled when the pointer arrives on the popover. */
+function scheduleClose() {
+ clearTimeout(openTimer);
+ openTimer = 0;
+ clearTimeout(closeTimer);
+ closeTimer = window.setTimeout(closeBadgePopover, HOVER_OUT_MS);
+}
+
+function cancelClose() {
+ clearTimeout(closeTimer);
+ closeTimer = 0;
+}
+
let globalsWired = false;
function wireGlobals() {
if (globalsWired) return;
@@ -138,7 +166,7 @@ export function wireBadgePopover(badge, rows) {
});
badge.addEventListener('pointerleave', e => {
if (/** @type {PointerEvent} */ (e).pointerType === 'touch') return;
- closeBadgePopover();
+ scheduleClose();
});
/* Keep the tap. iOS answers a long press on a link with its own callout and
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index d32d8bc..51ea896 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -10,25 +10,27 @@ import {
uniqueTitle,
} from '/js/widget-types.js?v=6a5e1619';
import {
+ el,
mk,
mkWrap as _mkWrap,
mountScaledWidget,
- teardownWidgets,
- sanitizeCssUrl,
- el,
q,
- qi,
qa,
+ qi,
+ sanitizeCssUrl,
setUserText,
-} from '/js/utils.js?v=b18c93ed';
-import { initSpotlight } from '/js/spotlight.js?v=712fa7de';
+ teardownWidgets,
+ titleWhenTruncated,
+} from '/js/utils.js?v=26566e09';
+import { initSpotlight } from '/js/spotlight.js?v=4352b315';
import { html, setHtml, raw } from '/js/html.js?v=c71f8903';
import { initI18n, t, currentLang } from '/js/i18n.js?v=d056c9c5';
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=14202a72';
-import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=be6330d6';
-import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=d00c0629';
+import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=d3561e4b';
+import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=c6430afc';
+import { formatNumber } from '/js/format-number.js?v=e2165e12';
+import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=08aae50f';
import {
configChanged,
landingAfterSetup,
@@ -40,14 +42,7 @@ 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 { startWakeLock } from '/js/wake-lock.js?v=6b9591cf';
-import {
- applyLabelTones,
- loadSamplingImage,
- parseCssColor,
- relativeLuminance,
- sampleImage,
- toneForLuminances,
-} from '/js/label-contrast.js?v=69cbdcb0';
+import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=38adb276';
/* Recomputed, never stored: the window can cross the breakpoint after load. */
let MOB = isMobileLayout();
@@ -101,6 +96,14 @@ function desktopSlots() {
const CB = { spotOpen: null, spotClose: null, mobPillBump: null };
+/* A touch produces a compatibility mouse event after it. */
+const COMPAT_POINTER_MS = 500;
+let _lastTouch = 0;
+
+/* A backend that accepts the connection and never answers would otherwise leave
+ 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';
@@ -178,6 +181,7 @@ function bupd(id) {
healthStale,
healthDetail: s.healthDetail,
translate: t,
+ format: formatNumber,
});
const sig = badgeSignature({ cls, txt, unit, bg, aria, color, title, nextColor, rows });
@@ -197,13 +201,17 @@ function bupd(id) {
}
txtEl.textContent = num;
unitEl.textContent = unit ? ' ' + unit : '';
- if (aria) {
- el.setAttribute('role', 'status');
- el.setAttribute('aria-label', aria);
- } else {
- el.removeAttribute('role');
- el.removeAttribute('aria-label');
- }
+ /* The badge belongs to its tile's name, not to a live region of its own.
+ An explicit label on the anchor wins over everything inside it, so a
+ badge left to speak for itself is never reached by a reader moving from
+ tile to tile. A name change is silent, which is what a figure on a timer
+ needs. */
+ el.setAttribute('aria-hidden', 'true');
+ el.removeAttribute('role');
+ el.removeAttribute('aria-label');
+ const tile = /** @type {HTMLElement|null} */ (el.closest('a, [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
pill behind is painted from this same value. */
if (bg) el.style.setProperty('--badge-bg', bg);
@@ -295,6 +303,7 @@ function mkIcon(item) {
: mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' });
a.className = 'icon';
a.setAttribute('aria-label', item.label || item.id);
+ a.dataset.tileName = item.label || item.id;
if (!showLabel) a.title = item.label || item.id;
a.appendChild(mkWrap(item, iw, Math.round(iw * ICON_R), isz, 'iwrap'));
if (showLabel) {
@@ -344,6 +353,7 @@ function mkDock(item) {
: mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' });
a.className = 'di';
a.setAttribute('aria-label', item.label || item.id);
+ a.dataset.tileName = item.label || item.id;
a.title = item.label || item.id;
a.appendChild(mkWrap(item, 78, Math.round(78 * ICON_R), 50, 'dwrap'));
return a;
@@ -393,8 +403,6 @@ function buildDesktop() {
const dk = el('dock');
dk.innerHTML = '';
dock.forEach(item => dk.appendChild(mkDock(item)));
- const ct = el('ctrls');
- ct.innerHTML = '';
}
/* Every page is mounted at once, so widgets the user has swiped away from keep
@@ -509,13 +517,12 @@ const WALLPAPER_BACKDROP = '#0d1117';
let bgTone = { grid: null, tone: null };
function retone() {
- requestAnimationFrame(() => applyLabelTones(bgTone));
-}
-
-function toneForColor(color) {
- const rgb = parseCssColor(color);
- if (!rgb) return null;
- return toneForLuminances([relativeLuminance(rgb[0], rgb[1], rgb[2])]);
+ requestAnimationFrame(() => {
+ applyLabelTones(bgTone);
+ /* Same frame: both need the layout the build produced, and both re-run on a
+ rebuild and a resize for the same reason. */
+ titleWhenTruncated();
+ });
}
/* Held so a resize re-samples without fetching the image again. */
@@ -744,7 +751,10 @@ function showSetupPrompt() {
async function boot() {
let authData = null;
try {
- const authCheck = await fetch('/api/auth/check', { cache: 'no-store' });
+ const authCheck = await fetch('/api/auth/check', {
+ cache: 'no-store',
+ signal: AbortSignal.timeout(BOOT_TIMEOUT_MS),
+ });
if (authCheck.status === 401) {
window.location.href = '/admin/';
return;
@@ -760,7 +770,7 @@ async function boot() {
let configFailed = false;
try {
- const res = await fetch('/api/config', { cache: 'no-store' });
+ const res = await fetch('/api/config', { cache: 'no-store', signal: AbortSignal.timeout(BOOT_TIMEOUT_MS) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const c = await res.json();
/* Saving rejects these, but a config written earlier still reaches here.
@@ -777,6 +787,10 @@ async function boot() {
await loadLocalIcons();
if (configFailed) {
+ /* The catalog is loaded as the last step of the fetch that just failed, so
+ without this the only screen left renders its own keys. nginx serves this
+ file, the API served the config, so it is still reachable. */
+ await initI18n('en');
const msg = document.createElement('div');
msg.className = 'api-error-screen';
setHtml(
@@ -846,9 +860,13 @@ async function boot() {
/* Attached once, not per layout: the window can cross the breakpoint later,
and a listener added on every crossing would fire twice. */
+ /* A pointer or a key must reach the pager on both layouts. The phone layout is
+ what a narrow window gets, and there its own swipe is the only pager, so a
+ mouse and a keyboard cannot leave page one. */
document.addEventListener('keydown', e => {
- if (MOB) return;
if (el('spot').classList.contains('on')) return;
+ /* The overlay covers the page it would scroll behind. */
+ if (document.querySelector('.folder-overlay')) return;
if (e.key === 'ArrowRight') goTo(pg + 1);
if (e.key === 'ArrowLeft') goTo(pg - 1);
});
@@ -862,16 +880,20 @@ async function boot() {
if (Math.abs(e.clientX - _dMx) > 8) _dDragging = true;
});
document.addEventListener('mouseup', e => {
- if (MOB || !_dDragging) return;
+ if (!_dDragging) return;
+ _dDragging = false;
+ /* A swipe is followed by a compatibility mouse event. Acting on both pages
+ twice for one gesture. */
+ if (Date.now() - _lastTouch < COMPAT_POINTER_MS) return;
const dx = e.clientX - _dMx;
if (Math.abs(dx) > 60) goTo(pg + (dx < 0 ? 1 : -1));
- _dDragging = false;
});
let _dTx = 0;
document.addEventListener(
'touchstart',
e => {
_dTx = e.touches[0].clientX;
+ _lastTouch = Date.now();
},
{ passive: true },
);
@@ -880,6 +902,7 @@ async function boot() {
document.addEventListener(
'touchend',
e => {
+ _lastTouch = Date.now();
if (MOB) return;
const dx = e.changedTouches[0].clientX - _dTx;
if (Math.abs(dx) > 50) goTo(pg + (dx < 0 ? 1 : -1));
diff --git a/public/js/format-number.js b/public/js/format-number.js
new file mode 100644
index 0000000..e4ebd0d
--- /dev/null
+++ b/public/js/format-number.js
@@ -0,0 +1,55 @@
+// @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. */
+
+/** @type {Intl.NumberFormat|null} */
+let cached = null;
+let cachedFor = '';
+
+/** The reader's locale, or 'en' where the runtime will not say. */
+function locale() {
+ try {
+ return navigator.language || 'en';
+ } catch {
+ return 'en';
+ }
+}
+
+/** @param {number} value @param {Intl.NumberFormatOptions} [options]
+ @returns {string} */
+export function formatNumber(value, options) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return String(value);
+ const loc = locale();
+ if (options) {
+ try {
+ return new Intl.NumberFormat(loc, options).format(value);
+ } catch {
+ return String(value);
+ }
+ }
+ /* One formatter for the common case: this runs per badge on every poll. */
+ if (!cached || cachedFor !== loc) {
+ try {
+ cached = new Intl.NumberFormat(loc);
+ cachedFor = loc;
+ } catch {
+ return String(value);
+ }
+ }
+ return cached.format(value);
+}
+
+/** 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)));
+}
diff --git a/public/js/label-contrast.js b/public/js/label-contrast.js
index da104ed..14eee75 100644
--- a/public/js/label-contrast.js
+++ b/public/js/label-contrast.js
@@ -166,6 +166,19 @@ export function parseCssColor(value) {
return a;
}
+/** The ink for a solid fill, from the fill's own luminance.
+
+ A fill the user chose cannot have an ink the project chose. Alpha on the ink
+ breaks the guarantee: at .85 one palette entry clears neither tone.
+
+ @param {string} color any CSS colour the parser accepts
+ @returns {'light'|'dark'|null} null when the colour cannot be parsed */
+export function toneForColor(color) {
+ const rgb = parseCssColor(color);
+ if (!rgb) return null;
+ return toneForLuminances([relativeLuminance(rgb[0], rgb[1], rgb[2])]);
+}
+
/** An image as a luminance grid.
@param {HTMLImageElement} img a loaded image
@@ -209,7 +222,9 @@ export function loadSamplingImage(url) {
});
}
-const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label';
+/* The page indicator is chrome over the same wallpaper, and it is tinted rather
+ than inked, so it needs the same reading. */
+const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label, #dots';
/* A folder overlay lays its own scrim over the background. */
const SCRIM = '.folder-overlay, .folder-overlay-mobile';
diff --git a/public/js/mobile-metrics.js b/public/js/mobile-metrics.js
index 259ec99..0f0d5ee 100644
--- a/public/js/mobile-metrics.js
+++ b/public/js/mobile-metrics.js
@@ -8,8 +8,38 @@
const BASE_VW = 393;
+/* The grid answers a wider window with more cells, not with bigger ones, so the
+ chrome around it must stop growing too. Above this the dock, the pill and the
+ margins would be drawn half as large again as anything they sit beside. The
+ widest phone reports 430px, which is under the cap, so no phone is affected. */
+const MAX_SC = 1.2;
+
+/* Cell size at the design viewport, gap included. The grid keeps a cell near
+ this and changes how many of them there are. */
+export const BASE_CELL_W = 91.5;
+export const BASE_CELL_H = 116.8;
+
+/* Every footprint is declared in cells, and the tallest is 6 rows deep. Fewer
+ rows than that and it can never be placed. */
+const MIN_ROWS = 6;
+const MIN_COLS = 4;
+
+/* How many cells fit the measured box, keeping a cell near its design size.
+
+ This is the grid-template equivalent of repeat(auto-fill, minmax()): the cell
+ stays put and the count follows the box. Scaling a fixed count instead is what
+ stretched four columns across a tablet and squeezed six into a phone.
+
+ @param {{ gridW: number, gridH: number, sc: number }} o
+ @returns {{ cols: number, rows: number }} */
+export function gridCellCount({ gridW, gridH, sc }) {
+ const cols = Math.max(MIN_COLS, Math.round(gridW / (BASE_CELL_W * sc)));
+ const rows = Math.max(MIN_ROWS, Math.round(gridH / (BASE_CELL_H * sc)));
+ return { cols, rows };
+}
+
export function mobileMetrics(vw) {
- const sc = vw / BASE_VW;
+ const sc = Math.min(vw / BASE_VW, MAX_SC);
const sm = Math.round(18 * sc);
const dh = Math.round(108 * sc);
const pillH = Math.round(34 * sc);
@@ -19,6 +49,28 @@ export function mobileMetrics(vw) {
return { sc, sm, dh, pillH, pillGap, dz };
}
+/* One column width for the whole home grid.
+
+ A card sized by width alone keeps its full width when its rows are too short,
+ loses its aspect, and the scaled iframe is then drawn taller than the card and
+ clipped. Narrowing every column by the same amount keeps a card's width
+ proportional to its column span, so two small widgets still measure the same
+ as one medium.
+
+ @param {{ gridW: number, rowH: number, gap: number, cols: number,
+ footprints: { design: [number, number], span: [number, number] }[] }} o
+ @returns {number} */
+export function gridColumnWidth({ gridW, rowH, gap, cols, footprints }) {
+ const full = (gridW - gap * (cols - 1)) / cols;
+ let k = full;
+ for (const { design, span } of footprints || []) {
+ const [sc, sr] = span;
+ const cellH = rowH * sr + gap * (sr - 1);
+ k = Math.min(k, ((cellH * design[0]) / design[1] - gap * (sc - 1)) / sc);
+ }
+ return Math.max(0, Math.floor(k));
+}
+
/* Both are distances above the bottom safe-area inset, which CSS adds. */
export function pillBottom(m) {
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index be57545..02e369f 100644
--- a/public/js/spotlight.js
+++ b/public/js/spotlight.js
@@ -1,4 +1,4 @@
-import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=b18c93ed';
+import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=26566e09';
import { wrapTab } from '/js/dialog.js?v=05935547';
import { t } from '/js/i18n.js?v=d056c9c5';
@@ -47,7 +47,14 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
f.appendChild(hdr);
cur.forEach((app, i) => {
const isFolder = app.type === 'folder';
- const a = mk('a', { href: app.href || '#', target: isFolder ? '_self' : '_blank', rel: 'noreferrer noopener' });
+ /* Settings is this dashboard, so it opens here and carries no
+ leaves-the-site arrow. Its tile does the same. */
+ const isInternal = isFolder || app.system === 'settings';
+ const a = mk('a', {
+ href: app.href || '#',
+ target: isInternal ? '_self' : '_blank',
+ rel: 'noreferrer noopener',
+ });
a.className = 'sr' + (i === 0 ? ' sel' : '');
a.id = 'sr-opt-' + i;
a.setAttribute('role', 'option');
@@ -67,6 +74,8 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
innerWidth / 393,
);
else openFolderDesktop(app);
+ } else if (app.system === 'settings' && app.href) {
+ window.location.href = app.href;
} else if (app.href) {
window.open(app.href, '_blank', 'noreferrer,noopener');
}
@@ -111,12 +120,15 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
const ht = mk('div');
ht.className = 'srh';
ht.textContent = (app.href || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
- const ar = mk('span');
- ar.className = 'sra';
- ar.textContent = '↗';
- ar.setAttribute('aria-hidden', 'true');
tx.append(nm, ht);
- a.append(ic, tx, ar);
+ a.append(ic, tx);
+ if (!isInternal) {
+ const ar = mk('span');
+ ar.className = 'sra';
+ ar.textContent = '↗';
+ ar.setAttribute('aria-hidden', 'true');
+ a.append(ar);
+ }
f.appendChild(a);
});
res.appendChild(f);
diff --git a/public/js/theme.js b/public/js/theme.js
index 01fbcd5..6d72aef 100644
--- a/public/js/theme.js
+++ b/public/js/theme.js
@@ -31,7 +31,10 @@ export function resolveTheme(mode, prefersDark) {
return m;
}
-/** The chrome colour for a theme: the surface the page draws behind its panel. */
+/** The chrome colour for a theme: the surface the page draws behind its panel.
+
+ A literal, not a token read back: this runs in a classic script before the
+ first paint, when no stylesheet has been parsed. It tracks --bg-base. */
export function themeColor(theme) {
return theme === 'light' ? '#FFFFFF' : '#0d1117';
}
diff --git a/public/js/ui.js b/public/js/ui.js
index f541456..494c5de 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -10,10 +10,11 @@ import {
q,
qa,
setUserText,
-} from '/js/utils.js?v=b18c93ed';
+} from '/js/utils.js?v=26566e09';
import { t, currentLang } from '/js/i18n.js?v=d056c9c5';
import { trapFocus } from '/js/dialog.js?v=05935547';
-import { mobileMetrics } from '/js/mobile-metrics.js?v=e48d02a8';
+import { toneForColor } from '/js/label-contrast.js?v=38adb276';
+import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=424d5d41';
let _state = null;
export function initUI(state) {
@@ -61,8 +62,10 @@ function css(el, props) {
function mkMiniIcon(child, pointerEvents) {
const bg = mk('div');
bg.className = 'folder-mini-bg';
- bg.style.background = clr(child.color);
+ const plate = clr(child.color);
+ bg.style.background = plate;
if (pointerEvents === 'none') bg.style.pointerEvents = 'none';
+ const onLight = toneForColor(plate) === 'dark';
if (child.iconUrl) {
const srcs = iconChain(child.iconUrl);
if (srcs.length) {
@@ -79,14 +82,14 @@ function mkMiniIcon(child, pointerEvents) {
bg.appendChild(img);
} else {
const s = mk('span');
- s.className = 'folder-mini-fb';
+ s.className = onLight ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb';
if (pointerEvents === 'none') s.style.pointerEvents = 'none';
s.textContent = (child.label || '?')[0].toUpperCase();
bg.appendChild(s);
}
} else {
const s = mk('span');
- s.className = 'folder-mini-fb';
+ s.className = onLight ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb';
if (pointerEvents === 'none') s.style.pointerEvents = 'none';
s.textContent = (child.label || '?')[0].toUpperCase();
bg.appendChild(s);
@@ -103,6 +106,7 @@ export function mkFolder(item) {
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';
if (!showLabel) a.title = item.label || t('type.folder');
a.onclick = e => {
e.preventDefault();
@@ -231,6 +235,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
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';
css(a, { '--rh': rh + 'px' });
let _opening = false;
function _openFolder() {
@@ -539,6 +544,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
releaseMobTrap = trapFocus(ov, { onClose: closeMob, initialFocus: ov });
}
+/* Cells per widget size on the 4x6 home grid. */
+const MOB_FOOTPRINT = { small: [2, 2], medium: [4, 2], large: [4, 4], xlarge: [4, 6] };
+
export function buildMobile() {
/* The previous widgets' observers and timers outlive their DOM. Stop them
before it is replaced. */
@@ -547,8 +555,6 @@ export function buildMobile() {
const vw = innerWidth,
vh = innerHeight;
const { sc, sm, dh, pillH, pillGap, dz } = mobileMetrics(vw);
- const COLS = 4,
- ROWS = 6;
const gap = Math.round(sm * 0.5);
css(document.body, {
'--sc': String(sc),
@@ -558,9 +564,10 @@ export function buildMobile() {
'--gap': gap + 'px',
'--pgh': vh + 'px',
});
- /* ── Mobile layout: single-pass 4×6 grid bin-packing ──
+ /* ── Mobile layout: single-pass grid bin-packing ──
Footprints in cells: icon/folder 1×1, small 2×2, medium 4×2, large 4×4,
- xlarge 4×6. */
+ 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 = '';
@@ -579,9 +586,33 @@ export function buildMobile() {
const firstPage = mkPage();
strip.appendChild(firstPage.page);
const gridBox = firstPage.grid.getBoundingClientRect();
- const cw = gridBox.width / COLS,
- rh = gridBox.height / ROWS;
+ const { cols: COLS, rows: ROWS } = gridCellCount({ gridW: gridBox.width, gridH: gridBox.height, sc });
+ css(document.body, { '--mcols': String(COLS), '--mrows': String(ROWS) });
+ const rh = gridBox.height / ROWS;
const rh2 = (gridBox.height - gap * (ROWS - 1)) / ROWS; /* exact row height incl. gaps */
+ /* One column width for the whole grid, narrow enough that every widget shape
+ present fits its rows. Fitting each card to its own cell instead would make
+ a card's width stop matching its column span, and two small widgets would
+ no longer measure the same as one medium. */
+ const cw2 = gridColumnWidth({
+ gridW: gridBox.width,
+ rowH: rh2,
+ gap,
+ cols: COLS,
+ footprints: [
+ ...new Set(
+ items()
+ .filter(i => i.type === 'widget' && !i.hidden)
+ .map(i => i.widgetSize || 'medium'),
+ ),
+ ].map(sz => ({
+ design: WIDGET_DESIGN[sz] || WIDGET_DESIGN.medium,
+ span: MOB_FOOTPRINT[sz] || MOB_FOOTPRINT.medium,
+ })),
+ });
+ const gridW = cw2 * COLS + gap * (COLS - 1);
+ const cw = gridW / COLS;
+ css(document.body, { '--mgw': Math.round(gridW) + 'px' });
const maxIsz = Math.round(74 * sc);
const isz = Math.round(Math.min(cw * 0.9, rh * 0.8, maxIsz));
const ir = Math.round(isz * 0.225),
@@ -594,16 +625,7 @@ export function buildMobile() {
is 0.424 of the icon it sits beside. */
const wBR = Math.round(isz * 0.424);
- const fp = it =>
- it.type !== 'widget'
- ? [1, 1]
- : it.widgetSize === 'xlarge'
- ? [4, 6]
- : it.widgetSize === 'large'
- ? [4, 4]
- : it.widgetSize === 'small'
- ? [2, 2]
- : [4, 2];
+ const fp = it => (it.type !== 'widget' ? [1, 1] : MOB_FOOTPRINT[it.widgetSize] || MOB_FOOTPRINT.medium);
const inFolder = new Set(
items()
@@ -667,6 +689,7 @@ export function buildMobile() {
: mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' });
a.className = 'dyn-mob-icon';
a.setAttribute('aria-label', item.label || item.id);
+ a.dataset.tileName = item.label || item.id;
css(a, { '--cw': '100%', '--rh': rh2 + 'px' });
a.appendChild(mkWrap(item, eff, er, em, ''));
if (showLabel) {
@@ -733,18 +756,27 @@ export function buildMobile() {
const dk = el('dock');
dk.className = 'mdock';
- const dockW = vw - Math.round(18 * sc);
- const dockIconSz = Math.round(Math.min(isz, ((dockW - Math.round(28 * sc)) / 4) * 0.85));
+ const maxDockW = vw - Math.round(18 * sc);
+ const dockPad = Math.round(14 * sc);
+ const dockIconSz = Math.round(Math.min(isz, ((maxDockW - Math.round(28 * sc)) / 4) * 0.85));
const dockIr = Math.round(dockIconSz * 0.225),
dockIm = Math.round(dockIconSz * 0.64);
const dockGap = Math.round(9 * sc);
- 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 ${Math.round(14 * sc)}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`;
+ /* Sized to what it holds, not to the window. The grid gains columns on a wide
+ screen while the dock keeps four icons, and a stretched bar reads as empty
+ rather than as a dock. */
+ const dockContentW = dock.length
+ ? 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 = '';
dock.forEach(item => {
const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' });
a.className = 'dyn-dock-icon';
const nm = item.label || item.id;
a.setAttribute('aria-label', nm);
+ a.dataset.tileName = nm;
a.title = nm; /* dock icons never show a label */
a.appendChild(mkWrap(item, dockIconSz, dockIr, dockIm, ''));
dk.appendChild(a);
diff --git a/public/js/utils.js b/public/js/utils.js
index 50d3c5d..b33c4f8 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -1,4 +1,5 @@
import { iconChain } from '/js/icons.js?v=69c2b9bd';
+import { toneForColor } from '/js/label-contrast.js?v=38adb276';
export const mk = (t, a = {}) => {
const e = document.createElement(t);
@@ -16,26 +17,50 @@ export const clr = c => {
const v = String(c).trim();
return SAFE_COLOR.test(v) ? v : DEFAULT_TILE_COLOR;
};
-export const fb = (l, sz) => {
+/* The plate is a colour the user chose, so the ink has to be measured from it.
+ White on the palette's own yellow reads at 1.5:1. */
+export const fb = (l, sz, plate) => {
const e = mk('span');
e.className = 'fb';
+ if (toneForColor(plate) === 'dark') e.classList.add('fb-on-light');
e.style.fontSize = Math.round(sz * 0.32) + 'px';
e.textContent = (l || '?')[0].toUpperCase();
return e;
};
export { esc } from '/js/html.js?v=c71f8903';
-/* A name the user typed has its own direction. Without dir="auto" an English
- name inside a Persian dashboard lays out right-to-left and an over-long one
- loses its head rather than its tail.
+/* A name the user typed has its own direction. Without that an English name
+ inside a Persian dashboard lays out right-to-left and an over-long one loses
+ its head rather than its tail.
+
+ The isolation goes on a around the text, not on the block. `dir` sets
+ alignment as well as bidi, so a Latin name on the block dragged its whole row
+ to the other edge: a title and its subtitle ended up on opposite sides.
@param {HTMLElement} node @param {string} text @returns {HTMLElement} */
export const setUserText = (node, text) => {
- node.textContent = text;
- node.setAttribute('dir', 'auto');
+ node.textContent = '';
+ const bdi = mk('bdi');
+ bdi.textContent = text;
+ node.appendChild(bdi);
return node;
};
+/* A tile label is one line and ellipsises. The full name is on the anchor's
+ accessible name either way, so this is for a pointer: a tooltip only where the
+ text is actually cut, because one on every tile is noise.
+
+ @param {ParentNode} [root] */
+export function titleWhenTruncated(root = document) {
+ for (const label of root.querySelectorAll('.ilabel, .dyn-mob-label, .dyn-fold-label')) {
+ const tile = /** @type {HTMLElement|null} */ (label.closest('a, button'));
+ if (!tile) continue;
+ const text = label.textContent || '';
+ if (label.scrollWidth > label.clientWidth + 1) tile.title = text;
+ else if (tile.title === text) tile.removeAttribute('title');
+ }
+}
+
/* Strip quotes, parens and backslashes. A user URL must not break out of a CSS
url('...') wrapper. */
export const sanitizeCssUrl = u => String(u || '').replace(/['"\\()]/g, '');
@@ -85,7 +110,7 @@ export function mkWrap(item, sz, r, isz, cls, breg) {
const img = mk('img', { src: SETTINGS_ICON, alt: '', draggable: false });
img.setAttribute('aria-hidden', 'true');
img.style.cssText = `width:${si}px;height:${si}px;object-fit:contain;position:relative;z-index:3;`;
- img.onerror = () => img.replaceWith(fb(item.label, sz));
+ img.onerror = () => img.replaceWith(fb(item.label, sz, wrapBg));
w.appendChild(img);
} else if (rawIcon) {
const chain = iconChain(rawIcon);
@@ -97,7 +122,7 @@ export function mkWrap(item, sz, r, isz, cls, breg) {
const tryNext = () => {
step++;
if (step < chain.length) img.src = chain[step];
- else img.replaceWith(fb(item.label, sz));
+ else img.replaceWith(fb(item.label, sz, wrapBg));
};
img.onerror = tryNext;
/* A 403 fires load, not onerror. A blocked image has zero dimensions. */
@@ -105,8 +130,8 @@ export function mkWrap(item, sz, r, isz, cls, breg) {
if (img.naturalWidth === 0) tryNext();
};
w.appendChild(img);
- } else w.appendChild(fb(item.label, sz));
- } else w.appendChild(fb(item.label, sz));
+ } else w.appendChild(fb(item.label, sz, wrapBg));
+ } else w.appendChild(fb(item.label, sz, wrapBg));
if (
breg &&
(item.monitoring?.healthcheck?.enabled ||
@@ -308,7 +333,10 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla
const w = card.clientWidth,
h = card.clientHeight;
if (!w || !h) return;
- const s = Math.max(w / dw, h / dh); /* cover; with matched aspect = exact fill */
+ /* Contain, not cover. With a matched aspect the two are the same and this
+ fills the card exactly. Where the card's aspect drifts from the design's,
+ cover crops the widget instead, and the card clips what it cropped. */
+ const s = Math.min(w / dw, h / dh);
const tx = (w - dw * s) / 2,
ty = (h - dh * s) / 2;
ifr.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`;
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index d5fe9b5..386143d 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -4,8 +4,8 @@
import { t } from '/js/i18n.js?v=d056c9c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { wireChecklist } from '/js/admin-shared.js?v=8f69dad6';
-import { renderColorControl } from '/js/admin-color-control.js?v=9fded679';
+import { wireChecklist } from '/js/admin-shared.js?v=e9afcefb';
+import { renderColorControl } from '/js/admin-color-control.js?v=32111fda';
import {
seedCarried,
applyOptionSet,
@@ -15,7 +15,7 @@ import {
visibleFieldFlags,
} from '/js/admin-logic.js?v=ddfc6f80';
import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
-import { qi } from '/js/utils.js?v=b18c93ed';
+import { qi } from '/js/utils.js?v=26566e09';
const PE =
'';
diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js
new file mode 100644
index 0000000..b672bf6
--- /dev/null
+++ b/public/js/widget-glyphs.js
@@ -0,0 +1,46 @@
+// @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. */
+
+/** @type {Record} */
+const PATHS = {
+ clock: '',
+ weather:
+ '',
+ gauge:
+ '',
+ shield:
+ '',
+ drive:
+ '',
+ archive:
+ '',
+ shelf:
+ '',
+ play: '',
+ network:
+ '',
+ merge:
+ '',
+ panels:
+ '',
+};
+
+/** The glyph names a manifest may declare. */
+export const GLYPH_NAMES = Object.freeze(Object.keys(PATHS));
+
+/** One glyph as SVG markup, or null when the name is not one of ours.
+ @param {unknown} name @returns {string|null} */
+export function widgetGlyph(name) {
+ const d = typeof name === 'string' ? PATHS[name] : undefined;
+ if (!d) return null;
+ return (
+ '`
+ );
+}
diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js
index 54e5ffe..4f8e381 100644
--- a/public/js/widget-toolbox.js
+++ b/public/js/widget-toolbox.js
@@ -234,6 +234,11 @@ export function wt(key, fallback) {
}
/** @param {number} ts @returns {string} */
+/* 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 function sinceLabel(ts) {
if (!ts) return '';
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html
index 36510aa..bcbdea5 100644
--- a/public/widgets/backup/backup.html
+++ b/public/widgets/backup/backup.html
@@ -114,7 +114,7 @@