+ ${hueRow}
${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)}
@@ -146,8 +178,9 @@ export function renderColorControl(
/* The markup carries no style attribute, so the page keeps style-src without
'unsafe-inline'. A swatch paints itself from its own value. */
for (const b of wrap.querySelectorAll('.cc-swatch[data-v]')) {
- const v = b.getAttribute('data-v');
- if (v && v[0] === '#') /** @type {HTMLElement} */ (b).style.background = v;
+ const v = b.getAttribute('data-v') || '';
+ const fill = v[0] === '#' ? v : v === 'auto' || v === 'clear' ? '' : _swatchFill(v);
+ if (fill) /** @type {HTMLElement} */ (b).style.background = fill;
}
const rows = /** @type {HTMLElement[]} */ ([...wrap.children]);
rows.forEach(r => container.appendChild(r));
@@ -161,8 +194,8 @@ export function renderColorControl(
hidden.type = 'hidden';
hidden.id = `${idPrefix}-val`;
container.appendChild(hidden);
- let mode = isSem(value) ? value : 'color';
- let showTune = false;
+ let mode = isKw(value) ? value : 'color';
+ let showTune = hues.includes(mode);
/* 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
@@ -179,6 +212,13 @@ export function renderColorControl(
rb = _rgb(b);
return ra && rb && ra.every((n, i) => Math.abs(n - rb[i]) <= 3);
};
+ const setSliders = hex => {
+ const hv = _hexToHsv(hex);
+ if (!hv) return;
+ hEl.value = String(hv.h);
+ sEl.value = String(hv.s);
+ vEl.value = String(hv.v);
+ };
function paint() {
const h = +hEl.value,
v = +vEl.value,
@@ -195,16 +235,15 @@ export function renderColorControl(
vEl.style.setProperty('--knob-fill', hex);
qa('.cc-swatch', container).forEach(b => {
let on = false;
- if (mode === 'dark' || mode === 'light') on = b.dataset.v === mode;
- else if (!showTune) on = b.dataset.v !== 'custom' && !b.classList.contains('cc-sem') && _near(b.dataset.v, hex);
+ if (mode !== 'color') on = b.dataset.v === mode;
+ else if (!showTune) on = b.dataset.v !== 'custom' && !b.classList.contains('cc-kw') && _near(b.dataset.v, hex);
b.classList.toggle('on', on);
});
const rb = q('.cc-rainbow', container);
- if (rb) rb.classList.toggle('on', mode === 'color' && showTune);
+ if (rb) rb.classList.toggle('on', showTune && (mode === 'color' || hues.includes(mode)));
reveal(tune, showTune, !_painted);
if (!codeRv.closest('.editing')) {
- codeRv.textContent =
- mode === 'color' ? hex : t(mode === 'dark' ? 'appearance.displayDark' : 'appearance.displayLight');
+ codeRv.textContent = mode === 'color' ? hex : kwName(mode);
codeRv.classList.remove('is-ph');
}
hidden.value = mode === 'color' ? (pristine ?? hex) : mode;
@@ -224,26 +263,23 @@ export function renderColorControl(
);
qa('.cc-swatch', container).forEach(b =>
b.addEventListener('click', () => {
- if (b.dataset.v === 'dark' || b.dataset.v === 'light') {
- mode = b.dataset.v;
- showTune = false;
+ const v = b.dataset.v || '';
+ if (v === 'custom') {
+ showTune = !(showTune && (mode === 'color' || hues.includes(mode)));
+ if (showTune && !hues.includes(mode)) mode = 'color';
commit();
return;
}
- if (b.dataset.v === 'custom') {
- mode = 'color';
- showTune = true;
+ if (isKw(v)) {
+ mode = v;
+ showTune = hues.includes(v);
+ if (showTune) setSliders(_swatchFill(v));
commit();
return;
}
mode = 'color';
showTune = false;
- const hv = _hexToHsv(b.dataset.v);
- if (hv) {
- hEl.value = String(hv.h);
- sEl.value = String(hv.s);
- vEl.value = String(hv.v);
- }
+ setSliders(v);
commit();
}),
);
@@ -251,21 +287,20 @@ export function renderColorControl(
root: container,
placeholder: '#rrggbb or any CSS color',
onCommit(val) {
+ if (mode !== 'color' && val === kwName(mode)) return;
const { value, ok } = normalizeColorInput(val);
const hv = ok ? _hexToHsv(value) : null;
if (hv) {
mode = 'color';
showTune = true;
- hEl.value = String(hv.h);
- sEl.value = String(hv.s);
- vEl.value = String(hv.v);
+ setSliders(value);
} else if (val) toast(t('toast.colorInvalid'), 'err');
commit();
},
});
if (mode === 'color') {
const presets = qa('.cc-swatch', container)
- .filter(b => b.dataset.v !== 'custom' && !b.classList.contains('cc-sem'))
+ .filter(b => b.dataset.v !== 'custom' && !b.classList.contains('cc-kw'))
.map(b => b.dataset.v);
showTune = !presets.some(pv => _near(pv, value));
}
diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js
index f8cf603..44b450c 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=5a5d655f';
-import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
-import { qa } from '/js/utils.js?v=045df327';
+import { state } from '/js/admin-state.js?v=831e219e';
+import { snapshotItems } from '/js/admin-save-logic.js?v=858f3f84';
+import { qa } from '/js/utils.js?v=55685187';
/** @type {(before: unknown) => void} */
let _save = () => {};
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index d2248c6..d33d7d8 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=5a5d655f';
-import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
+import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e';
+import { snapshotItems } from '/js/admin-save-logic.js?v=858f3f84';
import { reorderItems } from '/js/admin-logic.js?v=e3673bd7';
-import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=227e3153';
-import { paintIcon } from '/js/admin-shared.js?v=dc0e02b6';
-import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=045df327';
+import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=a9bbc794';
+import { paintIcon } from '/js/admin-shared.js?v=52e149f5';
+import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=55685187';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { t } from '/js/i18n.js?v=1f1ea9c1';
-import { sizeLabel } from '/js/admin-widget-form.js?v=3237dd3e';
+import { sizeLabel } from '/js/admin-widget-form.js?v=17a37df4';
import { widgetGlyph } from '/js/widget-glyphs.js?v=3adb57ad';
import { iconSvg } from '/js/icon-set.js?v=606a68c6';
diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js
index b1650d8..8e13495 100644
--- a/public/js/admin-save-logic.js
+++ b/public/js/admin-save-logic.js
@@ -1,7 +1,7 @@
// @ts-check
/* Keep this module free of the DOM. The DOM reads stay in doSave. */
-const BADGE_DEFAULT = '#1e6ef4';
+const BADGE_DEFAULT = 'info';
/* Keep in step with MAX_LABELS in badge-logic.js. */
const MAX_LABELS = 5;
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index c797fb6..d3fed7b 100644
--- a/public/js/admin-settings.js
+++ b/public/js/admin-settings.js
@@ -1,4 +1,4 @@
-import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=dc0e02b6';
+import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=52e149f5';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import {
@@ -9,15 +9,15 @@ import {
BLOCK,
} from '/js/admin-logic.js?v=e3673bd7';
import { confirmText } from '/js/modal.js?v=11fa1eff';
-import { el, inp, setUserText } from '/js/utils.js?v=045df327';
+import { el, inp, setUserText } from '/js/utils.js?v=55685187';
/* 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} */
+/** @type {{ dirty: () => boolean, reset: (force?: boolean) => void } | null} */
let _srvTrack = null;
-/** @type {{ dirty: () => boolean, reset: () => void } | null} */
+/** @type {{ dirty: () => boolean, reset: (force?: boolean) => void } | null} */
let _bgTrack = null;
const _val = (...ids) => {
@@ -60,6 +60,7 @@ const readWallpaperForm = () =>
function trackSave(buttonId, read) {
const btn = /** @type {HTMLButtonElement|null} */ (el(buttonId));
const tr = createDirtyTracker(read);
+ let touched = false;
const sync = () => {
if (btn) btn.disabled = !tr.dirty();
};
@@ -67,12 +68,21 @@ function trackSave(buttonId, read) {
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));
+ document.addEventListener(type, () =>
+ setTimeout(() => {
+ if (tr.dirty()) touched = true;
+ sync();
+ }),
+ );
sync();
return {
dirty: tr.dirty,
- reset: () => {
- tr.reset();
+ /* Do not re-baseline a touched form. The edit is swallowed and Save never
+ lights again.
+ @param {boolean} [force] */
+ reset: (force = true) => {
+ if (force || !touched) tr.reset();
+ if (force) touched = false;
sync();
},
};
@@ -251,7 +261,7 @@ export function loadSettings(c) {
_srvTrack = trackSave('srv-save', readServerForm);
_bgTrack = trackSave('bg-save', readWallpaperForm);
- syncAuthFromServer().then(() => _srvTrack?.reset());
+ syncAuthFromServer().then(() => _srvTrack?.reset(false));
}
async function syncAuthFromServer() {
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index bf59245..6643998 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -1,6 +1,6 @@
/* Stateless helpers shared by the admin modules. Mutable state stays out. */
import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=e3673bd7';
-import { el, q } from '/js/utils.js?v=045df327';
+import { el, q } from '/js/utils.js?v=55685187';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import { iconChain } from '/js/icons.js?v=9c8c550c';
import { iconSvg } from '/js/icon-set.js?v=606a68c6';
diff --git a/public/js/admin-state.js b/public/js/admin-state.js
index 4c280b2..8d4d9b7 100644
--- a/public/js/admin-state.js
+++ b/public/js/admin-state.js
@@ -12,7 +12,7 @@ export const state = {
ctype: 'app',
siurl: '',
- scol: 'dark',
+ scol: 'auto',
spaths: [],
/* Live Activity label styling, keyed by value path. */
slabels: Object.create(null),
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index c44ded2..6087cf5 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,11 +1,11 @@
-import { state } from '/js/admin-state.js?v=5a5d655f';
-import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=dc0e02b6';
-import { createListbox } from '/js/listbox.js?v=96066369';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=e998ed94';
+import { state } from '/js/admin-state.js?v=831e219e';
+import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=52e149f5';
+import { createListbox } from '/js/listbox.js?v=32f787c3';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=45f9a04b';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=e3673bd7';
import { t } from '/js/i18n.js?v=1f1ea9c1';
-import { q, qi, qa } from '/js/utils.js?v=045df327';
+import { q, qi, qa } from '/js/utils.js?v=55685187';
import { iconSvg } from '/js/icon-set.js?v=606a68c6';
const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']);
diff --git a/public/js/admin.js b/public/js/admin.js
index 47315ef..9aefb68 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,6 +1,6 @@
-import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=72ab8cad';
-import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9833ce5d';
-import { initList, render, syncFilterUI } from '/js/admin-list.js?v=1c65c8ad';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=442d8e7d';
+import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9efdedbe';
+import { initList, render, syncFilterUI } from '/js/admin-list.js?v=15bd26cd';
import { resolveAdminSection } from '/js/admin-logic.js?v=e3673bd7';
import {
buildAppItem,
@@ -9,14 +9,14 @@ import {
saveWithRevert,
snapshotItems,
upsertItem,
-} from '/js/admin-save-logic.js?v=4f71ef6c';
-import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=002dde46';
-import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=dc0e02b6';
-import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=3237dd3e';
+} from '/js/admin-save-logic.js?v=858f3f84';
+import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=4aed22e2';
+import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=52e149f5';
+import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=17a37df4';
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=96066369';
+import { createListbox } from '/js/listbox.js?v=32f787c3';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=1f1ea9c1';
import { loadLocalIcons } from '/js/icons.js?v=9c8c550c';
@@ -29,12 +29,20 @@ import {
NOTE,
parseErrorsAsSkipped,
SKIP,
-} from '/js/import-foreign.js?v=722d1c25';
+} from '/js/import-foreign.js?v=d154ca56';
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607';
import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff';
-import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=787bfdff';
-import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=045df327';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=a48fbc58';
+import {
+ THEME_KEY,
+ applyTheme,
+ prefersDark,
+ readMode,
+ resolveTheme,
+ watchSystemTheme,
+ writeMode,
+} from '/js/theme.js?v=787bfdff';
+import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=55685187';
+import { normalizeColorInput } from '/js/admin-color-control.js?v=42c11a9b';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
@@ -246,7 +254,7 @@ function openModal(idx) {
const item = editing ? structuredClone(editing) : null;
state.ctype = item?.type || 'app';
state.siurl = item?.iconUrl || '';
- state.scol = item?.color || 'dark';
+ state.scol = item ? item.color || 'dark' : 'auto';
state._customUrl = item?.url || '';
state._iframeOpts = item?.iframe ? { ...item.iframe } : {};
state.fnums = [];
@@ -262,7 +270,7 @@ function openModal(idx) {
state.slabels[l.path] = {
name: l.name || '',
unit: l.unit || '',
- color: l.color || '#1e6ef4',
+ color: l.color || 'info',
min: l.min == null ? '' : String(l.min),
};
}
@@ -572,7 +580,7 @@ async function doSave(orig) {
custMin: parseInt(inp('bcust-min')?.value || '', 10),
staticEn: inp('static-en')?.checked || false,
staticLabel: inp('f-static-label')?.value?.trim() || '',
- staticColor: inp('static-col-val')?.value || '#1e6ef4',
+ staticColor: inp('static-col-val')?.value || 'info',
dock: inp('f-dock')?.checked || false,
iconUrl: state.siurl,
scol: state.scol,
@@ -985,6 +993,12 @@ function initTheme() {
hidden.value = writeMode(readMode());
box.setValue(hidden.value);
watchSystemTheme(() => hidden.value);
+ addEventListener('storage', e => {
+ if (e.key !== THEME_KEY) return;
+ hidden.value = readMode();
+ box.setValue(hidden.value);
+ applyTheme(resolveTheme(hidden.value, prefersDark()));
+ });
}
const dashSaveEl = /** @type {HTMLButtonElement|null} */ (el('dash-save'));
diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js
index 7a12df6..bc61304 100644
--- a/public/js/badge-logic.js
+++ b/public/js/badge-logic.js
@@ -55,15 +55,17 @@ export function needsDark(hex) {
}
}
-export function resolveColor(c) {
- return c ? NAMED[c] || c : '';
+/** @param {unknown} c @param {(name: string) => string} [palette] */
+export function resolveColor(c, palette) {
+ if (!c || typeof c !== 'string') return '';
+ return (typeof palette === 'function' && palette(c)) || NAMED[c] || c;
}
/** A colour safe to hand to CSS, or ''. The fill lands in a custom property,
which stores any string: `url(...)` there becomes a real request.
@param {unknown} c @returns {string} */
-export function safeColor(c) {
- const v = typeof c === 'string' ? resolveColor(c) : '';
+export function safeColor(c, palette) {
+ const v = resolveColor(c, palette);
if (!v) return '';
const supports = globalThis.CSS?.supports;
if (typeof supports === 'function') return globalThis.CSS.supports('color', v) ? v : '';
@@ -130,9 +132,9 @@ export function badgeMinimum(custom) {
/** The labels reaching their own threshold, in priority order. `values` is
positional: index n is the number for `labels[n]`.
- @param {any[]} [labels] @param {number[]} [values]
+ @param {any[]} [labels] @param {number[]} [values] @param {(name: string) => string} [palette]
@returns {Array<{ index: number, name: string, value: number, unit: string, color: string }>} */
-export function firingLabels(labels, values) {
+export function firingLabels(labels, values, palette) {
if (!Array.isArray(labels) || !Array.isArray(values)) return [];
const out = [];
for (let i = 0; i < labels.length && out.length < MAX_LABELS; i++) {
@@ -145,7 +147,7 @@ export function firingLabels(labels, values) {
name: String(l.name || l.unit || l.path),
value: v,
unit: String(l.unit || ''),
- color: safeColor(l.color) || LABEL_DEFAULT_COLOR,
+ color: safeColor(l.color, palette) || safeColor('info', palette) || LABEL_DEFAULT_COLOR,
});
}
return out;
@@ -162,6 +164,7 @@ export function firingLabels(labels, values) {
healthDetail?: Record
,
translate?: (key: string, vars?: Record) => string,
format?: (value: number) => string,
+ palette?: (name: string) => string,
}} opts */
export function computeBadgeVisual({
health,
@@ -178,6 +181,7 @@ export function computeBadgeVisual({
healthDetail,
translate,
format,
+ palette,
}) {
const tr = typeof translate === 'function' ? translate : (k, v) => _fallback(k, v);
/* Digit shape is the reader's, not the interface language's. */
@@ -185,7 +189,7 @@ export function computeBadgeVisual({
/* Below this the item is treated as having no activity at all. One keeps the
original behaviour of badging any count above zero. */
const min = badgeMinimum(custom);
- const fired = firingLabels(labels, values);
+ const fired = firingLabels(labels, values, palette);
const top = fired[0];
const rows = [];
const active = top ? true : activity >= min;
@@ -207,11 +211,11 @@ export function computeBadgeVisual({
cls = 'badge on blue';
num = activity > 99 ? `${fmt(99)}+` : fmt(activity);
unit = custom.unit ? custom.unit.slice(0, 8) : '';
- bg = safeColor(custom.color);
+ bg = safeColor(custom.color, palette);
} else if (fixed) {
cls = 'badge on blue';
num = staticBdg.label.slice(0, 10);
- bg = safeColor(staticBdg.color);
+ bg = safeColor(staticBdg.color, palette);
} else if (!hideHealthy && hasHC) {
cls = 'badge on green';
} else {
@@ -253,7 +257,7 @@ export function computeBadgeVisual({
the reason beside it. */
if (health) rows.push({ name: reason || tr('status.needsAttention'), value: '', unit: '', color: NAMED.red });
for (const f of fired) rows.push(f);
- if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: safeColor(staticBdg.color) });
+ if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: safeColor(staticBdg.color, palette) });
const more = Math.max(0, rows.length - 1);
if (more) {
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 463a913..4d8ed12 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -6,8 +6,9 @@ import {
WIDGET_ROWS,
widgetSrc,
cardPreset,
+ fixedAppearance,
uniqueTitle,
-} from '/js/widget-types.js?v=d36b0153';
+} from '/js/widget-types.js?v=9264dee5';
import {
el,
isDashboardEmpty,
@@ -23,9 +24,9 @@ import {
setUserText,
teardownWidgets,
titleWhenTruncated,
-} from '/js/utils.js?v=045df327';
+} from '/js/utils.js?v=55685187';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
-import { initSpotlight } from '/js/spotlight.js?v=38fe61b4';
+import { initSpotlight } from '/js/spotlight.js?v=f050b4e5';
import { html, setHtml, raw } from '/js/html.js?v=c71f8903';
import { initI18n, t, currentLang } from '/js/i18n.js?v=1f1ea9c1';
import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7';
@@ -39,8 +40,8 @@ import {
buildMobile,
resetMobileChrome,
mkFolderGlyph,
-} from '/js/ui.js?v=e28d57cd';
-import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2';
+} from '/js/ui.js?v=d8363297';
+import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=ad283693';
import { formatNumber } from '/js/format-number.js?v=4a5ccef4';
import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3';
import { observeGlass } from '/js/glass-rim.js?v=3faec233';
@@ -57,6 +58,8 @@ import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607';
import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf';
import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
import { ensureSprite, iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43';
+import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=787bfdff';
ensureSprite();
@@ -201,6 +204,7 @@ function bupd(id) {
healthDetail: s.healthDetail,
translate: t,
format: formatNumber,
+ palette: name => paletteColor(name, pageTheme()),
});
const sig = badgeSignature({ cls, txt, unit, bg, aria, color, nextColor, rows });
@@ -346,6 +350,7 @@ function mkWidget(item) {
if (item.widgetType) card.dataset.wtype = item.widgetType;
const preset = cardPreset(item, widgetReg);
if (preset) card.dataset.card = preset;
+ if (fixedAppearance(item, widgetReg)) card.dataset.appearance = 'dark';
const design = WIDGET_DESIGN[sz] || WIDGET_DESIGN.medium;
card.style.height = WH.d[sz] + 'px';
card.style.borderRadius = WIDGET_R + 'px';
@@ -558,6 +563,8 @@ async function sampleWallpaper(url, brightness, fit) {
/* The wallpaper is sized against the viewport, so a resize moves which part of
it each label sits on. */
+const themeTone = () => (pageTheme() === 'light' ? 'dark' : 'light');
+
function resampleBg() {
if (!_bgSample) return retone();
bgTone = {
@@ -569,13 +576,15 @@ function resampleBg() {
_bgSample.fit,
WALLPAPER_BACKDROP,
),
- tone: null,
+ tone: themeTone(),
};
retone();
}
async function applyBg() {
const root = document.documentElement;
+ bgTone = { grid: null, tone: themeTone() };
+ retone();
try {
const bg = S.background || {};
if (bg.type === 'color' && bg.color) {
@@ -584,7 +593,7 @@ async function applyBg() {
root.style.setProperty('--bg-color', safeColor);
root.style.setProperty('--bg-brightness', '1');
root.style.setProperty('--bg-size', 'cover');
- bgTone = { grid: null, tone: toneForColor(safeColor) };
+ bgTone = { grid: null, tone: toneForColor(safeColor) ?? themeTone() };
retone();
} else if (bg.type === 'url' && bg.url) {
const url = sanitizeCssUrl(bg.url);
@@ -946,6 +955,20 @@ async function boot() {
applyBg();
+ const onTheme = () => {
+ applyTheme(resolveTheme(readMode(), prefersDark()));
+ applyBg();
+ buildLayout();
+ };
+ try {
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ if (readMode() === 'system') onTheme();
+ });
+ } catch {}
+ window.addEventListener('storage', e => {
+ if (e.key === THEME_KEY) onTheme();
+ });
+
onLayoutChange(mobile => {
MOB = mobile;
buildLayout();
diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js
index f1e5e99..fb87f57 100644
--- a/public/js/import-foreign.js
+++ b/public/js/import-foreign.js
@@ -3,7 +3,7 @@
only: a widget in the source becomes a plain app tile, never a Stackyard
widget. Keep this module free of the DOM and of the network. */
-import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=4f71ef6c';
+import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=858f3f84';
import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f';
export const SKIP = Object.freeze({
diff --git a/public/js/listbox.js b/public/js/listbox.js
index aeaa4a8..91cf92f 100644
--- a/public/js/listbox.js
+++ b/public/js/listbox.js
@@ -9,7 +9,7 @@
import { nextActiveIndex } from '/js/admin-logic.js?v=e3673bd7';
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=045df327';
+import { qa } from '/js/utils.js?v=55685187';
import { iconSvg } from '/js/icon-set.js?v=606a68c6';
const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev');
diff --git a/public/js/palette.js b/public/js/palette.js
new file mode 100644
index 0000000..4fdd013
--- /dev/null
+++ b/public/js/palette.js
@@ -0,0 +1,96 @@
+// @ts-check
+/* Mirrors tokens.css. ui/test/palette.test.mjs fails on drift. */
+
+/** @type {readonly string[]} */
+export const HUE_NAMES = [
+ 'red',
+ 'orange',
+ 'yellow',
+ 'green',
+ 'mint',
+ 'teal',
+ 'cyan',
+ 'blue',
+ 'indigo',
+ 'purple',
+ 'pink',
+ 'brown',
+ 'gray',
+];
+
+/** @type {readonly string[]} */
+export const ROLE_NAMES = ['accent', 'success', 'warning', 'danger', 'info'];
+
+/** @type {readonly string[]} */
+export const TILE_KEYWORDS = ['dark', 'light', 'auto', 'clear'];
+
+/* Prototype-free. A name like "constructor" otherwise resolves to a function
+ and reaches the DOM as a colour. */
+const _tables = t => {
+ for (const k of Object.keys(t)) t[k] = Object.assign(Object.create(null), t[k]);
+ return t;
+};
+
+const HUES = _tables({
+ dark: {
+ red: '#FF4245',
+ orange: '#FF9230',
+ yellow: '#FFD600',
+ green: '#30D158',
+ mint: '#00DAC3',
+ teal: '#00D2E0',
+ cyan: '#3CD3FE',
+ blue: '#0091FF',
+ indigo: '#6D7CFF',
+ purple: '#DB34F2',
+ pink: '#FF375F',
+ brown: '#B78A66',
+ gray: '#8E8E93',
+ },
+ light: {
+ red: '#FF383C',
+ orange: '#FF8D28',
+ yellow: '#FFCC00',
+ green: '#34C759',
+ mint: '#00C8B3',
+ teal: '#00C3D0',
+ cyan: '#00C0E8',
+ blue: '#0088FF',
+ indigo: '#6155F5',
+ purple: '#CB30E0',
+ pink: '#FF2D55',
+ brown: '#AC7F5E',
+ gray: '#8E8E93',
+ },
+});
+
+const ROLES = _tables({
+ dark: { accent: '#00D2E0', success: '#30D158', warning: '#FF9230', danger: '#FF4245', info: '#0091FF' },
+ light: { accent: '#0071A4', success: '#238539', warning: '#C93400', danger: '#D70015', info: '#0040DD' },
+});
+
+const TILE = _tables({
+ dark: { dark: '#1C1C1E', light: '#F2F2F7', auto: '#1C1C1E', clear: 'rgba(120,120,128,.36)' },
+ light: { dark: '#1C1C1E', light: '#F2F2F7', auto: '#F2F2F7', clear: 'rgba(255,255,255,.45)' },
+});
+
+/** @param {unknown} theme */
+const pick = theme => (theme === 'light' ? 'light' : 'dark');
+
+/** @param {unknown} name @param {unknown} theme @returns {string} */
+export function paletteColor(name, theme) {
+ const t = pick(theme);
+ if (typeof name !== 'string') return '';
+ return HUES[t][name] || ROLES[t][name] || '';
+}
+
+/** @param {unknown} name @param {unknown} theme @returns {string} */
+export function tileColor(name, theme) {
+ if (typeof name !== 'string') return '';
+ return TILE[pick(theme)][name] || paletteColor(name, theme);
+}
+
+/** @param {{ documentElement?: { getAttribute(n: string): string|null } }} [doc] */
+export function pageTheme(doc = globalThis.document) {
+ return doc?.documentElement?.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
+}
diff --git a/public/js/settings-icon.js b/public/js/settings-icon.js
index f5c5d11..85db304 100644
--- a/public/js/settings-icon.js
+++ b/public/js/settings-icon.js
@@ -4,3 +4,6 @@
export const SETTINGS_ICON =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMiIgeT0iMyIgd2lkdGg9IjIwIiBoZWlnaHQ9IjgiIHJ4PSI0IiBmaWxsPSIjMDBEMkUwIi8+PHJlY3QgeD0iMTAiIHk9IjUiIHdpZHRoPSIxMCIgaGVpZ2h0PSI0IiByeD0iMiIgZmlsbD0iI0YyRjJGNyIvPjxyZWN0IHg9IjIiIHk9IjEzIiB3aWR0aD0iMjAiIGhlaWdodD0iOCIgcng9IjQiIGZpbGw9IiNGMkYyRjciIGZpbGwtb3BhY2l0eT0iLjQiLz48cmVjdCB4PSI0IiB5PSIxNSIgd2lkdGg9IjEwIiBoZWlnaHQ9IjQiIHJ4PSIyIiBmaWxsPSIjRjJGMkY3Ii8+PC9zdmc+';
+
+export const SETTINGS_ICON_LIGHT =
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMiIgeT0iMyIgd2lkdGg9IjIwIiBoZWlnaHQ9IjgiIHJ4PSI0IiBmaWxsPSIjMDBDM0QwIi8+PHJlY3QgeD0iMTAiIHk9IjUiIHdpZHRoPSIxMCIgaGVpZ2h0PSI0IiByeD0iMiIgZmlsbD0iI0ZGRkZGRiIvPjxyZWN0IHg9IjIiIHk9IjEzIiB3aWR0aD0iMjAiIGhlaWdodD0iOCIgcng9IjQiIGZpbGw9IiM3ODc4ODAiIGZpbGwtb3BhY2l0eT0iLjI0Ii8+PHJlY3QgeD0iNCIgeT0iMTUiIHdpZHRoPSIxMCIgaGVpZ2h0PSI0IiByeD0iMiIgZmlsbD0iI0ZGRkZGRiIvPjwvc3ZnPg==';
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index 1e8fdd5..99ae680 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=045df327';
+import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=55685187';
import { t } from '/js/i18n.js?v=1f1ea9c1';
/* Attached to the window so a re-open can undo the previous one. */
diff --git a/public/js/ui.js b/public/js/ui.js
index 6993bd7..4f897d2 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -1,5 +1,5 @@
import { iconChain } from '/js/icons.js?v=9c8c550c';
-import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=d36b0153';
+import { widgetSrc, cardPreset, fixedAppearance, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=9264dee5';
import {
mk,
clr,
@@ -13,7 +13,7 @@ import {
q,
qa,
setUserText,
-} from '/js/utils.js?v=045df327';
+} from '/js/utils.js?v=55685187';
import { t, currentLang } from '/js/i18n.js?v=1f1ea9c1';
import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=ab5fe77e';
@@ -736,6 +736,7 @@ export function buildMobile() {
if (wtype) card.dataset.wtype = wtype;
const preset = cardPreset(item, _state.widgetReg);
if (preset) card.dataset.card = preset;
+ if (fixedAppearance(item, _state.widgetReg)) card.dataset.appearance = 'dark';
/* Same aspect as desktop, so the widget renders identically. */
card.style.cssText =
`aspect-ratio:${design[0]}/${design[1]};width:100%;max-width:100%;max-height:100%;` +
diff --git a/public/js/utils.js b/public/js/utils.js
index 16569a8..9efdbc9 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -1,8 +1,11 @@
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=fe53a1db';
+import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a';
+import { mkGlassRim } from '/js/glass-rim.js?v=3faec233';
+import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1';
import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac';
import { t } from '/js/i18n.js?v=1f1ea9c1';
+import { pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43';
export const mk = (t, a = {}) => {
const e = document.createElement(t);
@@ -15,8 +18,9 @@ export const mk = (t, a = {}) => {
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([0-9a-z%.,\s/+-]*\)|[a-z]{3,20})$/i;
const DEFAULT_TILE_COLOR = '#1C1C1E';
export const clr = c => {
- if (!c || c === 'dark') return DEFAULT_TILE_COLOR;
- if (c === 'light') return '#F2F2F7';
+ if (!c) return DEFAULT_TILE_COLOR;
+ const named = tileColor(c, pageTheme());
+ if (named) return named;
const v = String(c).trim();
return SAFE_COLOR.test(v) ? v : DEFAULT_TILE_COLOR;
};
@@ -25,7 +29,8 @@ export const clr = c => {
export const fb = (l, sz, plate) => {
const e = mk('span');
e.className = 'fb';
- if (toneForColor(plate) === 'dark') e.classList.add('fb-on-light');
+ const tone = toneForColor(plate) ?? (pageTheme() === 'light' ? 'dark' : 'light');
+ if (tone === 'dark') e.classList.add('fb-on-light');
e.style.fontSize = Math.round(sz * 0.32) + 'px';
e.textContent = (l || '?')[0].toUpperCase();
return e;
@@ -112,12 +117,21 @@ export const tgt = e => /** @type {HTMLInputElement} */ (e.target);
export function mkWrap(item, sz, r, isz, cls, breg) {
const w = mk('div');
w.className = cls ? `plate ${cls}` : 'plate';
- const wrapBg = item.system === 'settings' ? '#0D1117' : clr(item.color);
- w.style.cssText = `--pw:${sz}px;--tc:${wrapBg};width:${sz}px;height:${sz}px;border-radius:${r}px;background-color:${wrapBg};position:relative;flex-shrink:0;overflow:visible;display:flex;align-items:center;justify-content:center;`;
+ const wrapBg = clr(item.system === 'settings' ? 'auto' : item.color);
+ const clear = item.color === 'clear' && item.system !== 'settings';
+ w.style.cssText = `--pw:${sz}px;--tc:${wrapBg};width:${sz}px;height:${sz}px;border-radius:${r}px;${clear ? '' : `background-color:${wrapBg};`}position:relative;flex-shrink:0;overflow:visible;display:flex;align-items:center;justify-content:center;`;
+ if (clear) {
+ w.classList.add('plate-clear', 'glass-surface');
+ w.appendChild(mkGlassRim(sz, sz, smoothRectPath(sz, sz, r, 0)));
+ }
const rawIcon = item.iconUrl || '';
if (item.system === 'settings') {
const si = Math.min(sz, Math.round(isz * 1.22));
- const img = mk('img', { src: SETTINGS_ICON, alt: '', draggable: false });
+ const img = mk('img', {
+ src: pageTheme() === 'light' ? SETTINGS_ICON_LIGHT : SETTINGS_ICON,
+ alt: '',
+ draggable: false,
+ });
img.setAttribute('aria-hidden', 'true');
img.style.cssText = `width:${si}px;height:${si}px;object-fit:contain;position:relative;z-index:3;`;
img.onerror = () => img.replaceWith(fb(item.label, sz, wrapBg));
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index 75c0f15..f5f6b5b 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -3,9 +3,9 @@
import { t } from '/js/i18n.js?v=1f1ea9c1';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=dc0e02b6';
-import { createListbox } from '/js/listbox.js?v=96066369';
-import { renderColorControl } from '/js/admin-color-control.js?v=a48fbc58';
+import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=52e149f5';
+import { createListbox } from '/js/listbox.js?v=32f787c3';
+import { renderColorControl } from '/js/admin-color-control.js?v=42c11a9b';
import {
seedCarried,
applyOptionSet,
@@ -15,7 +15,7 @@ import {
visibleFieldFlags,
} from '/js/admin-logic.js?v=e3673bd7';
import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
-import { qi } from '/js/utils.js?v=045df327';
+import { qi } from '/js/utils.js?v=55685187';
const PE =
'';
diff --git a/public/js/widget-error.js b/public/js/widget-error.js
index a2d8ec2..cbe7a68 100644
--- a/public/js/widget-error.js
+++ b/public/js/widget-error.js
@@ -107,6 +107,7 @@ const CSS = `
/* This rule sets display, which outranks the user agent's [hidden] rule. Without
its own hidden rule the caption never goes away. */
.wt-cap[hidden] { display: none; }
+html[data-theme="light"] .wt-cap { color: var(--wt-cap-color, rgba(60,60,67,0.7)); }
.wt-cap svg { width: 12px; height: 12px; flex: 0 0 auto; opacity: 0.85; }
.wt-cap b { font-weight: 500; overflow: hidden; min-width: 0;
/* Two lines, then clip. The narrowest card cannot hold the longest line on
diff --git a/public/js/widget-theme.js b/public/js/widget-theme.js
new file mode 100644
index 0000000..295cd42
--- /dev/null
+++ b/public/js/widget-theme.js
@@ -0,0 +1,16 @@
+/* Load as a blocking classic script in . A module runs after the page has
+ painted, and the theme then arrives as a flash of the wrong one. */
+(function () {
+ try {
+ const frame = window.frameElement;
+ const dark =
+ (frame && frame.closest('[data-appearance="dark"]')) ||
+ window.parent.document.documentElement.getAttribute('data-theme') !== 'light';
+ const root = document.documentElement;
+ root.setAttribute('data-theme', dark ? 'dark' : 'light');
+ root.style.colorScheme = dark ? 'dark' : 'light';
+ /* A page with no background of its own shows the card through it. */
+ if (getComputedStyle(root).backgroundColor === 'rgba(0, 0, 0, 0)')
+ root.style.backgroundColor = dark ? '#1C1C1E' : '#FFFFFF';
+ } catch {}
+})();
diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js
index e0314c6..64ab9d2 100644
--- a/public/js/widget-toolbox.js
+++ b/public/js/widget-toolbox.js
@@ -4,7 +4,7 @@
import { esc, html, setHtml } from '/js/html.js?v=c71f8903';
import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f';
import { jitter } from '/js/jitter.js?v=4eeef4c9';
-import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=962e496a';
+import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=7da9754b';
export { esc, html, setHtml };
@@ -16,6 +16,27 @@ export function safeColor(value, fallback) {
return COLOR_RE.test(String(value ?? '').trim()) ? String(value).trim() : fallback;
}
+/* widget-theme.js sets the attribute before the first paint. */
+const _hostTheme = () => (document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark');
+
+const _lin = v => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
+/** @param {number[]} c */
+const _lum = c => 0.2126 * _lin(c[0] / 255) + 0.7152 * _lin(c[1] / 255) + 0.0722 * _lin(c[2] / 255);
+
+/** Darkens a colour only as far as 4.5:1 on white needs. Returns it unchanged on the dark theme.
+ @param {string} hex #rrggbb @param {number} [min] */
+export function readableInk(hex, min = 4.5) {
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
+ if (!m || _hostTheme() !== 'light') return hex;
+ let rgb = [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16));
+ for (let k = 0; k < 40 && 1.05 / (_lum(rgb) + 0.05) < min; k++) rgb = rgb.map(v => v * 0.95);
+ return '#' + rgb.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
+}
+
+export function theme() {
+ return _hostTheme();
+}
+
const NS = 'http://www.w3.org/2000/svg';
const _params = new URLSearchParams(location.search);
diff --git a/public/js/widget-types.js b/public/js/widget-types.js
index 6b64427..bd66c0e 100644
--- a/public/js/widget-types.js
+++ b/public/js/widget-types.js
@@ -17,7 +17,7 @@ export const WIDGET_ROWS = {
/* The iframe URL, from the manifest entry in `reg`. The cache version is hashed
from file content at release, never maintained by hand. */
/* A `card` inside the selected view wins over the manifest's top-level one. */
-const CARD_PRESETS = ['dark', 'light', 'translucent'];
+const CARD_PRESETS = ['dark', 'graphite', 'light', 'translucent'];
export function cardPreset(item, reg) {
const entry = item?.widgetType ? reg?.[item.widgetType] : null;
@@ -33,6 +33,11 @@ export function cardPreset(item, reg) {
return CARD_PRESETS.includes(card) ? card : '';
}
+export function fixedAppearance(item, reg) {
+ const entry = item?.widgetType ? reg?.[item.widgetType] : null;
+ return entry?.appearance === 'dark' ? 'dark' : '';
+}
+
export function widgetSrc(item, reg, opts) {
const type = item?.widgetType;
const entry = type ? reg?.[type] : null;
diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html
index d913680..8f7b7d5 100644
--- a/public/widgets/backup/backup.html
+++ b/public/widgets/backup/backup.html
@@ -6,6 +6,7 @@
Backup
+
@@ -96,7 +101,7 @@