diff --git a/public/img/admin/app-edit-labels-af3ec51a.png b/public/img/admin/app-edit-labels-af3ec51a.png new file mode 100644 index 0000000..e944963 Binary files /dev/null and b/public/img/admin/app-edit-labels-af3ec51a.png differ diff --git a/public/img/admin/app-edit-labels-b87badcc.png b/public/img/admin/app-edit-labels-b87badcc.png deleted file mode 100644 index 354827a..0000000 Binary files a/public/img/admin/app-edit-labels-b87badcc.png and /dev/null differ diff --git a/public/img/admin/app-edit-labels-dark-2ff5328b.png b/public/img/admin/app-edit-labels-dark-2ff5328b.png new file mode 100644 index 0000000..12bc4a2 Binary files /dev/null and b/public/img/admin/app-edit-labels-dark-2ff5328b.png differ diff --git a/public/img/admin/app-edit-labels-dark-7cf4b4cd.png b/public/img/admin/app-edit-labels-dark-7cf4b4cd.png deleted file mode 100644 index ae1ada7..0000000 Binary files a/public/img/admin/app-edit-labels-dark-7cf4b4cd.png and /dev/null differ diff --git a/public/img/admin/appearance-14c98840.png b/public/img/admin/appearance-14c98840.png deleted file mode 100644 index 399d123..0000000 Binary files a/public/img/admin/appearance-14c98840.png and /dev/null differ diff --git a/public/img/admin/appearance-dark-449811e9.png b/public/img/admin/appearance-dark-449811e9.png new file mode 100644 index 0000000..75c205b Binary files /dev/null and b/public/img/admin/appearance-dark-449811e9.png differ diff --git a/public/img/admin/appearance-dark-ff2e7225.png b/public/img/admin/appearance-dark-ff2e7225.png deleted file mode 100644 index f29b46a..0000000 Binary files a/public/img/admin/appearance-dark-ff2e7225.png and /dev/null differ diff --git a/public/img/admin/appearance-e9d99c15.png b/public/img/admin/appearance-e9d99c15.png new file mode 100644 index 0000000..280d1af Binary files /dev/null and b/public/img/admin/appearance-e9d99c15.png differ diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 8bab610..43cd9ef 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,7 +1,7 @@ -import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=045df327'; +import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=55685187'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c'; -import { state } from '/js/admin-state.js?v=5a5d655f'; +import { state } from '/js/admin-state.js?v=831e219e'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { isDockBlocked, @@ -21,10 +21,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=dc0e02b6'; -import { createListbox } from '/js/listbox.js?v=96066369'; -import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2'; -import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=a48fbc58'; +} from '/js/admin-shared.js?v=52e149f5'; +import { createListbox } from '/js/listbox.js?v=32f787c3'; +import { MAX_LABELS } from '/js/badge-logic.js?v=ad283693'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=42c11a9b'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=606a68c6'; @@ -210,9 +210,9 @@ export function buildAppForm(body, item) { const pv0 = el('ipv'); if (pv0) pv0.style.background = rc(state.scol); renderColorControl(el('icon-color-slot'), { - value: state.scol || 'dark', + value: state.scol || 'auto', idPrefix: 'icon-col', - semantic: true, + variant: 'tile', label: t('common.color'), onChange(v) { state.scol = v; @@ -223,7 +223,7 @@ export function buildAppForm(body, item) { renderColorControl(el('static-color-slot'), { value: staticBadge.color || BADGE_DEFAULT, idPrefix: 'static-col', - swatchColors: BADGE_SWATCHES, + variant: 'badge', label: t('common.color'), }); state._bpar = normKvRows(act.params); @@ -409,7 +409,7 @@ function renderActLabels(host) { renderColorControl(el(`albl-col-slot-${i}`), { value: l.color || BADGE_DEFAULT, idPrefix: `albl-col-${i}`, - swatchColors: BADGE_SWATCHES, + variant: 'badge', label: t('common.color'), }); const pathRow = el(`albl-path-row-${i}`); diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 214d378..1697b38 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=dc0e02b6'; +import { ag, ap } from '/js/admin-shared.js?v=52e149f5'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=045df327'; +import { el, inp as inpById, qa } from '/js/utils.js?v=55685187'; export async function checkAuth(onLogin) { try { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index d21159b..690d436 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,12 +1,12 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=dc0e02b6'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=52e149f5'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=045df327'; +import { qa, q } from '/js/utils.js?v=55685187'; import { iconSvg } from '/js/icon-set.js?v=606a68c6'; +import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; -export const BADGE_DEFAULT = '#1e6ef4'; -export const BADGE_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#1e6ef4', '#cb30df']; +export const BADGE_DEFAULT = 'info'; const _ccIco = { hueLo: iconSvg('hue-lo', 16), hueHi: iconSvg('hue-hi', 16), @@ -111,32 +111,64 @@ function _hexToHsv(hex) { export const _internals = { cssToHex: _cssToHex, hsvToRgb: _hsvToRgb, hexToHsv: _hexToHsv }; +const COLOR_KEYS = { + dark: 'color.dark', + light: 'color.light', + auto: 'color.auto', + clear: 'color.clear', + accent: 'color.accent', + success: 'color.success', + warning: 'color.warning', + danger: 'color.danger', + info: 'color.info', + red: 'color.red', + orange: 'color.orange', + yellow: 'color.yellow', + green: 'color.green', + mint: 'color.mint', + teal: 'color.teal', + cyan: 'color.cyan', + blue: 'color.blue', + indigo: 'color.indigo', + purple: 'color.purple', + pink: 'color.pink', + brown: 'color.brown', + gray: 'color.gray', +}; + +/** @param {string} v */ +const _swatchFill = v => tileColor(v, pageTheme()); + /** @param {HTMLElement} container @param {{ value?: string, idPrefix?: string, - onChange?: (value: string) => void, semantic?: boolean, + onChange?: (value: string) => void, variant?: 'tile'|'badge', swatchColors?: string[], label?: string }} [opts] */ export function renderColorControl( container, - { value = '#0289ff', idPrefix, onChange, semantic = false, swatchColors = CC_SWATCHES, label = 'Color' } = {}, + { value = '#0289ff', idPrefix, onChange, variant, swatchColors = CC_SWATCHES, label = 'Color' } = {}, ) { - const isSem = v => v === 'dark' || v === 'light'; - const init = _hexToHsv(isSem(value) ? '#0289ff' : value) || { h: 212, s: 99, v: 100 }; + const top = variant === 'tile' ? TILE_KEYWORDS : variant === 'badge' ? ROLE_NAMES : []; + const hues = variant ? HUE_NAMES : []; + const isKw = v => typeof v === 'string' && (top.includes(v) || hues.includes(v)); + const kwName = v => t(COLOR_KEYS[v]); + const init = _hexToHsv(isKw(value) ? _swatchFill(value) || '#0289ff' : value) || { h: 212, s: 99, v: 100 }; + const kwSwatch = v => + html``; const swatch = h => html``; - const swatches = semantic - ? html` - - - ${['#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'].map(swatch)}` - : html` - ${swatchColors.map(swatch)}`; + const rainbow = html``; + const swatches = variant ? html`${top.map(kwSwatch)}${rainbow}` : html`${rainbow}${swatchColors.map(swatch)}`; const wrap = document.createElement('div'); const slider = (label, cls, id, max, val, lo, hi) => html`
${label}
${raw(lo)}${raw(hi)}
`; + const hueRow = hues.length + ? html`
${t('color.system')}
${hues.map(kwSwatch)}
` + : ''; setHtml( wrap, html`
${label}
${swatches}
+ ${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 @@
@@ -48,7 +52,7 @@
@@ -91,7 +103,7 @@ the sparklines' time axis right to left. -->