@@ -252,7 +237,9 @@ export function buildAppForm(body, item) {
const hcEn = el('hc-en');
const showHide = (id, on) => {
const node = el(id);
- if (node) node.hidden = !on;
+ if (!node) return;
+ if (node.classList.contains('reveal')) reveal(node, on);
+ else node.hidden = !on;
};
hcEn?.addEventListener('change', e => {
showHide('hc-sub', tgt(e).checked);
@@ -260,8 +247,10 @@ export function buildAppForm(body, item) {
document.querySelectorAll('input[name="hc-type"]').forEach(r =>
r.addEventListener('change', () => {
const ping = inpById('hc-type-ping')?.checked;
- showHide('hc-con-row', !ping);
- showHide('hc-ping-row', ping);
+ swapContent(el('hc-con-row')?.parentElement, () => {
+ showHide('hc-con-row', !ping);
+ showHide('hc-ping-row', ping);
+ });
}),
);
el('hc-ping-test')?.addEventListener('click', testPing);
@@ -351,19 +340,19 @@ function actValueOptions() {
return out;
}
-function _valueSelect(idx, path) {
+function _valueSelectOptions() {
const opts = actValueOptions();
const groups = [
{ name: t('app.values'), rows: opts.filter(o => !o.computed) },
{ name: t('app.computedFromArray'), rows: opts.filter(o => o.computed) },
].filter(g => g.rows.length);
- const optionOf = o =>
- html`
`;
- const body =
- groups.length > 1
- ? groups.map(g => html`
`)
- : opts.map(optionOf);
- return html`
${t('app.value')}${raw(CHEV_SVG)}
`;
+ const entryOf = o => ({ value: o.path, label: o.value == null ? o.label : `${o.label} \u2014 ${o.value}` });
+ if (groups.length < 2) return opts.map(entryOf);
+ return groups.flatMap(g => [{ group: g.name }, ...g.rows.map(entryOf)]);
+}
+
+function _valueSelect(idx) {
+ return html`
${t('app.value')}
`;
}
function renderActLabels(host) {
@@ -405,7 +394,7 @@ function renderActLabels(host) {
card.className = 'grp';
setHtml(
card,
- html`${_valueSelect(i, path)}
+ html`${_valueSelect(i)}
${_ieRow(`ie-albl-name-${i}`, _optRow(t('app.labelText')), `albl-name-${i}`, l.name, t('app.labelPh'))}
${_ieRow(`ie-albl-unit-${i}`, _optRow(t('app.unit')), `albl-unit-${i}`, l.unit, t('app.unitPh'))}
@@ -421,20 +410,26 @@ function renderActLabels(host) {
swatchColors: BADGE_SWATCHES,
label: t('common.color'),
});
- const sel = inpById(`albl-path-${i}`);
- if (sel) {
- sel.onchange = () => {
- captureActLabels();
- const next = sel.value;
- if (state.spaths.includes(next)) {
- sel.value = state.spaths[i];
- toast(t('app.labelValueTaken'), 'err');
- return;
- }
- repathActLabel(state.spaths[i], next);
- state.spaths[i] = next;
- syncActMode();
- };
+ const pathRow = el(`albl-path-row-${i}`);
+ if (pathRow) {
+ const box = createListbox({
+ id: `albl-path-${i}`,
+ label: t('app.value'),
+ options: _valueSelectOptions(),
+ value: path,
+ onChange: next => {
+ captureActLabels();
+ if (state.spaths.includes(String(next))) {
+ box.setValue(state.spaths[i]);
+ toast(t('app.labelValueTaken'), 'err');
+ return;
+ }
+ repathActLabel(state.spaths[i], String(next));
+ state.spaths[i] = String(next);
+ syncActMode();
+ },
+ });
+ pathRow.appendChild(box.el);
}
});
wireActLabelDrag(host);
diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js
index 8baf8ca..b42ba82 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=0f36d0dc';
+import { ag, ap } from '/js/admin-shared.js?v=ca64cc9c';
import { t } from '/js/i18n.js?v=e644a5c5';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
-import { el, inp as inpById, qa } from '/js/utils.js?v=970a91b0';
+import { el, inp as inpById, qa } from '/js/utils.js?v=ada0c382';
export async function checkAuth(onLogin) {
try {
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index 6606422..de58183 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=0f36d0dc';
+import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=ca64cc9c';
import { t } from '/js/i18n.js?v=e644a5c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { qa, q } from '/js/utils.js?v=970a91b0';
+import { qa, q } from '/js/utils.js?v=ada0c382';
const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'];
export const BADGE_DEFAULT = '#1e6ef4';
@@ -134,15 +134,17 @@ export function renderColorControl(
${swatchColors.map(swatch)}`;
const wrap = document.createElement('div');
const slider = (label, cls, id, max, val, lo, hi) => html`
-
`;
+
`;
setHtml(
wrap,
html`
+
${slider('Hue', 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)}
${slider('Saturation', 'hsb-range', `${idPrefix}-s`, 100, init.s, _ccIco.satLo, _ccIco.satHi)}
${slider('Brightness', 'hsb-range', `${idPrefix}-v`, 100, init.v, _ccIco.brLo, _ccIco.brHi)}
-
${t('appearance.colorCode')}#rrggbb or any CSS color
`,
+
${t('appearance.colorCode')}#rrggbb or any CSS color
+
`,
);
/* The markup carries no style attribute, so the page keeps style-src without
'unsafe-inline'. A swatch paints itself from its own value. */
@@ -157,13 +159,15 @@ export function renderColorControl(
sEl = qLocal(`#${idPrefix}-s`),
vEl = qLocal(`#${idPrefix}-v`);
const codeRv = qLocal(`#${idPrefix}-code-row .rv`);
- const tune = rows.filter(r => r.classList.contains('cc-tune'));
+ const tune = rows.find(r => r.classList.contains('cc-tune'));
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = `${idPrefix}-val`;
container.appendChild(hidden);
let mode = isSem(value) ? value : 'color';
let showTune = false;
+ /* The first paint restores stored state, so it must not animate. */
+ let _painted = false;
/* The value as it was stored. The sliders are integers, so reading a colour
back out of them shifts it, and an untouched control would save a colour
nobody picked. Cleared the moment the user changes anything. */
@@ -200,13 +204,14 @@ export function renderColorControl(
});
const rb = q('.cc-rainbow', container);
if (rb) rb.classList.toggle('on', mode === 'color' && showTune);
- tune.forEach(r => r.classList.toggle('d-none', !showTune));
+ reveal(tune, showTune, !_painted);
if (!codeRv.closest('.editing')) {
codeRv.textContent =
mode === 'color' ? hex : t(mode === 'dark' ? 'appearance.displayDark' : 'appearance.displayLight');
codeRv.classList.remove('is-ph');
}
hidden.value = mode === 'color' ? (pristine ?? hex) : mode;
+ _painted = true;
}
const commit = () => {
pristine = null;
diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js
index 8de314b..05e79cb 100644
--- a/public/js/admin-drag.js
+++ b/public/js/admin-drag.js
@@ -10,9 +10,9 @@
it is near, and which classes say so. */
import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76';
-import { state } from '/js/admin-state.js?v=7d68e98e';
+import { state } from '/js/admin-state.js?v=5a5d655f';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
-import { qa } from '/js/utils.js?v=970a91b0';
+import { qa } from '/js/utils.js?v=ada0c382';
/** @type {(before: unknown) => void} */
let _save = () => {};
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index eff0d49..49029ee 100644
--- a/public/js/admin-list.js
+++ b/public/js/admin-list.js
@@ -11,15 +11,15 @@
The filter and the collapsed folders are in admin-state.js, because the page
reads and writes them too. */
-import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e';
+import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
-import { reorderItems } from '/js/admin-logic.js?v=74cb4272';
-import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=3ffdf185';
-import { paintIcon } from '/js/admin-shared.js?v=0f36d0dc';
-import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=970a91b0';
+import { reorderItems } from '/js/admin-logic.js?v=69e57d35';
+import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=e514303e';
+import { paintIcon } from '/js/admin-shared.js?v=ca64cc9c';
+import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=ada0c382';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { t } from '/js/i18n.js?v=e644a5c5';
-import { sizeLabel } from '/js/admin-widget-form.js?v=0cb044d0';
+import { sizeLabel } from '/js/admin-widget-form.js?v=20f4cf98';
import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947';
/** @type {{ openModal: (idx: number|null) => void,
diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js
index 780fc25..a54df69 100644
--- a/public/js/admin-logic.js
+++ b/public/js/admin-logic.js
@@ -314,3 +314,17 @@ export function failureIsMissingApiPath(url, advice) {
if (advice && advice.sessionExpired) return false;
return isBareHostUrl(url);
}
+
+/** Compares what `read` returns now with what it returned at the last reset.
+
+ @param {() => string} read
+ @returns {{ dirty: () => boolean, reset: () => void }} */
+export function createDirtyTracker(read) {
+ let base = read();
+ return {
+ dirty: () => read() !== base,
+ reset: () => {
+ base = read();
+ },
+ };
+}
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index 4b452c1..92cb8c0 100644
--- a/public/js/admin-settings.js
+++ b/public/js/admin-settings.js
@@ -1,14 +1,90 @@
-import { toast, ag, ap } from '/js/admin-shared.js?v=0f36d0dc';
+import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=ca64cc9c';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { t } from '/js/i18n.js?v=e644a5c5';
-import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=74cb4272';
+import {
+ shouldWritePassword,
+ settingsSaveBlocker,
+ clearsStoredPassword,
+ createDirtyTracker,
+ BLOCK,
+} from '/js/admin-logic.js?v=69e57d35';
import { confirmText } from '/js/modal.js?v=11fa1eff';
-import { el, inp, q, qa, setUserText } from '/js/utils.js?v=970a91b0';
+import { el, inp, setUserText } from '/js/utils.js?v=ada0c382';
/* Mirrors the server's rule: auth cannot be switched on with no password. */
let _passwordSet = false;
let _authEnabled = false;
+/** @type {{ dirty: () => boolean, reset: () => void } | null} */
+let _srvTrack = null;
+/** @type {{ dirty: () => boolean, reset: () => void } | null} */
+let _bgTrack = null;
+
+const _val = (...ids) => {
+ for (const id of ids) {
+ const node = inp(id);
+ if (node) return node.type === 'checkbox' ? String(node.checked) : node.value;
+ }
+ return '';
+};
+const _shown = id => {
+ const node = el(id);
+ return node && !node.classList.contains('is-ph') ? node.textContent : '';
+};
+
+/* Only what the header Save writes. The switches that save on change are left
+ out. */
+const readServerForm = () =>
+ JSON.stringify([
+ _shown('ie-title-v'),
+ _shown('ie-desc-v'),
+ _val('srv-ip'),
+ _val('srv-docker-en'),
+ _val('srv-socket'),
+ _val('srv-hide-healthy'),
+ _val('log-level'),
+ _val('lang-sel'),
+ _val('sec-en'),
+ _val('sec-pw'),
+ ]);
+const readWallpaperForm = () =>
+ JSON.stringify([
+ _val('bg-type'),
+ _val('bg-br'),
+ _val('bg-col-inp', 'bg-col'),
+ _val('bg-url-inp', 'bg-url'),
+ _val('bg-fit'),
+ _val('bg-color-inp', 'bg-color'),
+ _val('bg-apikey-inp', 'bg-apikey'),
+ ]);
+
+/** Disables `buttonId` while its section matches what was last saved. */
+function trackSave(buttonId, read) {
+ const btn = /** @type {HTMLButtonElement|null} */ (el(buttonId));
+ const tr = createDirtyTracker(read);
+ const sync = () => {
+ if (btn) btn.disabled = !tr.dirty();
+ };
+ /* On the document, not the section: a picker's option list is attached to
+ the body, and a choice made there must still enable Save. Deferred: pickers
+ and inline editors update their value after the event. */
+ for (const type of ['input', 'change', 'click', 'keyup', 'focusout'])
+ document.addEventListener(type, () => setTimeout(sync));
+ sync();
+ return {
+ dirty: tr.dirty,
+ reset: () => {
+ tr.reset();
+ sync();
+ },
+ };
+}
+
+/** Whether General or Appearance holds edits their Save has not written. */
+export function settingsDirty() {
+ return !!(_srvTrack?.dirty() || _bgTrack?.dirty());
+}
+
/* Hint codes from the socket proxy probe. The server picks the code from the
address shape; the wording lives here so it is translated. */
const SOCKET_HINTS = Object.freeze({
@@ -18,13 +94,12 @@ const SOCKET_HINTS = Object.freeze({
/* Both controls only mean anything while auth is on. Revoke also needs a stored
password, which is what makes a session possible. */
-function syncSessionRows() {
+function syncSessionRows(now = false) {
const on = !!inp('sec-en')?.checked;
el('sec-logout')?.classList.toggle('d-none', !on);
const canRevoke = on && _passwordSet;
- el('sec-revoke-row')?.classList.toggle('d-none', !canRevoke);
- const revokeTip = el('revoke-tip');
- if (revokeTip) revokeTip.classList.toggle('d-none', !canRevoke);
+ reveal(el('sec-revoke-wrap'), canRevoke, now);
+ reveal(el('revoke-tip-wrap'), canRevoke, now);
}
export function loadSettings(c) {
@@ -49,46 +124,11 @@ export function loadSettings(c) {
if (typeEl) {
typeEl.value = bg.type || 'unsplash';
showBgFields(bg.type || 'unsplash');
- const btn = el('bg-type-btn');
- const labels = {
- unsplash: t('appearance.sourceUnsplash'),
- url: t('appearance.sourceUrl'),
- color: t('appearance.sourceColor'),
- };
- if (btn) {
- const tn = btn.childNodes[0];
- if (tn && tn.nodeType === 3) tn.textContent = labels[typeEl.value] || typeEl.value;
- }
- qa('#bg-type-list li', document).forEach(li =>
- li.setAttribute('aria-selected', String(li.dataset.val === typeEl.value)),
- );
}
const llEl = inp('log-level');
- if (llEl) {
- llEl.value = s.logLevel || 'info';
- const llBtn = el('log-level-btn');
- const llLabels = { debug: t('general.logDebug'), info: t('general.logInfo'), error: t('general.logError') };
- if (llBtn) {
- const tn = llBtn.childNodes[0];
- if (tn && tn.nodeType === 3) tn.textContent = llLabels[llEl.value] || llEl.value;
- }
- qa('#log-level-list li', document).forEach(li =>
- li.setAttribute('aria-selected', String(li.dataset.val === llEl.value)),
- );
- }
+ if (llEl) llEl.value = s.logLevel || 'info';
const langEl = inp('lang-sel');
- if (langEl) {
- langEl.value = s.language || 'en';
- const laBtn = el('lang-btn');
- const laLi = q(`#lang-list li[data-val="${langEl.value}"]`);
- if (laBtn) {
- const tn = laBtn.childNodes[0];
- if (tn && tn.nodeType === 3) tn.textContent = laLi ? laLi.textContent : langEl.value;
- }
- qa('#lang-list li', document).forEach(li =>
- li.setAttribute('aria-selected', String(li.dataset.val === langEl.value)),
- );
- }
+ if (langEl) langEl.value = s.language || 'en';
/* The key itself is never included in /api/config. */
const apiEl = inp('bg-apikey-inp') || inp('bg-apikey');
if (apiEl) {
@@ -164,7 +204,6 @@ export function loadSettings(c) {
_si('bg-col-inp', s.background?.collection || '');
_si('bg-url-inp', s.background?.url || '');
_si('bg-fit', s.background?.fit === 'fit' ? 'fit' : 'fill');
- showBgFit(s.background?.fit === 'fit' ? 'fit' : 'fill');
showWallpaperFile(s.background?.url || '');
_si('bg-color-inp', s.background?.color || '');
_sv('ie-bgurl-v', s.background?.url, 'Image URL');
@@ -175,19 +214,15 @@ export function loadSettings(c) {
const dockerEnEl = inp('srv-docker-en');
const dockerSubEl = el('srv-docker-sub');
const socketEl = inp('srv-socket');
- const hideHealthyRowEl = el('srv-hide-healthy-row');
const hideHealthyEl = inp('srv-hide-healthy');
if (dockerEnEl) {
dockerEnEl.checked = !!s.server?.socketProxyUrl;
- const applyDocker = v => {
+ const applyDocker = (v, now = false) => {
if (dockerSubEl) dockerSubEl.classList.toggle('open', v);
- if (hideHealthyRowEl) hideHealthyRowEl.classList.toggle('d-none', !v);
- const socketRow = el('ie-socket');
- if (socketRow) socketRow.classList.toggle('d-none', !v);
- const socketHint = el('socket-hint');
- if (socketHint) socketHint.classList.toggle('d-none', !v);
+ reveal(el('srv-docker-rows'), v, now);
+ reveal(el('socket-hint-wrap'), v, now);
};
- applyDocker(dockerEnEl.checked);
+ applyDocker(dockerEnEl.checked, true);
dockerEnEl.addEventListener('change', () => applyDocker(dockerEnEl.checked));
}
if (hideHealthyEl) hideHealthyEl.checked = s.server?.hideHealthyBadge !== false;
@@ -220,9 +255,11 @@ export function loadSettings(c) {
secRevoke.disabled = false;
}
});
- secEnEl?.addEventListener('change', syncSessionRows);
+ secEnEl?.addEventListener('change', () => syncSessionRows());
- syncAuthFromServer();
+ _srvTrack = trackSave('srv-save', readServerForm);
+ _bgTrack = trackSave('bg-save', readWallpaperForm);
+ syncAuthFromServer().then(() => _srvTrack?.reset());
}
async function syncAuthFromServer() {
@@ -238,14 +275,12 @@ async function syncAuthFromServer() {
if (secEnEl) {
/* The effective state. Enabled with no password behaves as off. */
secEnEl.checked = !!d.enabled;
- const pwRow = el('ie-pw');
- const pwHint = el('pw-hint-static');
- if (pwRow) pwRow.classList.toggle('d-none', !d.enabled);
- if (pwHint) pwHint.classList.toggle('d-none', !d.enabled);
+ reveal(el('ie-pw-wrap'), !!d.enabled, true);
+ reveal(el('pw-hint-wrap'), !!d.enabled, true);
}
const pwValEl = el('ie-pw-v');
if (pwValEl) pwValEl.textContent = d.passwordSet ? t('common.configured') : t('common.notSet');
- syncSessionRows();
+ syncSessionRows(true);
}
/** The stored wallpaper, named by its file rather than its full path.
@@ -263,21 +298,16 @@ export function showWallpaperFile(url) {
}
}
-/** @param {string} fit @returns {void} */
-export function showBgFit(fit) {
- const btn = el('bg-fit-btn');
- const tn = btn?.childNodes[0];
- if (tn && tn.nodeType === 3) tn.textContent = fit === 'fit' ? t('appearance.fitContain') : t('appearance.fitFill');
- qa('#bg-fit-list li', document).forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === fit)));
-}
-
export function showBgFields(type) {
- ['unsplash', 'url', 'color'].forEach(t => {
- const node = el(`bg-${t}-fields`);
- if (node) node.classList.toggle('d-none', t !== type);
+ const host = el('bg-unsplash-fields')?.parentElement;
+ swapContent(host, () => {
+ ['unsplash', 'url', 'color'].forEach(t => {
+ const node = el(`bg-${t}-fields`);
+ if (node) node.classList.toggle('d-none', t !== type);
+ });
+ const brRow = el('bg-brightness-row');
+ if (brRow) brRow.classList.toggle('d-none', type === 'color');
});
- const brRow = el('bg-brightness-row');
- if (brRow) brRow.classList.toggle('d-none', type === 'color');
}
/** @param {Event} [e] */
async function saveLabels(e) {
@@ -335,6 +365,7 @@ async function saveWallpaper() {
const keyVal = (inp('bg-apikey-inp') || inp('bg-apikey'))?.value?.trim() || '';
if (keyVal) await ap('/api/settings/unsplash-key', { apiKey: keyVal });
}
+ _bgTrack?.reset();
toast(t('toast.saved'));
} catch (e) {
toast(t('toast.saveFailed', { err: e.message }), 'err');
@@ -444,6 +475,7 @@ async function saveServer() {
pwEl.value = '';
}
}
+ _srvTrack?.reset();
toast(socketWarning || t('toast.saved'), socketWarning ? 'err' : 'ok');
if (langChanged) {
location.reload();
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index d158cb8..28d093f 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -1,9 +1,8 @@
/* Stateless helpers shared by the admin modules. Mutable state stays out. */
-import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=74cb4272';
-import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
-import { el, qa, q } from '/js/utils.js?v=970a91b0';
+import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=69e57d35';
+import { el, q } from '/js/utils.js?v=ada0c382';
import { t } from '/js/i18n.js?v=e644a5c5';
-import { iconChain } from '/js/icons.js?v=04e7796e';
+import { iconChain } from '/js/icons.js?v=9c8c550c';
export const API = '';
@@ -124,12 +123,85 @@ export function setTogDisabled(input, disabled, describedById) {
});
}
+/** Opens or closes a `.reveal` wrapper.
+
+ @param {Element|null} node @param {boolean} open
+ @param {boolean} [now] Skip the animation, for state restored from the
+ server rather than chosen by the user.
+ @returns {void} */
+export function reveal(node, open, now = false) {
+ if (!node) return;
+ const box = /** @type {HTMLElement} */ (node);
+ if (box.classList.contains('open') === open) return;
+ /* The clip is only for the animation. Left on it cuts off any menu opened
+ from a revealed row. */
+ box.classList.remove('reveal-done');
+ clearTimeout(Number(box.dataset.revealTimer || 0));
+ if (open) {
+ const settle = () => box.classList.add('reveal-done');
+ if (now) settle();
+ else box.dataset.revealTimer = String(setTimeout(settle, 400));
+ }
+ if (now) {
+ box.classList.add('reveal-now');
+ box.classList.toggle('open', open);
+ /* Read back, or both class changes land in one recalculation and it
+ animates anyway. */
+ void box.offsetHeight;
+ box.classList.remove('reveal-now');
+ return;
+ }
+ box.classList.toggle('open', open);
+}
+
+/** Replaces a container's content and animates its height from the old to the
+ new one.
+
+ @param {Element|null} node The box whose height is animated.
+ @param {() => void} apply Renders the new content, synchronously.
+ @returns {void} */
+export function swapContent(node, apply) {
+ if (!node) {
+ apply();
+ return;
+ }
+ const box = /** @type {HTMLElement} */ (node);
+ const from = box.getBoundingClientRect().height;
+ /* Abandon a running swap, or its cleanup lands mid-way and pins a height. */
+ box.classList.remove('swapping');
+ box.style.height = '';
+ box.style.overflow = '';
+ box.style.transition = '';
+ apply();
+ const to = box.getBoundingClientRect().height;
+ if (!(from > 0) || Math.abs(to - from) < 1) return;
+ box.classList.add('swapping');
+ /* Inline, not a class. A disclosure's own rule is more specific and would
+ replace this transition with its own. */
+ box.style.overflow = 'hidden';
+ box.style.transition = 'height var(--t-reveal) var(--ease-reveal)';
+ box.style.height = `${from}px`;
+ void box.offsetHeight;
+ box.style.height = `${to}px`;
+ let timer = 0;
+ const end = e => {
+ if (e && (e.target !== box || e.propertyName !== 'height')) return;
+ clearTimeout(timer);
+ box.removeEventListener('transitionend', end);
+ box.classList.remove('swapping');
+ box.style.height = '';
+ box.style.overflow = '';
+ box.style.transition = '';
+ };
+ box.addEventListener('transitionend', end);
+ /* transitionend never fires if the transition is dropped, and the height
+ would stay pinned for the session. */
+ timer = setTimeout(end, 600);
+}
+
export const PE_SVG =
'
';
-export const CHEV_SVG =
- '
';
-
/* `root` lets a caller wire a subtree that is not in the document yet. */
/** A function placeholder is resolved on use, not at wiring time: this runs
before the catalog is fetched, so a translated one has to be read later.
@@ -206,111 +278,6 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = ''
/* The listbox interaction WAI-ARIA expects for a `.row-dd` checklist. The
caller owns the markup and what a toggle means. */
-export function wireChecklist(dd, btn, list, onToggle) {
- const opts = () => qa('li[role="option"]', list);
- let active = -1;
-
- const setActive = i => {
- const o = opts();
- if (!o.length || i == null) return;
- active = i;
- o.forEach((li, n) => {
- li.tabIndex = n === active ? 0 : -1;
- li.classList.toggle('kb-active', n === active);
- });
- o[active].focus();
- fluidHoverKb(o[active]);
- };
- /* Scoped to the open state. A listener that outlives the list holds the
- detached subtree it closes over, and the settings form rewires on every
- render. */
- let outside = null;
- const open = () => {
- list.hidden = false;
- btn.setAttribute('aria-expanded', 'true');
- outside = new AbortController();
- document.addEventListener(
- 'click',
- e => {
- if (!dd.contains(e.target)) close();
- },
- { signal: outside.signal },
- );
- const o = opts();
- const first = o.findIndex(li => li.getAttribute('aria-selected') === 'true');
- setActive(first >= 0 ? first : 0);
- };
- const close = ({ focusBtn = false } = {}) => {
- outside?.abort();
- outside = null;
- list.hidden = true;
- btn.setAttribute('aria-expanded', 'false');
- opts().forEach(li => li.classList.remove('kb-active'));
- fluidHoverClear(list);
- if (focusBtn) btn.focus();
- };
- const toggle = li => {
- onToggle(li);
- };
-
- btn.addEventListener('click', e => {
- e.stopPropagation();
- if (list.hidden) open();
- else close();
- });
- btn.addEventListener('keydown', e => {
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
- e.preventDefault();
- if (list.hidden) open();
- }
- });
-
- list.addEventListener('click', e => {
- const li = e.target.closest('li[role="option"]');
- if (li) toggle(li);
- });
- list.addEventListener('keydown', e => {
- const o = opts();
- if (!o.length) return;
- const moved = nextActiveIndex(e.key, active, o.length);
- if (moved != null) {
- e.preventDefault();
- setActive(moved);
- return;
- }
- switch (e.key) {
- case ' ':
- case 'Enter':
- e.preventDefault();
- if (o[active]) toggle(o[active]);
- break;
- case 'Escape':
- e.preventDefault();
- close({ focusBtn: true });
- break;
- case 'Tab':
- close();
- break;
- default:
- break;
- }
- });
-
- opts().forEach(li => {
- li.tabIndex = -1;
- });
- return { close };
-}
-
-/* An icon name resolves to several candidate URLs, and only the browser can
- say which one exists. Walk them on error and fall back to a letter, or a
- name that resolves to nothing leaves a broken image behind.
-
- Shared because it was not: the folder picker carried its own copy with no
- fallback at all, and every app whose first candidate missed showed broken.
-
- @param {HTMLElement} host @param {string} [rawIcon] @param {string} [fallbackText]
- @param {string} [imgCss] @returns {void} */
export function paintIcon(host, rawIcon, fallbackText = '?', imgCss = '') {
const letter = () => {
host.textContent = fallbackText;
diff --git a/public/js/admin-state.js b/public/js/admin-state.js
index 4a21247..4c280b2 100644
--- a/public/js/admin-state.js
+++ b/public/js/admin-state.js
@@ -21,6 +21,7 @@ export const state = {
_evItem: null,
_evIsEdit: false,
+ _folderApps: null,
_wtype: 'custom',
_wsize: 'medium',
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 49ae244..26da3c4 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,10 +1,11 @@
-import { state } from '/js/admin-state.js?v=7d68e98e';
-import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=0f36d0dc';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1b131080';
+import { state } from '/js/admin-state.js?v=5a5d655f';
+import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=ca64cc9c';
+import { createListbox } from '/js/listbox.js?v=3e267705';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=b0c9ade8';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=74cb4272';
+import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=69e57d35';
import { t } from '/js/i18n.js?v=e644a5c5';
-import { q, qi, qa } from '/js/utils.js?v=970a91b0';
+import { q, qi, qa } from '/js/utils.js?v=ada0c382';
const SIZE_ICONS = {
small:
@@ -59,16 +60,13 @@ function _renderWidgetForm(body) {
const typeList = [...Object.values(state._widgetReg).map(w => [w.name, w.label]), ['custom', 'Custom']].sort((a, b) =>
a[1].localeCompare(b[1]),
);
- const typeOpts = typeList.map(
- ([t, label]) => html`
`,
- );
const shell = document.createElement('div');
shell.className = 'grp';
setHtml(
shell,
html`
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
-
${t('widgetCfg.type')}${raw(CHEV_SVG)}
`,
+
${t('widgetCfg.type')}
`,
);
body.appendChild(shell);
initInlineEdit('ie-wname', 'f-wlabel', {
@@ -77,12 +75,18 @@ function _renderWidgetForm(body) {
state._wlabel = v;
},
});
- const typeSel = qi('#f-wtype', shell);
- typeSel.onchange = () => {
- state._wtype = typeSel.value;
- state._wsize = widgetSizes(state._wtype)[0];
- _renderWidgetForm(body);
- };
+ const typeBox = createListbox({
+ id: 'f-wtype',
+ label: t('widgetCfg.type'),
+ options: typeList.map(([name, label]) => ({ value: name, label })),
+ value: state._wtype,
+ onChange: v => {
+ state._wtype = String(v);
+ state._wsize = widgetSizes(state._wtype)[0];
+ swapContent(body, () => _renderWidgetForm(body));
+ },
+ });
+ q('#wtype-row', shell).appendChild(typeBox.el);
const refused = (state._widgetRejected || []).filter(r => r && r.name);
if (refused.length) {
@@ -130,7 +134,7 @@ function _renderWidgetForm(body) {
/* A view switch can change which sizes are offered, and the tiles are
drawn above this form. */
onChange(key) {
- if (_vf && key === _vf) _renderWidgetForm(body);
+ if (_vf && key === _vf) swapContent(body, () => _renderWidgetForm(body));
},
});
state._autoFormType = state._wtype;
@@ -209,17 +213,25 @@ function _renderCustomConfig(body) {
'strict-origin',
'strict-origin-when-cross-origin',
'unsafe-url',
- ].map(v => html`
`);
+ ].map(v => ({ value: v, label: v || 'Default' }));
setHtml(
adv,
html`
-
${t('widgetCfg.referrerPolicy')}${raw(CHEV_SVG)}
+
${t('widgetCfg.referrerPolicy')}
${t('widgetCfg.allowFeaturePolicy')}${o.allow ? o.allow : 'autoplay; fullscreen'}
${t('widgetCfg.refreshInterval')} (ms)${o.refreshInterval ? o.refreshInterval : 'e.g. 2000'}
`,
);
+ const refBox = createListbox({
+ label: t('widgetCfg.referrerPolicy'),
+ options: refOpts,
+ value: o.referrerPolicy || '',
+ onChange: () => sync(),
+ });
+ q('#if-referrer-row', adv).appendChild(refBox.el);
+
const sync = () => {
- state._iframeOpts.referrerPolicy = qi('#if-referrer', adv).value || undefined;
+ state._iframeOpts.referrerPolicy = refBox.getValue() || undefined;
state._iframeOpts.allow = qi('#if-allow', adv).value.trim() || undefined;
state._iframeOpts.allowFullscreen = qi('#if-fs', adv).checked;
const ri = parseInt(qi('#if-refresh', adv).value, 10);
diff --git a/public/js/admin.js b/public/js/admin.js
index 553b5a9..a441952 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,7 +1,7 @@
-import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=92c86f9e';
-import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=ee5255ba';
-import { initList, render, syncFilterUI } from '/js/admin-list.js?v=0c33cd56';
-import { resolveAdminSection } from '/js/admin-logic.js?v=74cb4272';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=06ddf610';
+import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=ae0bd2a5';
+import { initList, render, syncFilterUI } from '/js/admin-list.js?v=937c0555';
+import { resolveAdminSection } from '/js/admin-logic.js?v=69e57d35';
import {
buildAppItem,
claimFolderChildren,
@@ -10,15 +10,16 @@ import {
snapshotItems,
upsertItem,
} from '/js/admin-save-logic.js?v=4f71ef6c';
-import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=23830fd0';
-import { ag, ap, initInlineEdit, paintIcon, setReauthHandler, toast } from '/js/admin-shared.js?v=0f36d0dc';
-import { collapsedFolders, filter, state } from '/js/admin-state.js?v=7d68e98e';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=0cb044d0';
+import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=98013f90';
+import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=ca64cc9c';
+import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=20f4cf98';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0';
+import { createListbox } from '/js/listbox.js?v=3e267705';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=e644a5c5';
-import { loadLocalIcons } from '/js/icons.js?v=04e7796e';
+import { loadLocalIcons } from '/js/icons.js?v=9c8c550c';
import {
clearSkipTls,
convert,
@@ -31,8 +32,8 @@ import {
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d';
import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff';
import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=00c011c9';
-import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=970a91b0';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=d162431d';
+import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=ada0c382';
+import { normalizeColorInput } from '/js/admin-color-control.js?v=cfb3b3e9';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
@@ -57,7 +58,7 @@ async function load() {
await initI18n(c.settings?.language || 'en');
document.title = t('nav.pageTitle');
initVersion();
- syncThemeLabel();
+ syncPickerLabels();
try {
const wr = await ag('/api/widgets');
state._widgetReg = Object.create(null);
@@ -72,7 +73,10 @@ async function load() {
state.items.filter(i => i.type === 'folder').forEach(f => collapsedFolders.add(f.id));
document.body.classList.add('authed');
render();
+ _savedItems = JSON.stringify(state.items);
+ syncDashSave();
loadSettings(c);
+ syncPickerLabels();
applyBg();
}
@@ -121,6 +125,7 @@ async function save() {
const full = await ag('/api/config');
full.items = state.items;
await ap('/api/config', full);
+ _savedItems = JSON.stringify(state.items);
toast(t('toast.saved'));
ok = true;
} catch (e) {
@@ -128,6 +133,7 @@ async function save() {
}
state.saving = false;
render();
+ syncDashSave();
return ok;
}
@@ -149,6 +155,8 @@ async function appendAndSave(newItems) {
full.items = [...current, ...newItems];
await ap('/api/config', full);
state.items = full.items;
+ _savedItems = JSON.stringify(state.items);
+ syncDashSave();
} finally {
state.saving = false;
render();
@@ -279,7 +287,12 @@ function openModal(idx) {
}
const isEdit = idx != null;
- el('ev-title').textContent = t('nav.general');
+ const evTitle = el('ev-title');
+ if (isEdit) setUserText(evTitle, t('common.editNamed', { name: item.label || item.id }));
+ else {
+ evTitle.textContent = t('type.addNew');
+ evTitle.removeAttribute('dir');
+ }
const delBtn = el('ev-delete');
const saveBtn = el('ev-save');
if (delBtn) {
@@ -533,7 +546,7 @@ async function doSave(orig) {
return;
}
/* An app belongs to one folder, or the dashboard renders it twice. */
- const children = qa('#folder-apps-list li[aria-selected="true"]', document).map(li => li.dataset.val);
+ const children = state._folderApps?.getValue() || [];
claimFolderChildren(state.items, orig?.id, children);
item = {
id:
@@ -606,7 +619,7 @@ function initNav() {
function show(requested) {
const id = resolveAdminSection(requested, sections);
if (id === null) return;
- if (id !== requested) console.warn('admin: unknown section', requested, '- showing', id);
+ if (requested && id !== requested) console.warn('admin: unknown section', requested, '- showing', id);
qa('.sec', document).forEach(s => {
s.hidden = s.id !== 'sec-' + id;
});
@@ -731,84 +744,93 @@ async function initVersion() {
function initSecToggle() {
const en = inp('sec-en');
- const pwRow = el('ie-pw');
- const pwHint = el('pw-hint-static');
if (!en) return;
- function apply(on) {
- if (pwRow) pwRow.classList.toggle('d-none', !on);
- if (pwHint) pwHint.classList.toggle('d-none', !on);
+ function apply(on, now = false) {
+ reveal(el('ie-pw-wrap'), on, now);
+ reveal(el('pw-hint-wrap'), on, now);
}
- apply(en.checked);
+ apply(en.checked, true);
en.addEventListener('change', () => apply(en.checked));
}
function initDockerToggle() {
const en = inp('srv-docker-en');
- const hideRow = el('srv-hide-healthy-row');
- const socketRow = el('ie-socket');
if (!en) return;
- function apply(on) {
- if (hideRow) hideRow.classList.toggle('d-none', !on);
- if (socketRow) socketRow.classList.toggle('d-none', !on);
+ function apply(on, now = false) {
+ reveal(el('srv-docker-rows'), on, now);
+ reveal(el('socket-hint-wrap'), on, now);
}
- apply(en.checked);
+ apply(en.checked, true);
en.addEventListener('change', () => apply(en.checked));
}
function initBgType() {
- const btn = el('bg-type-btn');
- const list = el('bg-type-list');
+ const slot = el('bg-type-slot');
const hidden = inp('bg-type');
- if (!btn || !list || !hidden) return;
-
- function setVal(val) {
+ if (!slot || !hidden) return;
+ const apply = val => {
hidden.value = val;
- const labels = { unsplash: 'Unsplash', url: 'Image', color: 'Solid color' };
- /* Update only the text node. The SVG chevron must survive. */
- const textNode = btn.childNodes[0];
- if (textNode && textNode.nodeType === 3) textNode.textContent = labels[val] || val;
- list.querySelectorAll('li').forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === val)));
- list.hidden = true;
showBgFields(val);
const hint = el('bgcol-hint');
if (hint) hint.style.display = val === 'unsplash' ? '' : 'none';
const imgHint = el('bg-url-hint');
if (imgHint) imgHint.style.display = val === 'url' ? '' : 'none';
- }
-
- btn.addEventListener('click', e => {
- e.stopPropagation();
- list.hidden = !list.hidden;
- });
- list.querySelectorAll('li').forEach(li => {
- li.addEventListener('click', () => setVal(li.dataset.val));
+ };
+ const box = createListbox({
+ id: 'bg-type',
+ label: t('appearance.wallpaperSource'),
+ options: [
+ { value: 'unsplash', label: t('appearance.sourceUnsplash') },
+ { value: 'url', label: t('appearance.sourceUrl') },
+ { value: 'color', label: t('appearance.sourceColor') },
+ ],
+ value: hidden.value || 'unsplash',
+ onChange: v => apply(String(v)),
});
- document.addEventListener('click', () => {
- list.hidden = true;
+ slot.appendChild(box.el);
+ relabel(() => {
+ box.setLabel(t('appearance.wallpaperSource'));
+ box.setOptions(
+ [
+ { value: 'unsplash', label: t('appearance.sourceUnsplash') },
+ { value: 'url', label: t('appearance.sourceUrl') },
+ { value: 'color', label: t('appearance.sourceColor') },
+ ],
+ hidden.value,
+ );
});
-
- setVal(hidden.value || 'unsplash');
+ apply(hidden.value || 'unsplash');
}
function initBgFit() {
- const btn = el('bg-fit-btn');
- const list = el('bg-fit-list');
+ const slot = el('bg-fit-slot');
const hidden = inp('bg-fit');
- if (!btn || !list || !hidden) return;
- function setVal(val) {
+ if (!slot || !hidden) return;
+ const apply = val => {
hidden.value = val;
- showBgFit(val);
- list.hidden = true;
- }
- btn.addEventListener('click', e => {
- e.stopPropagation();
- list.hidden = !list.hidden;
+ };
+ const box = createListbox({
+ id: 'bg-fit',
+ label: t('appearance.fit'),
+ options: [
+ { value: 'fill', label: t('appearance.fitFill') },
+ { value: 'fit', label: t('appearance.fitContain') },
+ ],
+ value: hidden.value || 'fill',
+ onChange: v => apply(String(v)),
});
- list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val || 'fill')));
- document.addEventListener('click', () => {
- list.hidden = true;
+ slot.appendChild(box.el);
+ relabel(() => {
+ box.setLabel(t('appearance.fit'));
+ box.setOptions(
+ [
+ { value: 'fill', label: t('appearance.fitFill') },
+ { value: 'fit', label: t('appearance.fitContain') },
+ ],
+ hidden.value,
+ );
});
- setVal(hidden.value || 'fill');
+ apply(hidden.value || 'fill');
}
/** A body that is not JSON is the web server answering on its own.
@@ -887,60 +909,68 @@ async function fetchWallpaperLink(url) {
}
function initLogLevel() {
- const btn = el('log-level-btn');
- const list = el('log-level-list');
+ const slot = el('log-level-slot');
const hidden = inp('log-level');
- if (!btn || !list || !hidden) return;
- const labels = { debug: 'Debug', info: 'Info', error: 'Errors' };
- function setVal(val) {
- hidden.value = val;
- const textNode = btn.childNodes[0];
- if (textNode && textNode.nodeType === 3) textNode.textContent = labels[val] || val;
- list.querySelectorAll('li').forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === val)));
- list.hidden = true;
- }
- btn.addEventListener('click', e => {
- e.stopPropagation();
- list.hidden = !list.hidden;
+ if (!slot || !hidden) return;
+ const box = createListbox({
+ id: 'log-level',
+ label: t('general.loggingLevel'),
+ options: [
+ { value: 'debug', label: t('general.logDebug') },
+ { value: 'info', label: t('general.logInfo') },
+ { value: 'error', label: t('general.logError') },
+ ],
+ value: hidden.value || 'info',
+ onChange: v => {
+ hidden.value = String(v);
+ },
});
- list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val)));
- document.addEventListener('click', () => {
- list.hidden = true;
+ slot.appendChild(box.el);
+ relabel(() => {
+ box.setLabel(t('general.loggingLevel'));
+ box.setOptions(
+ [
+ { value: 'debug', label: t('general.logDebug') },
+ { value: 'info', label: t('general.logInfo') },
+ { value: 'error', label: t('general.logError') },
+ ],
+ hidden.value,
+ );
});
- setVal(hidden.value || 'info');
+ hidden.value = hidden.value || 'info';
}
function initLanguage() {
- const btn = el('lang-btn');
- const list = el('lang-list');
+ const slot = el('lang-slot');
const hidden = inp('lang-sel');
- if (!btn || !list || !hidden) return;
- const names = Object.fromEntries(LANGUAGES.map(l => [l.code, l.name]));
- setHtml(
- list,
- html`${LANGUAGES.map(l => html`
${l.name}`)}`,
- );
- function setVal(val) {
- hidden.value = val;
- const tn = btn.childNodes[0];
- if (tn && tn.nodeType === 3) tn.textContent = names[val] || val;
- list.querySelectorAll('li').forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === val)));
- list.hidden = true;
- }
- btn.addEventListener('click', e => {
- e.stopPropagation();
- list.hidden = !list.hidden;
+ if (!slot || !hidden) return;
+ const box = createListbox({
+ id: 'lang',
+ label: t('common.language'),
+ options: LANGUAGES.map(l => ({ value: l.code, label: l.name })),
+ value: hidden.value || 'en',
+ onChange: v => {
+ hidden.value = String(v);
+ },
});
- list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val)));
- document.addEventListener('click', () => {
- list.hidden = true;
+ slot.appendChild(box.el);
+ relabel(() => {
+ box.setLabel(t('common.language'));
+ box.setValue(hidden.value);
});
- setVal(hidden.value || 'en');
+ hidden.value = hidden.value || 'en';
}
-/* The row label follows the catalog, so it is written once the catalog is
- loaded and again on every language change. */
-let syncThemeLabel = () => {};
+/* The pickers are built before the catalog loads, so each registers how to
+ relabel itself. Without this their options read as raw keys. */
+const relabels = [];
+const relabel = fn => {
+ relabels.push(fn);
+ return fn;
+};
+const syncPickerLabels = () => {
+ relabels.forEach(fn => fn());
+};
const THEME_LABEL_KEYS = {
system: 'appearance.displaySystem',
@@ -949,36 +979,47 @@ const THEME_LABEL_KEYS = {
};
function initTheme() {
- const btn = el('theme-btn');
- const list = el('theme-list');
+ const slot = el('theme-slot');
const hidden = inp('theme-sel');
- if (!btn || !list || !hidden) return;
- syncThemeLabel = () => {
- const tn = btn.childNodes[0];
- if (tn && tn.nodeType === 3) tn.textContent = t(THEME_LABEL_KEYS[hidden.value] || THEME_LABEL_KEYS.system);
- };
- function setVal(val) {
- hidden.value = writeMode(val);
- syncThemeLabel();
- list
- .querySelectorAll('li')
- .forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === hidden.value)));
- list.hidden = true;
- }
- btn.addEventListener('click', e => {
- e.stopPropagation();
- list.hidden = !list.hidden;
+ if (!slot || !hidden) return;
+ const opts = () => [
+ { value: 'system', label: t(THEME_LABEL_KEYS.system) },
+ { value: 'light', label: t(THEME_LABEL_KEYS.light) },
+ { value: 'dark', label: t(THEME_LABEL_KEYS.dark) },
+ ];
+ const box = createListbox({
+ id: 'theme',
+ label: t('appearance.displayMode'),
+ options: opts(),
+ value: readMode(),
+ onChange: v => {
+ hidden.value = writeMode(String(v));
+ box.setValue(hidden.value);
+ },
});
- list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val)));
- document.addEventListener('click', () => {
- list.hidden = true;
+ slot.appendChild(box.el);
+ relabel(() => {
+ box.setLabel(t('appearance.displayMode'));
+ box.setOptions(opts(), hidden.value);
});
+ hidden.value = writeMode(readMode());
+ box.setValue(hidden.value);
watchSystemTheme(() => hidden.value);
- setVal(readMode());
}
-const dashSaveEl = el('dash-save');
+const dashSaveEl = /** @type {HTMLButtonElement|null} */ (el('dash-save'));
if (dashSaveEl) dashSaveEl.onclick = () => save();
+/* The list saves each change as it is made, so its Save lights only while a
+ write is pending or failed. */
+let _savedItems = '';
+function syncDashSave() {
+ if (dashSaveEl) dashSaveEl.disabled = JSON.stringify(state.items) === _savedItems;
+}
+addEventListener('beforeunload', e => {
+ if (!settingsDirty() && JSON.stringify(state.items) === _savedItems) return;
+ e.preventDefault();
+ e.returnValue = '';
+});
/* Fetched, never reached by navigating a link. A link hands the request to the
browser, which saves an error body under the backup's own filename. */
diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js
index 81d1916..6677485 100644
--- a/public/js/dashboard-logic.js
+++ b/public/js/dashboard-logic.js
@@ -22,6 +22,16 @@ function fingerprint(c) {
/** The page index to open on, clamped to the pages that exist.
@param {string|null} stored @param {number} totalPages @returns {number} */
+/* Desktop tiles keep one size. A narrower window gets fewer columns. */
+export const DESKTOP_COL = { width: 152.67, gap: 25, min: 4, max: 6 };
+
+/** @param {number} avail the width the grid may take, in CSS px */
+export function desktopCols(avail) {
+ /* Half a pixel of slack: six 152.67px columns measure 1041.02px. */
+ const fit = Math.floor((avail + DESKTOP_COL.gap + 0.5) / (DESKTOP_COL.width + DESKTOP_COL.gap));
+ return Math.max(DESKTOP_COL.min, Math.min(DESKTOP_COL.max, fit));
+}
+
export function restorePage(stored, totalPages) {
const n = Number(stored);
if (stored == null || stored === '' || !Number.isInteger(n) || n < 0) return 0;
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index d255445..71f93d0 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -1,4 +1,4 @@
-import { loadLocalIcons, iconChain } from '/js/icons.js?v=04e7796e';
+import { loadLocalIcons, iconChain } from '/js/icons.js?v=9c8c550c';
import {
WIDGET_HEIGHTS,
WIDGET_DESIGN,
@@ -24,18 +24,26 @@ import {
setUserText,
teardownWidgets,
titleWhenTruncated,
-} from '/js/utils.js?v=970a91b0';
+} from '/js/utils.js?v=ada0c382';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
-import { initSpotlight } from '/js/spotlight.js?v=2b722c16';
+import { initSpotlight } from '/js/spotlight.js?v=bcf8c942';
import { html, setHtml, raw } from '/js/html.js?v=c71f8903';
import { initI18n, t, currentLang } from '/js/i18n.js?v=e644a5c5';
import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7';
import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f';
-import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=08ec9951';
+import {
+ initUI,
+ mkFolder,
+ openFolderDesktop,
+ openFolderMobile,
+ buildMobile,
+ resetMobileChrome,
+ mkFolderGlyph,
+} from '/js/ui.js?v=cc6f0031';
import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2';
import { formatNumber } from '/js/format-number.js?v=4a5ccef4';
import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3';
-import { configChanged, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=74ffcb7e';
+import { configChanged, desktopCols, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=a8f759ed';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
import { jitter } from '/js/jitter.js?v=4eeef4c9';
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=9de1cb7d';
@@ -57,10 +65,8 @@ const ICON_R = 0.2237;
const WIDGET_R = 28;
const wCost = { d: WIDGET_COST.desktop, m: WIDGET_COST.mobile };
-const DCOLS = 6;
/* The design values the stylesheet's --tile-h and --row-gap ratios were derived
- from. Read gridMetrics() for the live sizes; these are only the fallback and
- the divisor that turns a live tile height back into a scale factor. */
+ from. Read gridMetrics() for the live sizes; these are only the fallback. */
const DESIGN_TILE = 152;
const DESIGN_ROW_GAP = 30;
@@ -76,24 +82,28 @@ function gridMetrics() {
probe.className = 'gm-probe';
const gap = document.createElement('i');
probe.appendChild(gap);
+ const avail = document.createElement('b');
+ probe.appendChild(avail);
document.body.appendChild(probe);
const tile = probe.getBoundingClientRect().height || DESIGN_TILE;
const rowGap = gap.getBoundingClientRect().height || DESIGN_ROW_GAP;
+ const width = avail.getBoundingClientRect().width || innerWidth;
probe.remove();
- return { tile, rowGap, scale: tile / DESIGN_TILE };
+ return { tile, rowGap, cols: desktopCols(width) };
}
-let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, scale: 1 };
+let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, cols: 6 };
/* The reserves mirror the .page padding in dashboard.css. Change one and the
- page breaks stop matching the box.
+ page breaks stop matching the box. The bottom reserve clears the page dots,
+ which sit above the dock.
@param {boolean} hasDock */
function desktopSlots(hasDock) {
const ih = innerHeight;
const top = Math.min(70, Math.max(44, ih * 0.04));
- const bottom = hasDock ? Math.min(160, Math.max(110, ih * 0.1)) : top;
+ const bottom = hasDock ? 204 : 68;
const rows = Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap))));
- return DCOLS * rows;
+ return gm.cols * rows;
}
const CB = { spotOpen: null, spotClose: null, mobPillBump: null };
@@ -298,8 +308,8 @@ function paginate() {
function mkIcon(item) {
if (item.type === 'folder') return mkFolder(item);
const showLabel = S.showLabels?.desktop !== false;
- const iw = Math.round((showLabel ? 72 : 78) * gm.scale),
- isz = Math.round((showLabel ? 50 : 56) * gm.scale);
+ const iw = showLabel ? 72 : 78,
+ isz = showLabel ? 50 : 56;
const a =
item.system === 'settings'
? mk('a', { href: '/admin/' })
@@ -338,8 +348,8 @@ function mkWidget(item) {
const preset = cardPreset(item, widgetReg);
if (preset) card.dataset.card = preset;
const design = WIDGET_DESIGN[sz] || WIDGET_DESIGN.medium;
- card.style.height = Math.round(WH.d[sz] * gm.scale) + 'px';
- card.style.borderRadius = Math.round(WIDGET_R * gm.scale) + 'px';
+ card.style.height = WH.d[sz] + 'px';
+ card.style.borderRadius = WIDGET_R + 'px';
mountScaledWidget(card, {
src: widgetSrc(item, widgetReg, { lang: currentLang() }),
title: widgetTitle(item),
@@ -385,8 +395,10 @@ function buildDesktop() {
closeBadgePopover();
BEL.clear();
usedWidgetTitles = new Set();
+ resetMobileChrome();
/* Before paginate() and before any tile is built: both size against it. */
gm = gridMetrics();
+ document.documentElement.style.setProperty('--cols', String(gm.cols));
const dock = items.filter(i => i.type === 'app' && i.dock && !i.hidden).slice(0, 4);
document.body.classList.toggle('no-dock', !dock.length);
const pages = paginate();
@@ -847,10 +859,21 @@ async function boot() {
_stateRef = state;
initUI(state);
initFluidHover();
- initSpotlight({ getItems: () => items, isMob: () => MOB, CB, iconChain, openFolderDesktop, openFolderMobile });
+ initSpotlight({
+ getItems: () => items,
+ isMob: () => MOB,
+ CB,
+ iconChain,
+ openFolderDesktop,
+ openFolderMobile,
+ folderGlyph: mkFolderGlyph,
+ });
+ /* The width the current layout was built at. */
+ let _mw = innerWidth;
/* Mobile measures the viewport as it builds, so it waits for layout. */
const buildLayout = () => {
+ _mw = innerWidth;
if (MOB) {
document.body.classList.add('is-mob');
requestAnimationFrame(() =>
@@ -973,18 +996,25 @@ async function boot() {
/* The desktop tile size follows the viewport, so a resize can change how many
rows fit. Rebuild only when the slot count actually moves, not on every
pixel: a rebuild tears down and remounts every widget iframe. */
+ const hasDock = () => items.some(i => i.type === 'app' && i.dock && !i.hidden);
let _dz,
- _slots = desktopSlots();
+ _slots = desktopSlots(hasDock());
+ /* The phone layout rebuilds on a width change only. A phone keyboard changes
+ the height, and a rebuild then would close it. */
let _rs;
window.addEventListener('resize', () => {
clearTimeout(_rs);
- _rs = setTimeout(resampleBg, 200);
+ _rs = setTimeout(() => {
+ resampleBg();
+ if (MOB && innerWidth !== _mw) buildLayout();
+ }, 200);
if (MOB) return;
clearTimeout(_dz);
_dz = setTimeout(() => {
if (MOB) return;
gm = gridMetrics();
- const slots = desktopSlots();
+ document.documentElement.style.setProperty('--cols', String(gm.cols));
+ const slots = desktopSlots(hasDock());
if (slots === _slots) return;
_slots = slots;
buildDesktop();
diff --git a/public/js/icons.js b/public/js/icons.js
index 052dec2..cadeb93 100644
--- a/public/js/icons.js
+++ b/public/js/icons.js
@@ -3,15 +3,19 @@
user-uploaded SVG loaded that way cannot execute script. Inlined into the DOM
it can. */
const LOCAL_ICONS = new Set();
+/* The demo blocks outbound requests, so its icon proxy only ever fails. */
+let _proxy = true;
export async function loadLocalIcons() {
try {
const r = await fetch('/api/icons/local', { cache: 'no-store' });
if (r.ok) {
+ const d = await r.json();
/* Mutate the existing Set. Reassigning it leaves every other module
holding a stale reference. */
LOCAL_ICONS.clear();
- ((await r.json()).files || []).forEach(f => LOCAL_ICONS.add(f));
+ (d.files || []).forEach(f => LOCAL_ICONS.add(f));
+ _proxy = d.demo !== true;
}
} catch {}
}
@@ -103,8 +107,8 @@ export function iconChain(rawIcon) {
/* One request, not one per format. Hundreds of catalogue entries are png
only and hundreds have no png, and the server knows which from the
catalogue index it already holds. */
- if (explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`);
- else chain.push(`/api/icons/cdn?name=${cdn}${src}`);
+ if (_proxy && explicitExt) chain.push(`/api/icons/cdn?name=${cdn}&ext=${explicitExt}${src}`);
+ else if (_proxy) chain.push(`/api/icons/cdn?name=${cdn}${src}`);
for (const ext of ['svg', 'png']) {
if (explicitExt && explicitExt !== ext) continue;
const direct = cdnFileUrl(source, cdn, ext);
diff --git a/public/js/listbox.js b/public/js/listbox.js
new file mode 100644
index 0000000..0b425a8
--- /dev/null
+++ b/public/js/listbox.js
@@ -0,0 +1,344 @@
+// @ts-check
+/* The picker for every list in the admin.
+
+ Keep the list on . Inside its row the settings panel clips it.
+
+ Do not use popover. The top layer makes iOS collapse its browser toolbar,
+ which exposes a strip of page canvas and misplaces the menu. */
+
+import { nextActiveIndex } from '/js/admin-logic.js?v=69e57d35';
+import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
+import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
+import { qa } from '/js/utils.js?v=ada0c382';
+
+const CHEV =
+ '
';
+
+const TYPEAHEAD_MS = 800;
+
+let seq = 0;
+
+/* The lists live on while their rows do not, and the admin rebuilds a
+ form by replacing its children. Without this every rebuild strands one list
+ per picker on the page for the rest of the session. */
+const mounted = new Set();
+function pruneDetached() {
+ for (const m of mounted) {
+ /* `placed` is set a microtask after creation, by which time the caller has
+ attached the row. Before that the entry is new, not abandoned. */
+ if (!m.placed || m.dd.isConnected) continue;
+ m.list.remove();
+ mounted.delete(m);
+ }
+}
+
+const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]';
+
+/** The list is last on , so the browser's own Tab would leave the form.
+
+ @param {HTMLElement} from @param {1|-1} step */
+function focusBeside(from, step) {
+ const all = qa(FOCUSABLE, document).filter(
+ n =>
+ n === from ||
+ (n.tabIndex >= 0 &&
+ !(/** @type {any} */ (n).disabled) &&
+ n.getClientRects().length &&
+ !n.closest('.row-dd-list')),
+ );
+ const next = all[all.indexOf(from) + step];
+ (next || from).focus();
+}
+
+/** An entry with `group` and no `value` is a heading. The keyboard skips it.
+
+ @typedef {{ value?: string, label?: string, group?: string }} ListboxOption */
+
+/** Builds a picker and returns it with its own small API.
+
+ @param {{ options?: ListboxOption[], value?: string|string[],
+ multiple?: boolean, label: string, placeholder?: string,
+ summary?: (selected: ListboxOption[]) => string,
+ onChange?: (value: any) => void, emptyText?: string,
+ id?: string }} opts
+ @returns {{ el: HTMLElement, button: HTMLElement, list: HTMLElement,
+ getValue: () => any, setValue: (v: any) => void,
+ setOptions: (o: ListboxOption[], v?: any) => void,
+ setLabel: (l: string) => void,
+ close: () => void }} */
+export function createListbox(
+ {
+ options = [],
+ value,
+ multiple = false,
+ label,
+ placeholder = '',
+ summary,
+ onChange,
+ emptyText = '',
+ id = '',
+ } = /** @type {any} */ ({}),
+) {
+ let opts = options.slice();
+ let chosen = multiple
+ ? new Set((Array.isArray(value) ? value : []).map(String))
+ : new Set(value == null || value === '' ? [] : [String(value)]);
+
+ /* The list is not a descendant of its button, so the two are tied by id. */
+ const listId = id ? `${id}-list` : `lb-${++seq}`;
+ const dd = document.createElement('div');
+ dd.className = 'row-dd';
+ setHtml(
+ dd,
+ html`
+
`,
+ );
+ const btn = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-btn'));
+ const text = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-text'));
+ const list = /** @type {HTMLElement} */ (dd.querySelector('.row-dd-list'));
+ pruneDetached();
+ list.remove();
+ list.hidden = true;
+ document.body.appendChild(list);
+ const entry = { dd, list, placed: false };
+ mounted.add(entry);
+ queueMicrotask(() => {
+ entry.placed = true;
+ });
+
+ const selected = () => opts.filter(o => o.group == null && chosen.has(String(o.value)));
+ const items = () => qa('li[role="option"]', list);
+
+ function paintButton() {
+ const sel = selected();
+ if (summary) text.textContent = summary(sel);
+ else if (!sel.length) text.textContent = placeholder;
+ else text.textContent = sel.map(o => o.label).join(', ');
+ text.classList.toggle('is-ph', !sel.length && !summary);
+ }
+
+ function paintList() {
+ if (!opts.length && emptyText) {
+ setHtml(list, html`
${emptyText}`);
+ return;
+ }
+ setHtml(
+ list,
+ html`${opts.map(o =>
+ o.group != null
+ ? html`
${o.group}`
+ : html`
${o.label}`,
+ )}`,
+ );
+ items().forEach(li => {
+ li.tabIndex = -1;
+ });
+ }
+
+ function paint() {
+ paintList();
+ paintButton();
+ }
+
+ let active = -1;
+ let open_ = false;
+ /* Scoped to the open state. A listener that outlives the list holds the
+ detached row it closes over, and the admin rewires on every render. */
+ let outside = null;
+
+ function place() {
+ const r = btn.getBoundingClientRect();
+ const pr = list.getBoundingClientRect();
+ /* Not the layout viewport. iOS grows the visible area past it. */
+ const vh = visualViewport?.height ?? window.innerHeight;
+ const vw = visualViewport?.width ?? window.innerWidth;
+ const gap = 4;
+ let top = r.bottom + gap;
+ if (top + pr.height > vh - 8) top = Math.max(8, r.top - gap - pr.height);
+ const rtl = (document.documentElement.getAttribute('dir') || 'ltr') === 'rtl';
+ let left = rtl ? r.left : r.right - pr.width;
+ left = Math.min(Math.max(8, left), vw - pr.width - 8);
+ list.style.top = `${Math.round(top)}px`;
+ list.style.left = `${Math.round(left)}px`;
+ }
+
+ const setActive = i => {
+ const o = items();
+ if (!o.length || i == null) return;
+ active = i;
+ o.forEach((li, n) => {
+ li.tabIndex = n === active ? 0 : -1;
+ li.classList.toggle('kb-active', n === active);
+ });
+ o[active].focus();
+ fluidHoverKb(o[active]);
+ };
+
+ const open = () => {
+ if (open_) return;
+ list.hidden = false;
+ open_ = true;
+ btn.setAttribute('aria-expanded', 'true');
+ place();
+ outside = new AbortController();
+ /* The visible area resizes a frame after the menu opens. */
+ visualViewport?.addEventListener('resize', place, { signal: outside.signal });
+ visualViewport?.addEventListener('scroll', place, { signal: outside.signal });
+ /* A scroll event lands a frame late. Close only when the button moved. */
+ const at = btn.getBoundingClientRect();
+ document.addEventListener(
+ 'scroll',
+ e => {
+ if (list.contains(/** @type {Node} */ (e.target))) return;
+ const r = btn.getBoundingClientRect();
+ if (Math.abs(r.top - at.top) > 1 || Math.abs(r.left - at.left) > 1) close();
+ },
+ { capture: true, signal: outside.signal },
+ );
+ document.addEventListener(
+ 'click',
+ e => {
+ const t = /** @type {Node} */ (e.target);
+ if (!dd.contains(t) && !list.contains(t)) close();
+ },
+ { signal: outside.signal },
+ );
+ const o = items();
+ const first = o.findIndex(li => li.getAttribute('aria-selected') === 'true');
+ setActive(first >= 0 ? first : 0);
+ };
+
+ const close = ({ focusBtn = false } = {}) => {
+ outside?.abort();
+ outside = null;
+ list.hidden = true;
+ open_ = false;
+ btn.setAttribute('aria-expanded', 'false');
+ items().forEach(li => li.classList.remove('kb-active'));
+ fluidHoverClear(list);
+ if (focusBtn) btn.focus();
+ };
+
+ function commit(li) {
+ const val = li.dataset.val;
+ if (val == null) return;
+ if (multiple) {
+ if (chosen.has(val)) chosen.delete(val);
+ else chosen.add(val);
+ const keep = active;
+ paint();
+ setActive(keep);
+ } else {
+ chosen = new Set([val]);
+ paint();
+ close({ focusBtn: true });
+ }
+ onChange?.(getValue());
+ }
+
+ let typed = '';
+ let typedAt = 0;
+ function typeahead(key) {
+ const now = Date.now();
+ typed = now - typedAt > TYPEAHEAD_MS ? key : typed + key;
+ typedAt = now;
+ const o = items();
+ const from = typed.length === 1 ? active + 1 : active;
+ for (let n = 0; n < o.length; n++) {
+ const i = (from + n + o.length) % o.length;
+ if ((o[i].textContent || '').trim().toLowerCase().startsWith(typed.toLowerCase())) {
+ setActive(i);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ if (open_) close();
+ else open();
+ });
+ btn.addEventListener('keydown', e => {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
+ if (!open_) {
+ e.preventDefault();
+ open();
+ }
+ }
+ });
+
+ list.addEventListener('click', e => {
+ const li = /** @type {HTMLElement} */ (e.target).closest('li[role="option"]');
+ if (li) commit(/** @type {HTMLElement} */ (li));
+ });
+ list.addEventListener('keydown', e => {
+ const o = items();
+ if (!o.length) return;
+ const moved = nextActiveIndex(e.key, active, o.length);
+ if (moved != null) {
+ e.preventDefault();
+ setActive(moved);
+ return;
+ }
+ switch (e.key) {
+ case ' ':
+ case 'Enter':
+ e.preventDefault();
+ if (o[active]) commit(o[active]);
+ break;
+ case 'Escape':
+ e.preventDefault();
+ close({ focusBtn: true });
+ break;
+ case 'Tab':
+ e.preventDefault();
+ close();
+ focusBeside(btn, e.shiftKey ? -1 : 1);
+ break;
+ default:
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && typeahead(e.key)) e.preventDefault();
+ break;
+ }
+ });
+
+ const getValue = () =>
+ multiple
+ ? opts
+ .filter(o => o.group == null)
+ .map(o => String(o.value))
+ .filter(v => chosen.has(v))
+ : [...chosen][0];
+
+ paint();
+
+ return {
+ el: dd,
+ button: btn,
+ list,
+ getValue,
+ setValue(v) {
+ chosen = multiple
+ ? new Set((Array.isArray(v) ? v : []).map(String))
+ : new Set(v == null || v === '' ? [] : [String(v)]);
+ paint();
+ },
+ setOptions(next, v) {
+ opts = (next || []).slice();
+ if (v !== undefined) {
+ chosen = multiple
+ ? new Set((Array.isArray(v) ? v : []).map(String))
+ : new Set(v == null || v === '' ? [] : [String(v)]);
+ } else if (!multiple) {
+ /* A refreshed list may no longer carry what was chosen. */
+ const still = [...chosen].filter(c => opts.some(o => String(o.value) === c));
+ chosen = new Set(still);
+ }
+ paint();
+ },
+ setLabel(next) {
+ list.setAttribute('aria-label', next);
+ },
+ close: () => close(),
+ };
+}
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index 8121b42..0e72274 100644
--- a/public/js/spotlight.js
+++ b/public/js/spotlight.js
@@ -1,5 +1,5 @@
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
-import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=970a91b0';
+import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=ada0c382';
import { t } from '/js/i18n.js?v=e644a5c5';
/* Attached to the window so a re-open can undo the previous one. */
@@ -7,7 +7,7 @@ const _w = /** @type {any} */ (window);
/* `isMob` is a function, not a flag: the window can cross the breakpoint while
this module is loaded. */
-export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDesktop, openFolderMobile }) {
+export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDesktop, openFolderMobile, folderGlyph }) {
const MOB = () => isMob();
const ov = /** @type {HTMLDialogElement} */ (el('spot'));
const inp = inpById('sin');
@@ -15,7 +15,8 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
const cancelBtn = el('spot-cancel');
const live = el('sres-live');
let si = 0,
- cur = [];
+ cur = [],
+ returnFocus = null;
inp.setAttribute('role', 'combobox');
inp.setAttribute('aria-autocomplete', 'list');
@@ -64,15 +65,10 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
const doOpen = () => {
close();
if (isFolder) {
- if (MOB())
- openFolderMobile(
- app,
- Math.round(60 * (innerWidth / 393)),
- Math.round(14 * (innerWidth / 393)),
- Math.round(38 * (innerWidth / 393)),
- innerWidth / 393,
- );
- else openFolderDesktop(app);
+ if (MOB()) {
+ const s = Math.min(innerWidth, 430) / 393;
+ openFolderMobile(app, Math.round(60 * s), Math.round(14 * s), Math.round(38 * s), s);
+ } else openFolderDesktop(app);
} else if (app.system === 'settings' && app.href) {
window.location.href = app.href;
} else if (app.href) {
@@ -88,7 +84,11 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
const ic = mk('div');
ic.className = 'sri';
ic.style.background = clr(app.color);
- if (app.iconUrl) {
+ if (isFolder && folderGlyph) {
+ ic.style.background = 'transparent';
+ ic.style.position = 'relative';
+ ic.appendChild(folderGlyph(app, 44));
+ } else if (app.iconUrl) {
const img = mk('img', { alt: '', loading: 'lazy' });
img.setAttribute('aria-hidden', 'true');
const srcs = iconChain(app.iconUrl);
@@ -176,6 +176,7 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
function open(ch) {
if (ov.open) return;
+ returnFocus = document.activeElement;
ov.showModal();
ov.classList.add('on');
inp.value = ch || '';
@@ -218,6 +219,13 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
ov.classList.remove('on');
inp.value = '';
res.replaceChildren();
+ const back = returnFocus;
+ returnFocus = null;
+ /* Only while focus is still inside the closed dialog. A folder opened from
+ a result has already taken it. */
+ if (!ov.contains(document.activeElement)) return;
+ if (back instanceof HTMLElement && back !== document.body && back.isConnected) back.focus();
+ else inp.blur();
});
function close() {
diff --git a/public/js/ui.js b/public/js/ui.js
index a1464e5..f9d6ff5 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -1,4 +1,4 @@
-import { iconChain } from '/js/icons.js?v=04e7796e';
+import { iconChain } from '/js/icons.js?v=9c8c550c';
import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=a1b61636';
import {
mk,
@@ -13,7 +13,7 @@ import {
q,
qa,
setUserText,
-} from '/js/utils.js?v=970a91b0';
+} from '/js/utils.js?v=ada0c382';
import { t, currentLang } from '/js/i18n.js?v=e644a5c5';
import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=ab5fe77e';
@@ -101,6 +101,28 @@ function mkMiniIcon(child, pointerEvents) {
return bg;
}
+/** The miniature icon grid a folder tile shows. @param {number} size */
+export function mkFolderGlyph(folder, size) {
+ const wrap = mk('div');
+ wrap.className = 'dyn-fold-wrap';
+ css(wrap, {
+ '--br': Math.round(size * 0.24) + 'px',
+ '--gap': Math.round(size * 0.04) + 'px',
+ '--pad': Math.round(size * 0.1) + 'px',
+ });
+ (folder.children || [])
+ .slice(0, 9)
+ .map(id => items().find(i => i.id === id))
+ .filter(Boolean)
+ .forEach(child => {
+ const cell = mk('div');
+ cell.className = 'dyn-fold-cell';
+ cell.appendChild(mkMiniIcon(child, 'none'));
+ wrap.appendChild(cell);
+ });
+ return wrap;
+}
+
export function mkFolder(item) {
const showLabel = S().showLabels?.desktop !== false;
const iw = showLabel ? 72 : 78;
@@ -283,24 +305,10 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
box.className = 'dyn-sz dyn-box';
css(box, { '--sz': eff + 'px' });
box.style.pointerEvents = 'none';
- const wrap = mk('div');
- const pad = Math.round(eff * 0.1),
- gap = Math.round(eff * 0.04);
- wrap.className = 'dyn-fold-wrap';
- css(wrap, { '--br': Math.round(eff * 0.24) + 'px', '--gap': gap + 'px', '--pad': pad + 'px' });
+ const wrap = mkFolderGlyph(item, eff);
const sheen = mk('div');
sheen.className = 'dyn-fold-sheen';
- wrap.appendChild(sheen);
- (item.children || [])
- .slice(0, 9)
- .map(id => items().find(i => i.id === id))
- .filter(Boolean)
- .forEach(child => {
- const cell = mk('div');
- cell.className = 'dyn-fold-cell';
- cell.appendChild(mkMiniIcon(child, 'none'));
- wrap.appendChild(cell);
- });
+ wrap.prepend(sheen);
box.appendChild(wrap);
const fb_ = mk('div');
fb_.className = 'badge';
@@ -319,6 +327,8 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
}
let folderOverlayMob = null;
+/* The widest phone viewport. Matches the cap in mobile-metrics.js. */
+export const FOLDER_MAX_VW = 430;
export function openFolderMobile(folder, isz, _ir, _im, sc) {
if (folderOverlayMob) {
folderOverlayMob.remove();
@@ -347,9 +357,12 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
folderOverlayMob = null;
});
- const ptScale = vw / 393;
+ /* Phone size on every window. A wider window centres the folder instead of
+ enlarging it. */
+ const ptScale = Math.min(vw, FOLDER_MAX_VW) / 393;
const margin = Math.round(34 * ptScale),
- boxW = vw - margin * 2;
+ boxW = Math.min(vw, FOLDER_MAX_VW) - margin * 2,
+ boxLeft = Math.round((vw - boxW) / 2);
const padH = Math.round(20 * ptScale),
padVT = Math.round(24 * ptScale),
padVB = Math.round(22 * ptScale);
@@ -376,7 +389,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
const titleFs = Math.round(30 * ptScale),
titleGap = Math.round(40 * ptScale);
const titleRendH = Math.ceil(titleFs * 1.05) + Math.round(4 * ptScale);
- const titleLeft = margin + padH + Math.round(6 * ptScale);
+ const titleLeft = boxLeft + padH + Math.round(6 * ptScale);
const titleEl = mk('div');
titleEl.className = 'folder-title-mobile dyn-title-mob';
@@ -391,7 +404,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
const box = mk('div');
box.className = 'folder-box-mobile dyn-box-mob';
css(box, {
- '--left': margin + 'px',
+ '--left': boxLeft + 'px',
'--bw': boxW + 'px',
'--bh': boxH + 'px',
'--top': boxTop + 'px',
@@ -520,6 +533,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
);
ov.appendChild(titleEl);
ov.appendChild(box);
+ ov.onclick = e => {
+ if (e.target === ov) closeMob();
+ };
ov.addEventListener(
'touchend',
e => {
@@ -817,6 +833,12 @@ export function buildMobile() {
pillNew.onclick = () => {
if (CB().spotOpen) CB().spotOpen('');
};
+ pillNew.onkeydown = e => {
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (CB().spotOpen) CB().spotOpen('');
+ };
let _pillIdleTimer = null;
function pillPaging(on) {
@@ -849,3 +871,20 @@ export function buildMobile() {
document.addEventListener('touchstart', st()._mobTsCleanup, { passive: true });
document.addEventListener('touchend', st()._mobTeCleanup, { passive: true });
}
+
+/* The desktop build shares the dock and pill elements with buildMobile, which
+ styles them inline. */
+export function resetMobileChrome() {
+ const dk = el('dock');
+ dk.className = '';
+ dk.style.cssText = '';
+ const pill = el('mob-search-pill');
+ pill.style.cssText = '';
+ pill.classList.remove('paging');
+ CB().mobPillBump = null;
+ const { _mobTsCleanup, _mobTeCleanup } = st();
+ if (_mobTsCleanup) document.removeEventListener('touchstart', _mobTsCleanup);
+ if (_mobTeCleanup) document.removeEventListener('touchend', _mobTeCleanup);
+ st()._mobTsCleanup = null;
+ st()._mobTeCleanup = null;
+}
diff --git a/public/js/utils.js b/public/js/utils.js
index f75d3c0..23b47f9 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -1,4 +1,4 @@
-import { iconChain } from '/js/icons.js?v=04e7796e';
+import { iconChain } from '/js/icons.js?v=9c8c550c';
import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13';
import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac';
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index e36b481..e7b82ff 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -4,8 +4,9 @@
import { t } from '/js/i18n.js?v=e644a5c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { wireChecklist } from '/js/admin-shared.js?v=0f36d0dc';
-import { renderColorControl } from '/js/admin-color-control.js?v=d162431d';
+import { reveal } from '/js/admin-shared.js?v=ca64cc9c';
+import { createListbox } from '/js/listbox.js?v=3e267705';
+import { renderColorControl } from '/js/admin-color-control.js?v=cfb3b3e9';
import {
seedCarried,
applyOptionSet,
@@ -13,14 +14,12 @@ import {
requiredFieldMissing,
groupBounds,
visibleFieldFlags,
-} from '/js/admin-logic.js?v=74cb4272';
+} from '/js/admin-logic.js?v=69e57d35';
import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
-import { qi } from '/js/utils.js?v=970a91b0';
+import { qi } from '/js/utils.js?v=ada0c382';
const PE =
'
';
-const CHEV =
- '
';
/* A counter, not randomness. Six base-36 characters collide plausibly over a
long session, and an id that changes per run cannot be asserted on. */
@@ -231,38 +230,26 @@ function _picklist(field, value, ctx, size) {
let opts = Array.isArray(field.options) ? field.options.slice() : [];
const sels = [];
+ const shown = cur => {
+ const out = opts.map(o => ({ value: String(o.value), label: o.label != null ? String(o.label) : String(o.value) }));
+ if (cur && !opts.some(o => String(o.value) === cur)) out.unshift({ value: cur, label: cur });
+ out.unshift({ value: '', label: t('widgetCfg.empty') });
+ return out;
+ };
for (let i = 0; i < count; i++) {
const row = document.createElement('div');
row.className = 'row';
setHtml(row, html`
${rowLabel} ${i + 1}`);
- const selWrap = document.createElement('div');
- selWrap.className = 'sel-wrap';
- const sel = document.createElement('select');
- sel.className = 'row-sel';
- sel.setAttribute('aria-label', `${rowLabel} ${i + 1}`);
- selWrap.appendChild(sel);
- const chevT = document.createElement('template');
- setHtml(chevT, raw(CHEV));
- selWrap.appendChild(chevT.content.firstElementChild);
- row.appendChild(selWrap);
+ const cur = chosen[i] != null ? String(chosen[i]) : '';
+ const lb = createListbox({ label: `${rowLabel} ${i + 1}`, options: shown(cur), value: cur });
+ row.appendChild(lb.el);
card.appendChild(row);
- sels.push(sel);
+ sels.push(lb);
}
function paint() {
- sels.forEach((sel, i) => {
- const cur = sel.value || (chosen[i] != null ? String(chosen[i]) : '');
- const items = opts.map(
- o =>
- html`
`,
- );
- if (cur && !opts.some(o => String(o.value) === cur))
- items.unshift(html`
`);
- items.unshift(html`
`);
- setHtml(sel, html`${items}`);
- });
+ sels.forEach(lb => lb.setOptions(shown(lb.getValue() || ''), lb.getValue() || ''));
}
- paint();
if (field.optionsFrom) {
btn.addEventListener('click', async () => {
@@ -286,7 +273,7 @@ function _picklist(field, value, ctx, size) {
btn.style.display = 'none';
}
- const get = () => [field.key, sels.map(s => s.value || null)];
+ const get = () => [field.key, sels.map(lb => lb.getValue() || null)];
return { el: wrap, get, control: null, liveValue: () => null };
}
@@ -299,38 +286,46 @@ function _select(field, value, ctx, config = {}) {
let carried = seedCarried(config, carryKeys);
let chosen = value != null ? String(value) : field.default != null ? String(field.default) : '';
setHtml(row, html`
${field.label}${_tag(field)}`);
- const selWrap = document.createElement('div');
- selWrap.className = 'sel-wrap';
- const sel = document.createElement('select');
- sel.className = 'row-sel';
- selWrap.appendChild(sel);
- const chevT = document.createElement('template');
- setHtml(chevT, raw(CHEV));
- selWrap.appendChild(chevT.content.firstElementChild);
- row.appendChild(selWrap);
+ /* Keep a stored value the list does not offer, or opening the picker
+ discards it. */
+ const shown = () => {
+ const out = opts.map(o => ({ value: String(o.value), label: o.label != null ? String(o.label) : String(o.value) }));
+ if (chosen && !opts.some(o => String(o.value) === chosen)) out.unshift({ value: chosen, label: chosen });
+ if (field.optional || !out.length) out.unshift({ value: '', label: t('widgetCfg.none') });
+ return out;
+ };
+ /* A native select lands on its first option when nothing is stored. Fetched
+ options arrive after the field is built, so this runs on every paint. */
+ const normalise = () => {
+ if (!field.optional && !chosen && opts.length) chosen = String(opts[0].value);
+ };
+ normalise();
+ const lb = createListbox({
+ label: field.label,
+ options: shown(),
+ value: chosen,
+ onChange: v => {
+ chosen = v == null ? '' : String(v);
+ syncCarried();
+ wrap.dispatchEvent(new Event('change'));
+ },
+ });
+ row.appendChild(lb.el);
wrap.appendChild(row);
- function paint() {
- const items = opts.map(
- o =>
- html`
`,
- );
- if (chosen && !opts.some(o => String(o.value) === chosen))
- items.unshift(html`
`);
- if (field.optional) items.unshift(html`
`);
- setHtml(sel, items.length ? html`${items}` : html`
`);
- }
- paint();
+ const paint = () => {
+ normalise();
+ lb.setOptions(shown(), chosen);
+ };
function syncCarried() {
if (!carryKeys.length) return;
carried = applyOptionSet(
carried,
- opts.find(x => String(x.value) === sel.value),
+ opts.find(x => String(x.value) === chosen),
carryKeys,
);
}
- sel.addEventListener('change', syncCarried);
if (field.optionsFrom) {
const fr = document.createElement('div');
@@ -352,7 +347,7 @@ function _select(field, value, ctx, config = {}) {
btn.disabled = true;
try {
opts = await _fetchOptions(field, ctx);
- chosen = sel.value || chosen;
+ chosen = lb.getValue() || chosen;
paint();
syncCarried();
status.textContent = opts.length ? t('widgetCfg.loaded', { count: opts.length }) : t('widgetCfg.noOptions');
@@ -372,8 +367,8 @@ function _select(field, value, ctx, config = {}) {
wrap.appendChild(h);
}
- const get = () => [field.key, sel.value, carryKeys.length ? carried : null];
- return { el: wrap, get, control: sel, liveValue: () => sel.value };
+ const get = () => [field.key, chosen, carryKeys.length ? carried : null];
+ return { el: wrap, get, control: wrap, liveValue: () => chosen };
}
function _pills(field, value) {
@@ -393,10 +388,35 @@ function _pills(field, value) {
r.addEventListener('change', () => {
if (r.checked) {
sel = r.value;
- group.dispatchEvent(new Event('change'));
+ wrap.dispatchEvent(new Event('change'));
}
}),
);
+
+ /* A segmented control cannot shrink: its labels do not wrap, so it can be
+ wider than the screen. When it does not fit it becomes the picker. */
+ const label = row.querySelector('.rl');
+ const fits = () => {
+ const room = row.clientWidth - label.getBoundingClientRect().width - 24;
+ return room <= 0 || group.scrollWidth <= room;
+ };
+ if (typeof ResizeObserver === 'function') {
+ const ro = new ResizeObserver(() => {
+ if (fits()) return;
+ ro.disconnect();
+ const box = createListbox({
+ label: field.label,
+ options: opts.map(o => ({ value: String(o.value), label: String(o.label) })),
+ value: sel,
+ onChange: v => {
+ sel = String(v);
+ wrap.dispatchEvent(new Event('change'));
+ },
+ });
+ group.replaceWith(box.el);
+ });
+ ro.observe(row);
+ }
if (field.hint) {
const h = document.createElement('p');
h.className = 'grp-tip in-card';
@@ -404,7 +424,7 @@ function _pills(field, value) {
wrap.appendChild(h);
}
const get = () => [field.key, sel];
- return { el: wrap, get, control: group, liveValue: () => sel };
+ return { el: wrap, get, control: wrap, liveValue: () => sel };
}
/* ── Multi-select → checklist dropdown (tap to toggle), value is array in declared order. ── */
@@ -413,29 +433,27 @@ function _multiselect(field, value) {
const row = document.createElement('div');
row.className = 'row';
const opts = Array.isArray(field.options) ? field.options : [];
- const cur = new Set(
+ let cur = new Set(
Array.isArray(value) ? value.map(String) : Array.isArray(field.default) ? field.default.map(String) : [],
);
- const summary = () =>
- cur.size === 0 ? t('widgetCfg.noneSelected') : t('widgetCfg.selectedCount', { count: cur.size });
- setHtml(
- row,
- html`
${field.label}${opts.map(o => html`- ${o.label}
`)}
`,
- );
- wrap.appendChild(row);
- const dd = row.querySelector('.row-dd');
- const btn = row.querySelector('.row-dd-btn'),
- list = row.querySelector('.row-dd-list'),
- sumEl = row.querySelector('.ms-sum');
- wireChecklist(dd, btn, list, li => {
- const v = li.dataset.val,
- on = li.getAttribute('aria-selected') !== 'true';
- li.setAttribute('aria-selected', String(on));
- if (on) cur.add(v);
- else cur.delete(v);
- sumEl.textContent = summary();
- wrap.dispatchEvent(new Event('change'));
+ /* Reads what it is handed, not `cur`. The button is painted before the
+ change is reported back. */
+ const summary = sel =>
+ sel.length === 0 ? t('widgetCfg.noneSelected') : t('widgetCfg.selectedCount', { count: sel.length });
+ setHtml(row, html`
${field.label}`);
+ const lb = createListbox({
+ label: field.label,
+ multiple: true,
+ options: opts.map(o => ({ value: String(o.value), label: String(o.label) })),
+ value: [...cur],
+ summary,
+ onChange: v => {
+ cur = new Set(v);
+ wrap.dispatchEvent(new Event('change'));
+ },
});
+ row.appendChild(lb.el);
+ wrap.appendChild(row);
if (field.hint) {
const h = document.createElement('p');
h.className = 'grp-tip in-card';
@@ -470,7 +488,7 @@ function _color(field, value) {
}
function _visible(b) {
- return b.el.style.display !== 'none';
+ return b.wrap ? b.wrap.classList.contains('open') : b.el.style.display !== 'none';
}
function _missingIn(built) {
@@ -494,12 +512,44 @@ function _wireShowIf(built) {
for (const b of built) if (b.liveValue) liveByKey[b.field.key] = b.liveValue;
const fields = built.map(b => b.field);
const readValue = key => (liveByKey[key] ? liveByKey[key]() : undefined);
+ /* The wrapper takes the row's own place, so a single-row field keeps the
+ wrapper that draws its separator. */
+ for (const b of built) {
+ if (!b.field.showIf || !b.el.parentNode) continue;
+ const wrap = document.createElement('div');
+ wrap.className = b.el.classList.contains('row') ? 'row-wrap reveal' : 'reveal';
+ const inner = document.createElement('div');
+ inner.className = 'reveal-in';
+ b.el.replaceWith(wrap);
+ inner.appendChild(b.el);
+ wrap.appendChild(inner);
+ b.wrap = wrap;
+ }
+ /* A card with nothing left in it still draws its border and claims its gap. */
+ const cards = [...new Set(built.map(b => b.el.closest('.grp')).filter(Boolean))];
+ const cardHasRow = card =>
+ [...card.querySelectorAll('.row')].some(r => !r.classList.contains('d-none') && !r.closest('.reveal:not(.open)'));
+ const syncCards = () => {
+ for (const card of cards) {
+ const empty = !cardHasRow(card);
+ card.classList.toggle('grp-empty', empty);
+ const hdr = card.previousElementSibling;
+ if (hdr?.classList.contains('grp-hdr')) hdr.classList.toggle('grp-empty', empty);
+ }
+ };
+
+ let first = true;
const apply = () => {
const shown = visibleFieldFlags(fields, readValue);
built.forEach((b, i) => {
if (!b.field.showIf) return;
- b.el.style.display = shown[i] ? '' : 'none';
+ if (b.wrap) reveal(b.wrap, shown[i], first);
+ else b.el.style.display = shown[i] ? '' : 'none';
});
+ /* Again after the transition: a closing card still holds its rows. */
+ syncCards();
+ setTimeout(syncCards, 400);
+ first = false;
};
for (const b of built) {
if (b.control) b.control.addEventListener('change', apply);
diff --git a/public/widgets/clock/analog.html b/public/widgets/clock/analog.html
index 7f01396..ee8ae50 100644
--- a/public/widgets/clock/analog.html
+++ b/public/widgets/clock/analog.html
@@ -74,7 +74,7 @@
};
let _mode='dark', _timezone='', _showDate=true;
-let _groups=null, _dateText=null, _rafId=null, _running=false, _vb=0;
+let _groups=null, _dateTexts=[], _rafId=null, _running=false, _vb=0;
function _now(){
if(!_timezone) return new Date();
@@ -114,11 +114,16 @@
face.appendChild(el('rect',{ x:0, y:0, width:V, height:V, fill:dial.fill }));
}
- if(_showDate){
- _dateText=el('text',{ x:cx, y:V-V*0.085, 'text-anchor':'middle',
+ _dateTexts=[];
+ const dateOf=clip=>{
+ if(!_showDate) return;
+ const n=el('text',{ x:cx, y:V-V*0.085, 'text-anchor':'middle',
'font-size':V*0.072, fill:dial.date });
- face.appendChild(_dateText);
- } else { _dateText=null; }
+ if(clip) n.setAttribute('clip-path', clip);
+ _dateTexts.push(n);
+ face.appendChild(n);
+ };
+ dateOf(null);
const dist=V*0.085;
_groups={ minute:{discs:[],ticks:[]}, hour:{discs:[],ticks:[]}, second:{discs:[],ticks:[]} };
@@ -148,18 +153,24 @@
const inMinute=clipOf('clip-minute','minute',{});
const inHour=clipOf('clip-hour','hour',{});
const inMinuteHour=clipOf('clip-minute-hour','hour',{ 'clip-path':inMinute });
+ const inSecond=clipOf('clip-second','second',{});
+ /* The date stays under the hands. Each disc repeats it, clipped to the disc,
+ so it shows through tinted like the disc overlaps. */
face.appendChild(discOf('minute',{ fill:DISC.minute.color }));
+ dateOf(inMinute);
face.appendChild(tickOf('minute'));
face.appendChild(discOf('hour',{ fill:DISC.hour.color }));
face.appendChild(discOf('hour',{ fill:OVERLAP.minuteHour, 'clip-path':inMinute }));
+ dateOf(inHour);
face.appendChild(tickOf('hour'));
face.appendChild(discOf('second',{ fill:DISC.second.color }));
face.appendChild(discOf('second',{ fill:OVERLAP.minuteSecond, 'clip-path':inMinute }));
face.appendChild(discOf('second',{ fill:OVERLAP.hourSecond, 'clip-path':inHour }));
face.appendChild(discOf('second',{ fill:OVERLAP.minuteHourSecond, 'clip-path':inMinuteHour }));
+ dateOf(inSecond);
face.appendChild(tickOf('second'));
face.appendChild(el('circle',{ cx, cy, r:V*0.022, fill:dial.dot }));
@@ -189,8 +200,9 @@
}
document.getElementById('widget').setAttribute('aria-label', _a11yLabel(now));
- if(_showDate && _dateText){
- _dateText.textContent=_dateShortFmt.format(now);
+ if(_showDate && _dateTexts.length){
+ const d=_dateShortFmt.format(now);
+ for(const n of _dateTexts) n.textContent=d;
}
}
diff --git a/public/widgets/dns/index.html b/public/widgets/dns/index.html
index 06d9c0e..db574e8 100644
--- a/public/widgets/dns/index.html
+++ b/public/widgets/dns/index.html
@@ -21,27 +21,13 @@
.widget.clickable { cursor:pointer; }
.hero { display:flex; flex-direction:column; gap:2px; flex-shrink:0; }
- .pct { font-size:clamp(26px,9cqw,36px); font-weight:700; color:#FF4245; line-height:1; letter-spacing:-0.02em; }
+ .pct { font-size:clamp(26px,9cqw,36px); font-weight:700; color:#FF4245; line-height:1; letter-spacing:-0.02em; transition: color 0.15s ease; }
.sub { font-size:clamp(9px,3cqw,11px); font-weight:600; letter-spacing:0.09em; color:rgba(255,255,255,0.5); text-transform:uppercase; }
- .flow { flex:1; min-height:0; margin-top:13px; position:relative; }
+ .flow { flex:1; min-height:0; margin-top:13px; position:relative; touch-action:pan-y; }
.flow svg { width:100%; height:100%; display:block; overflow:visible; }
.link { transition: opacity 0.15s ease; }
-
- .tip {
- position:absolute; z-index:5;
- background:rgba(20,20,22,0.96);
- backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
- border-radius:8px; padding:6px 9px;
- box-shadow:0 6px 18px rgba(0,0,0,0.55), inset 0 0 0 0.5px rgba(255,255,255,0.1);
- display:flex; align-items:center; gap:6px; white-space:nowrap;
- opacity:0; transform:translateY(2px); pointer-events:none;
- transition: opacity 0.12s ease, transform 0.12s ease;
- }
- .tip.show { opacity:1; transform:translateY(0); }
- .tip i { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
- .tip b { font-size:11px; font-weight:600; color:#fff; }
- .tip s { font-size:11px; font-weight:500; color:rgba(255,255,255,0.6); text-decoration:none; }
+ @media (prefers-reduced-motion: reduce) { .pct, .link { transition:none; } }
.msg { position:absolute; inset-inline:0; bottom:0; }
@@ -56,7 +42,6 @@
@@ -82,7 +67,7 @@
const widgetEl = document.getElementById('widget');
const pctEl = document.getElementById('pct');
const svgEl = document.getElementById('svg');
-const tipEl = document.getElementById('tip');
+const subEl = document.getElementById('sub-label');
const msgEl = document.getElementById('msg');
const srEl = document.getElementById('sr');
@@ -126,11 +111,10 @@
const allowed = Math.max(0, total - blocked - cached);
const pct = total > 0 ? Math.round(blocked / total * 100) : 0;
- pctEl.textContent = formatNumber(pct / 100, { style: 'percent' });
-
const model = { total, allowed, cached, blocked, pct };
_last = model;
render(model);
+ showHeadline();
srEl.textContent = wt('ui.summary', '{pct}% of {total} queries blocked. Allowed {allowed}, cached {cached}, blocked {blocked}.')
.replace('{pct}', formatNumber(pct)).replace('{total}', fmt(total))
@@ -138,79 +122,100 @@
}
function constLink(x0,c0,x1,c1,t) {
- const h=t/2, cx0=x0+(x1-x0)*0.5, cx1=x0+(x1-x0)*0.5;
- return `M${x0},${c0-h} C${cx0},${c0-h} ${cx1},${c1-h} ${x1},${c1-h} `
- + `L${x1},${c1+h} C${cx1},${c1+h} ${cx0},${c0+h} ${x0},${c0+h} Z`;
+ const h=t/2, cx=x0+(x1-x0)*0.5;
+ return `M${x0},${c0-h} C${cx},${c0-h} ${cx},${c1-h} ${x1},${c1-h} `
+ + `L${x1},${c1+h} C${cx},${c1+h} ${cx},${c0+h} ${x0},${c0+h} Z`;
}
+const W = 240, H = 120, NODE_W = 8, SRC_SHARE = 0.5;
+let _segs = [];
+let _sel = -1;
+
function render(m) {
- const flow = document.getElementById('flow');
- const W = 240, H = 120;
svgEl.setAttribute('viewBox', `0 0 ${W} ${H}`);
- const sum = m.allowed + m.cached + m.blocked || 1;
const segs = [
{ key:'allowed', label:wt('ui.allowed','Allowed'), value:m.allowed, color:'#30D158' },
{ key:'cached', label:wt('ui.cached','Cached'), value:m.cached, color:'#0091FF' },
{ key:'blocked', label:wt('ui.blocked','Blocked'), value:m.blocked, color:'#FF4245' },
].filter(s => s.value > 0);
+ _segs = segs;
+ if (_sel >= segs.length) _sel = -1;
if (!segs.length) { svgEl.innerHTML=''; return; }
- const nodeW = 9, pad = 2;
- const nX = pad, nX2 = nX + nodeW;
- const outX = W - pad - nodeW, outX2 = W - pad;
+ const sum = segs.reduce((a,s)=>a+s.value,0);
+ const srcH = H * SRC_SHARE;
+ const t = segs.map(s => Math.max(2, s.value / sum * srcH));
+ const tSum = t.reduce((a,b)=>a+b,0);
+ const gap = segs.length > 1 ? (H - tSum) / (segs.length - 1) : 0;
+ const x0 = NODE_W, x1 = W - NODE_W;
- const t = segs.map(s => Math.max(2, s.value / sum * H));
- const tScale = H / t.reduce((a,b)=>a+b,0);
- const tt = t.map(v => v * tScale);
-
- let fy = 0; const fc = tt.map(th => { const c=fy+th/2; fy+=th; return c; });
- const outGap = segs.length > 1 ? 8 : 0;
- const totalR = tt.reduce((a,b)=>a+b,0) + (segs.length-1)*outGap;
- const rScale = (H - (segs.length-1)*outGap) / tt.reduce((a,b)=>a+b,0);
- const rt = tt.map(v => v * rScale);
- let ry = (H - (rt.reduce((a,b)=>a+b,0) + (segs.length-1)*outGap)) / 2;
- const rc = rt.map(th => { const c=ry+th/2; ry+=th+outGap; return c; });
-
- const meta = {};
+ let sy = (H - tSum) / 2;
+ let ty = segs.length > 1 ? 0 : (H - tSum) / 2;
let links = '', nodes = '';
segs.forEach((s,i) => {
- links += `
`;
- meta[s.key] = { label:s.label, value:s.value, color:s.color, cy:rc[i] };
+ s.sy = sy + t[i]/2; s.ty = ty + t[i]/2; s.t = t[i];
+ links += `
` + nodes;
svgEl.innerHTML = links + nodes;
+ highlight();
+}
+
+function ribbonY(s, x) {
+ const u = Math.min(1, Math.max(0, (x - NODE_W) / (W - 2*NODE_W)));
+ return s.sy + (s.ty - s.sy) * (3*u*u - 2*u*u*u);
+}
- svgEl.querySelectorAll('.link').forEach(p => {
- const onEnter = () => {
- const mm = meta[p.dataset.k];
- svgEl.querySelectorAll('.link').forEach(q => q.style.opacity = q===p ? '1' : '0.3');
- tipEl.innerHTML = `
`;
- tipEl.classList.add('show');
- const fh = document.getElementById('flow').clientHeight;
- tipEl.style.top = Math.max(0, Math.min(fh - tipEl.offsetHeight, (mm.cy / H * fh) - tipEl.offsetHeight/2)) + 'px';
- tipEl.style.right = '4px'; tipEl.style.left = 'auto';
- };
- const onLeave = () => {
- svgEl.querySelectorAll('.link').forEach(q => q.style.opacity = '0.9');
- tipEl.classList.remove('show');
- };
- p.style.cursor = _href ? 'pointer' : 'default';
- p.addEventListener('mouseenter', onEnter);
- p.addEventListener('mouseleave', onLeave);
- p.addEventListener('touchstart', e => { onEnter(); }, { passive:true });
+function highlight() {
+ svgEl.querySelectorAll('.link').forEach((p,i) => {
+ p.style.opacity = _sel < 0 || i === _sel ? '1' : '0.3';
});
}
-document.addEventListener('touchend', e => {
- if (!e.target.closest('.link')) {
- svgEl.querySelectorAll('.link').forEach(q => q.style.opacity = '0.9');
- tipEl.classList.remove('show');
+function showHeadline() {
+ const s = _segs[_sel];
+ if (s) {
+ pctEl.textContent = fmt(s.value);
+ pctEl.style.color = s.color;
+ subEl.textContent = s.label;
+ } else if (_last) {
+ pctEl.textContent = formatNumber(_last.pct / 100, { style: 'percent' });
+ pctEl.style.color = '';
+ subEl.textContent = wt('ui.queriesBlocked', 'Queries Blocked');
}
-}, { passive:true });
+}
+
+function select(i) {
+ if (i === _sel) return;
+ _sel = i;
+ highlight();
+ showHeadline();
+}
+
+function pick(e) {
+ if (!_segs.length) return;
+ const r = flowEl.getBoundingClientRect();
+ if (!r.width || !r.height) return;
+ const x = (e.clientX - r.left) / r.width * W;
+ const y = (e.clientY - r.top) / r.height * H;
+ let best = 0, bestD = Infinity;
+ _segs.forEach((s,i) => {
+ const d = Math.abs(ribbonY(s, x) - y) - s.t/2;
+ if (d < bestD) { bestD = d; best = i; }
+ });
+ select(best);
+}
+
+const flowEl = document.getElementById('flow');
+flowEl.addEventListener('pointermove', pick);
+flowEl.addEventListener('pointerdown', pick);
+for (const type of ['pointerleave', 'pointercancel']) flowEl.addEventListener(type, () => select(-1));
+flowEl.addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') select(-1); });
boot();
diff --git a/public/widgets/nowplaying/index.html b/public/widgets/nowplaying/index.html
index 30be462..b525428 100644
--- a/public/widgets/nowplaying/index.html
+++ b/public/widgets/nowplaying/index.html
@@ -16,35 +16,38 @@
.widget.clickable{cursor:pointer}
.widget.clickable:focus-visible{outline:2px solid #00D2E0;outline-offset:-3px}
.widget::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;
- box-shadow:inset 3px 0 0 rgba(0,0,0,.22),inset -3px 0 0 rgba(0,0,0,.22),
- inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(0,0,0,.3)}
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(0,0,0,.3)}
.stage{position:absolute;inset:0}
- .stage.outL{animation:outL .2s ease-in forwards}
- .stage.outR{animation:outR .2s ease-in forwards}
- .stage.inR{animation:inR .28s cubic-bezier(.22,.7,.3,1) forwards}
- .stage.inL{animation:inL .28s cubic-bezier(.22,.7,.3,1) forwards}
- @keyframes outL{to{transform:translateX(-114%);opacity:0}}
- @keyframes outR{to{transform:translateX(114%);opacity:0}}
- @keyframes inR{from{transform:translateX(114%);opacity:0}to{transform:translateX(0);opacity:1}}
- @keyframes inL{from{transform:translateX(-114%);opacity:0}to{transform:translateX(0);opacity:1}}
-
- .sticker{position:absolute;left:14px;right:14px;top:11px;height:76px;
- background:linear-gradient(180deg,#FFFFFF,#d2d2d2);border-radius:6px;
+
+ .sticker{position:absolute;left:14px;right:14px;top:14px;height:73px;
+ background:linear-gradient(180deg,#FFFFFF,#d2d2d2);border-radius:14px;
border:1px solid rgba(0,0,0,.18);box-shadow:0 1px 2px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.6);
- padding:7px 10px;display:flex;flex-direction:column;gap:2px;overflow:hidden}
+ padding:8px 12px 22px;display:flex;flex-direction:column;gap:2px;overflow:hidden}
.title{font-size:13px;font-weight:700;line-height:1.16;color:#1C1C1E;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
- /* margin-top:auto seats the meta lines on the sticker's bottom edge. */
- .sub,.player{flex:none;font-size:9px;font-weight:600;line-height:1.12;letter-spacing:.02em;
+ .sub{flex:none;font-size:9px;font-weight:600;line-height:1.12;letter-spacing:.02em;color:#48484A;margin-top:auto;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:uppercase}
- .sub{color:#48484A;margin-top:auto}
- .player{color:#8E8E93}
.idle .title{color:#636366;font-weight:600}
- .sticker{--wt-cap-color:#48484A}
- .np-cap{flex:none;margin-top:auto}
+ .sticker{--wt-cap-color:#1C1C1E}
+ .np-cap{flex:none;flex-wrap:wrap;row-gap:3px}
+ .sticker .np-cap b{font-size:12.5px;font-weight:600;line-height:1.2}
+ .sticker .np-cap svg{width:14px;height:14px}
+ .sticker .np-cap i{flex-basis:100%;padding-inline-start:19px;font-size:9px;font-weight:600;
+ letter-spacing:.03em;text-transform:uppercase;color:#8E8E93}
/* .title sets display. Without this rule the hidden attribute does nothing. */
- .sticker [hidden]{display:none}
+ .sticker [hidden],.band [hidden]{display:none}
+
+ .band{position:absolute;left:14px;right:14px;top:68px;height:19px;z-index:3;
+ display:flex;align-items:center;justify-content:space-between;gap:4px;padding-inline:12px 4px;
+ border-radius:0 0 14px 14px;box-shadow:inset 0 .5px 0 rgba(0,0,0,.2);
+ background:var(--band,#8E8E93);color:var(--band-ink,#FFFFFF)}
+ .err .band{background:#636366;color:#FFFFFF}
+ .player{min-width:0;font-size:8.5px;font-weight:700;line-height:1;letter-spacing:.06em;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:uppercase}
+ .ctl{flex:none;display:flex;align-items:center}
+ .count{min-width:14px;font-size:8.5px;font-weight:600;line-height:1;text-align:center;opacity:.8;
+ font-variant-numeric:tabular-nums}
.window{position:absolute;left:14px;right:14px;top:94px;bottom:14px;
background:radial-gradient(135% 135% at 50% 20%,#636366,#48484A 58%,#48484A);
@@ -65,41 +68,42 @@
display:flex;flex-direction:column;gap:6px;opacity:.5}
.ticks i{display:block;width:12px;height:2px;background:#FFFFFF;border-radius:1px}
- .arrow{position:absolute;top:93px;transform:translateY(-50%);width:24px;height:34px;
- border:0;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;
- color:#FFFFFF;opacity:.92;transition:opacity .15s;z-index:3}
- .arrow svg{width:17px;height:17px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.5))}
- .arrow.l{inset-inline-start:0}.arrow.r{inset-inline-end:0}
+ .nav{width:19px;height:19px;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;
+ display:flex;align-items:center;justify-content:center}
+ .nav svg{width:10px;height:10px}
+ .nav:hover{background:rgba(128,128,128,.22)}
+ .nav:focus-visible{outline:2px solid currentColor;outline-offset:-2px}
/* A chevron is a drawing: no logical property points it the other way. */
- [dir="rtl"] .arrow svg{transform:scaleX(-1)}
- .arrow:disabled{opacity:.2;cursor:default}
- .arrow:not(:disabled):hover{opacity:1}
- .arrow:focus-visible{outline:2px solid #fff;outline-offset:1px;border-radius:4px;opacity:1}
+ [dir="rtl"] .nav svg{transform:scaleX(-1)}
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);border:0}
@media(prefers-reduced-motion:reduce){
.spinwrap .reel{animation:none!important}
- .stage{animation:none!important}
.tape{transition:none!important}
}