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')}

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 @@