diff --git a/public/img/admin/app-edit-badge-8cf6ea34.png b/public/img/admin/app-edit-badge-8cf6ea34.png
deleted file mode 100644
index 1583dbb..0000000
Binary files a/public/img/admin/app-edit-badge-8cf6ea34.png and /dev/null differ
diff --git a/public/img/admin/app-edit-badge-b0148914.png b/public/img/admin/app-edit-badge-b0148914.png
new file mode 100644
index 0000000..8fb7f8d
Binary files /dev/null and b/public/img/admin/app-edit-badge-b0148914.png differ
diff --git a/public/img/admin/app-edit-badge-dark-48e1e5f8.png b/public/img/admin/app-edit-badge-dark-48e1e5f8.png
deleted file mode 100644
index 4879446..0000000
Binary files a/public/img/admin/app-edit-badge-dark-48e1e5f8.png and /dev/null differ
diff --git a/public/img/admin/app-edit-badge-dark-d272ba44.png b/public/img/admin/app-edit-badge-dark-d272ba44.png
new file mode 100644
index 0000000..55c9136
Binary files /dev/null and b/public/img/admin/app-edit-badge-dark-d272ba44.png differ
diff --git a/public/img/admin/app-edit-labels-27443940.png b/public/img/admin/app-edit-labels-27443940.png
deleted file mode 100644
index 1562f18..0000000
Binary files a/public/img/admin/app-edit-labels-27443940.png and /dev/null differ
diff --git a/public/img/admin/app-edit-labels-3cce9d48.png b/public/img/admin/app-edit-labels-3cce9d48.png
new file mode 100644
index 0000000..a3abccd
Binary files /dev/null and b/public/img/admin/app-edit-labels-3cce9d48.png differ
diff --git a/public/img/admin/app-edit-labels-dark-4998668a.png b/public/img/admin/app-edit-labels-dark-4998668a.png
deleted file mode 100644
index 1d7f3bb..0000000
Binary files a/public/img/admin/app-edit-labels-dark-4998668a.png and /dev/null differ
diff --git a/public/img/admin/app-edit-labels-dark-7729b939.png b/public/img/admin/app-edit-labels-dark-7729b939.png
new file mode 100644
index 0000000..e6114f8
Binary files /dev/null and b/public/img/admin/app-edit-labels-dark-7729b939.png differ
diff --git a/public/img/admin/appearance-d7bb5b62.png b/public/img/admin/appearance-d7bb5b62.png
deleted file mode 100644
index db82cb2..0000000
Binary files a/public/img/admin/appearance-d7bb5b62.png and /dev/null differ
diff --git a/public/img/admin/appearance-da7cfb3d.png b/public/img/admin/appearance-da7cfb3d.png
new file mode 100644
index 0000000..ba004aa
Binary files /dev/null and b/public/img/admin/appearance-da7cfb3d.png differ
diff --git a/public/img/admin/appearance-dark-d382dd9a.png b/public/img/admin/appearance-dark-d382dd9a.png
new file mode 100644
index 0000000..8b0c74c
Binary files /dev/null and b/public/img/admin/appearance-dark-d382dd9a.png differ
diff --git a/public/img/admin/appearance-dark-e18f071b.png b/public/img/admin/appearance-dark-e18f071b.png
deleted file mode 100644
index a056259..0000000
Binary files a/public/img/admin/appearance-dark-e18f071b.png and /dev/null differ
diff --git a/public/img/dashboard-c1303e4c.jpg b/public/img/dashboard-c1303e4c.jpg
deleted file mode 100644
index 152c0c7..0000000
Binary files a/public/img/dashboard-c1303e4c.jpg and /dev/null differ
diff --git a/public/img/dashboard-c6746175.jpg b/public/img/dashboard-c6746175.jpg
new file mode 100644
index 0000000..fe3fdc3
Binary files /dev/null and b/public/img/dashboard-c6746175.jpg differ
diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js
index dac5ad6..ff74f44 100644
--- a/public/js/admin-app-form.js
+++ b/public/js/admin-app-form.js
@@ -22,7 +22,7 @@ import {
setTogDisabled,
swapContent,
} from '/js/admin-shared.js?v=fd784739';
-import { createListbox } from '/js/listbox.js?v=a67e9c98';
+import { createListbox } from '/js/listbox.js?v=bedd1104';
import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2';
import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=837961fa';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index 17d0f06..be890c2 100644
--- a/public/js/admin-list.js
+++ b/public/js/admin-list.js
@@ -19,7 +19,7 @@ import { paintIcon } from '/js/admin-shared.js?v=fd784739';
import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=70cbc405';
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=3b1b3546';
+import { sizeLabel } from '/js/admin-widget-form.js?v=3e1f15dd';
import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947';
/** @type {{ openModal: (idx: number|null) => void,
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index 12634d2..23bd7c3 100644
--- a/public/js/admin-settings.js
+++ b/public/js/admin-settings.js
@@ -36,8 +36,6 @@ const _shown = id => {
out. */
const readServerForm = () =>
JSON.stringify([
- _shown('ie-title-v'),
- _shown('ie-desc-v'),
_val('srv-ip'),
_val('srv-docker-en'),
_val('srv-socket'),
@@ -185,12 +183,6 @@ export function loadSettings(c) {
node.classList.add('is-ph');
}
};
- _sv('ie-title-v', s.title || 'Stackyard', 'Stackyard');
- _sv(
- 'ie-desc-v',
- s.description || 'Stackyard · self-hosted homelab dashboard',
- 'Stackyard · self-hosted homelab dashboard',
- );
_sv('ie-ip-v', s.server?.hostIp, '192.168.1.100');
_sv('ie-socket-v', s.server?.socketProxyUrl, 'http://socket-proxy:2375');
_sv('ie-pw-v', '', t('common.notSet')); /* set below after auth check */
@@ -439,13 +431,6 @@ async function saveServer() {
const prevLang = c.settings.language || 'en';
const dockerEnabled = inp('srv-docker-en')?.checked || false;
const socketUrl = inp('srv-socket')?.value?.trim() || '';
- /* A greyed placeholder (.is-ph) means empty, so it is not saved. */
- const titleEl = el('ie-title-v');
- const descEl = el('ie-desc-v');
- const titleV = titleEl && !titleEl.classList.contains('is-ph') ? titleEl.textContent.trim() : '';
- const descV = descEl && !descEl.classList.contains('is-ph') ? descEl.textContent.trim() : '';
- if (titleV) c.settings.title = titleV;
- if (descV) c.settings.description = descV;
c.settings.server = {
...c.settings.server,
hostIp: inp('srv-ip')?.value?.trim() || '',
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 91e2b4e..a056cc9 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,7 +1,7 @@
import { state } from '/js/admin-state.js?v=5a5d655f';
import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=fd784739';
-import { createListbox } from '/js/listbox.js?v=a67e9c98';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=ad9e05ee';
+import { createListbox } from '/js/listbox.js?v=bedd1104';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=40e30a39';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=69e57d35';
import { t } from '/js/i18n.js?v=1f1ea9c1';
@@ -55,7 +55,8 @@ function _renderWidgetForm(body) {
state._wAutoCfg = Object.assign({}, state._wAutoCfg, state._autoForm.getValues());
}
state._autoForm = null;
- body.replaceChildren();
+ const addNew = body.querySelector(':scope > .add-new-card');
+ body.replaceChildren(...(addNew ? [addNew] : []));
const typeList = [...Object.values(state._widgetReg).map(w => [w.name, w.label]), ['custom', 'Custom']].sort((a, b) =>
a[1].localeCompare(b[1]),
@@ -107,7 +108,7 @@ function _renderWidgetForm(body) {
scard.className = 'grp';
setHtml(
scard,
- html`
${_sizeOpts.map(s => html``)}
`,
+ html`${_sizeOpts.map(s => html``)}
`,
);
body.appendChild(scard);
qa('.tile-opt', scard).forEach(b =>
@@ -237,7 +238,6 @@ function _renderCustomConfig(body) {
const ri = parseInt(qi('#if-refresh', adv).value, 10);
state._iframeOpts.refreshInterval = ri && ri >= 250 ? ri : undefined;
};
- q('#if-referrer', adv).onchange = sync;
q('#if-fs', adv).onchange = sync;
initInlineEdit('if-allow-row', 'if-allow', {
placeholder: 'autoplay; fullscreen',
diff --git a/public/js/admin.js b/public/js/admin.js
index 42c4d87..1de6f87 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=a53ac0e8';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=47949906';
import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=c70fb722';
-import { initList, render, syncFilterUI } from '/js/admin-list.js?v=b0923a32';
+import { initList, render, syncFilterUI } from '/js/admin-list.js?v=94b420e9';
import { resolveAdminSection } from '/js/admin-logic.js?v=69e57d35';
import {
buildAppItem,
@@ -10,13 +10,13 @@ import {
snapshotItems,
upsertItem,
} from '/js/admin-save-logic.js?v=4f71ef6c';
-import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=079a3d2d';
+import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=2d1a8a29';
import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=fd784739';
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=3b1b3546';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=3e1f15dd';
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=a67e9c98';
+import { createListbox } from '/js/listbox.js?v=bedd1104';
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';
@@ -202,10 +202,9 @@ const typeLabels = () => ({ app: t('type.app'), widget: t('type.widget'), folder
function buildAddNewCard() {
const grp = document.createElement('div');
- grp.className = 'grp';
+ grp.className = 'grp add-new-card';
const row = document.createElement('div');
row.className = 'row tile-row';
- setHtml(row, html`${t('type.addNew')}`);
const grpTiles = document.createElement('div');
grpTiles.className = 'tile-grp';
const labels = typeLabels();
@@ -634,18 +633,6 @@ function initNav() {
}
function initAllInlineEdits() {
- initInlineEdit('ie-title', 'ie-input', {
- placeholder: 'Stackyard',
- onCommit(v) {
- el('ie-title-v').textContent = v || 'Stackyard';
- },
- });
-
- const descInp = document.createElement('input');
- descInp.id = 'ie-desc-input';
- document.body.appendChild(descInp);
- initInlineEdit('ie-desc', 'ie-desc-input', { placeholder: 'Stackyard · self-hosted homelab dashboard' });
-
initInlineEdit('ie-ip', 'srv-ip', { placeholder: '192.168.1.100' });
initInlineEdit('ie-socket', 'srv-socket', { placeholder: 'http://socket-proxy:2375' });
diff --git a/public/js/dashboard-logic.js b/public/js/dashboard-logic.js
index 6677485..f6b7474 100644
--- a/public/js/dashboard-logic.js
+++ b/public/js/dashboard-logic.js
@@ -32,6 +32,62 @@ export function desktopCols(avail) {
return Math.max(DESKTOP_COL.min, Math.min(DESKTOP_COL.max, fit));
}
+/** Must match the sparse auto-placement of `.grid`, or a tile lands below the page.
+
+ @template T
+ @param {T[]} tiles in display order
+ @param {(tile: T) => [number, number]} span columns and rows a tile takes
+ @param {number} cols @param {number} rows
+ @returns {T[][]} */
+export function desktopPages(tiles, span, cols, rows) {
+ /** @type {T[][]} */
+ const pages = [];
+ /** @type {T[]} */
+ let cur = [];
+ /** @type {boolean[][]} */
+ let taken = [];
+ let r = 0,
+ c = 0;
+ const free = (
+ /** @type {number} */ row,
+ /** @type {number} */ col,
+ /** @type {number} */ w,
+ /** @type {number} */ h,
+ ) => {
+ for (let y = row; y < row + h; y++) for (let x = col; x < col + w; x++) if (taken[y]?.[x]) return false;
+ return true;
+ };
+ for (const tile of tiles) {
+ const [sw, sh] = span(tile);
+ const w = Math.min(Math.max(1, sw), cols);
+ const h = Math.max(1, sh);
+ const place = () => {
+ for (;;) {
+ for (; c + w <= cols; c++) if (free(r, c, w, h)) return r;
+ r++;
+ c = 0;
+ }
+ };
+ let row = place();
+ if (row + h > rows && cur.length) {
+ pages.push(cur);
+ cur = [];
+ taken = [];
+ r = 0;
+ c = 0;
+ row = place();
+ }
+ for (let y = row; y < row + h; y++) {
+ taken[y] ??= [];
+ for (let x = c; x < c + w; x++) taken[y][x] = true;
+ }
+ c += w;
+ cur.push(tile);
+ }
+ if (cur.length) pages.push(cur);
+ return pages;
+}
+
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 7fe5f6a..68a4d9f 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -4,11 +4,10 @@ import {
WIDGET_DESIGN,
WIDGET_COLS,
WIDGET_ROWS,
- WIDGET_COST,
widgetSrc,
cardPreset,
uniqueTitle,
-} from '/js/widget-types.js?v=a1b61636';
+} from '/js/widget-types.js?v=d36b0153';
import {
el,
isDashboardEmpty,
@@ -39,11 +38,18 @@ import {
buildMobile,
resetMobileChrome,
mkFolderGlyph,
-} from '/js/ui.js?v=8c5a8dba';
+} from '/js/ui.js?v=b530cbb6';
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, desktopCols, landingAfterSetup, restorePage } from '/js/dashboard-logic.js?v=a8f759ed';
+import { observeGlass } from '/js/glass-rim.js?v=3faec233';
+import {
+ configChanged,
+ desktopCols,
+ desktopPages,
+ landingAfterSetup,
+ restorePage,
+} from '/js/dashboard-logic.js?v=0d519f8b';
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=e9f4b607';
@@ -63,7 +69,6 @@ const ICON_R = 0.2237;
/* A widget tile's corner, at design size. WIDGET_DESIGN's small is 170 square
against the reference's 165, so this is its 28 unchanged. */
const WIDGET_R = 28;
-const wCost = { d: WIDGET_COST.desktop, m: WIDGET_COST.mobile };
/* 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. */
@@ -98,12 +103,11 @@ let gm = { tile: DESIGN_TILE, rowGap: DESIGN_ROW_GAP, cols: 6 };
which sit above the dock.
@param {boolean} hasDock */
-function desktopSlots(hasDock) {
+function desktopRows(hasDock) {
const ih = innerHeight;
const top = Math.min(70, Math.max(44, ih * 0.04));
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 gm.cols * rows;
+ return Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap))));
}
const CB = { spotOpen: null, spotClose: null, mobPillBump: null };
@@ -282,27 +286,18 @@ function paginate() {
.flatMap(f => f.children || [])
.map(String),
);
- const budget = desktopSlots(items.some(i => i.type === 'app' && i.dock && !i.hidden));
+ const rows = desktopRows(items.some(i => i.type === 'app' && i.dock && !i.hidden));
const bare = isDashboardEmpty(items);
- const pages = [];
- let cur = [],
- used = 0;
- for (const item of items) {
- if (bare && item.system) continue;
- if (item.dock) continue;
- if (item.hidden) continue;
- if (inFolder.has(String(item.id))) continue;
- const cost = item.type === 'widget' ? wCost[pl][item.widgetSize || 'medium'] : 1;
- if (used + cost > budget && cur.length) {
- pages.push([...cur]);
- cur = [];
- used = 0;
- }
- cur.push(item);
- used += cost;
- }
- if (cur.length) pages.push(cur);
- return pages;
+ const tiles = items.filter(
+ item => !(bare && item.system) && !item.dock && !item.hidden && !inFolder.has(String(item.id)),
+ );
+ /** @returns {[number, number]} */
+ const span = item => {
+ if (item.type !== 'widget') return [1, 1];
+ const sz = item.widgetSize || 'medium';
+ return [wCols[pl][sz], wRows[pl][sz]];
+ };
+ return desktopPages(tiles, span, gm.cols, rows);
}
function mkIcon(item) {
@@ -424,6 +419,7 @@ function buildDesktop() {
dk.replaceChildren();
dock.forEach(item => dk.appendChild(mkDock(item)));
dk.hidden = !dock.length;
+ observeGlass(dk, 45, 0.2);
}
/* Every page is mounted at once, so widgets the user has swiped away from keep
@@ -998,7 +994,7 @@ async function boot() {
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(hasDock());
+ _slots = desktopRows(hasDock()) * gm.cols;
/* The phone layout rebuilds on a width change only. A phone keyboard changes
the height, and a rebuild then would close it. */
let _rs;
@@ -1014,7 +1010,7 @@ async function boot() {
if (MOB) return;
gm = gridMetrics();
document.documentElement.style.setProperty('--cols', String(gm.cols));
- const slots = desktopSlots(hasDock());
+ const slots = desktopRows(hasDock()) * gm.cols;
if (slots === _slots) return;
_slots = slots;
buildDesktop();
diff --git a/public/js/glass-rim.js b/public/js/glass-rim.js
new file mode 100644
index 0000000..06a18a6
--- /dev/null
+++ b/public/js/glass-rim.js
@@ -0,0 +1,60 @@
+import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1';
+
+const NS = 'http://www.w3.org/2000/svg';
+let seq = 0;
+const observers = new WeakMap();
+
+function svgEl(tag, attrs) {
+ const el = document.createElementNS(NS, tag);
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
+ return el;
+}
+
+/** Clip the host to the same path `d`, or half of the stroke paints outside it.
+ @param {number} w @param {number} h @param {string} d @returns {SVGSVGElement} */
+export function mkGlassRim(w, h, d) {
+ const id = `glass-rim-${++seq}`;
+ const edge = Math.min(0.5, 14 / h);
+ const svg = document.createElementNS(NS, 'svg');
+ svg.setAttribute('class', 'glass-rim');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
+ const line = svgEl('linearGradient', { id, gradientUnits: 'userSpaceOnUse', x1: 0, y1: 0, x2: 0, y2: h });
+ for (const [offset, cls] of [
+ [0, 'glass-rim-light'],
+ [edge, 'glass-rim-dark'],
+ [1 - edge, 'glass-rim-dark'],
+ [1, 'glass-rim-light'],
+ ]) {
+ line.appendChild(svgEl('stop', { offset, class: cls }));
+ }
+ const defs = svgEl('defs', {});
+ defs.appendChild(line);
+ svg.appendChild(defs);
+ svg.appendChild(svgEl('path', { d, stroke: `url(#${id})`, 'stroke-width': 1.5 }));
+ return svg;
+}
+
+/** Glass behind a host, redrawn on every resize.
+ The host must be positioned and set isolation:isolate, or the layer paints over its content.
+ @param {HTMLElement} host @param {number} radius @param {number} smoothing
+ @returns {() => void} stops the redraws */
+export function observeGlass(host, radius, smoothing) {
+ observers.get(host)?.disconnect();
+ const layer = document.createElement('div');
+ layer.className = 'glass-layer glass-surface';
+ host.prepend(layer);
+ const draw = () => {
+ const w = host.offsetWidth;
+ const h = host.offsetHeight;
+ if (!w || !h) return;
+ const d = smoothRectPath(w, h, radius, smoothing);
+ layer.style.clipPath = `path('${d}')`;
+ layer.replaceChildren(mkGlassRim(w, h, d));
+ };
+ draw();
+ const ro = new ResizeObserver(draw);
+ ro.observe(host);
+ observers.set(host, ro);
+ return () => ro.disconnect();
+}
diff --git a/public/js/listbox.js b/public/js/listbox.js
index 23ea3a9..f325e1d 100644
--- a/public/js/listbox.js
+++ b/public/js/listbox.js
@@ -198,8 +198,9 @@ export function createListbox(
document.addEventListener(
'click',
e => {
- const t = /** @type {Node} */ (e.target);
- if (!dd.contains(t) && !list.contains(t)) close();
+ /* Read the path, not the target. A pick redraws the list and detaches the target. */
+ const path = e.composedPath();
+ if (!path.includes(dd) && !path.includes(list)) close();
},
{ signal: outside.signal },
);
diff --git a/public/js/smooth-corner.js b/public/js/smooth-corner.js
new file mode 100644
index 0000000..1e390da
--- /dev/null
+++ b/public/js/smooth-corner.js
@@ -0,0 +1,42 @@
+const rad = deg => (deg * Math.PI) / 180;
+const n = v => Math.round(v * 100) / 100;
+
+/** A rounded rectangle whose corners ease into the edge. `smoothing` 0 is a plain arc.
+ @param {number} w @param {number} h @param {number} radius @param {number} smoothing
+ @returns {string} SVG path data, also valid inside CSS path() */
+export function smoothRectPath(w, h, radius, smoothing) {
+ const half = Math.min(w, h) / 2;
+ const r = Math.min(radius, half);
+ const s = Math.max(0, Math.min(smoothing, half / r - 1));
+ const p = Math.min((1 + s) * r, half);
+ const arc = 90 * (1 - s);
+ const arcLen = Math.sin(rad(arc / 2)) * r * Math.SQRT2;
+ const alpha = (90 - arc) / 2;
+ const beta = 45 * s;
+ const c = r * Math.tan(rad(alpha / 2)) * Math.cos(rad(beta));
+ const d = c * Math.tan(rad(beta));
+ const b = (p - arcLen - c - d) / 3;
+ const a = 2 * b;
+ const k = a + b + c;
+
+ return [
+ `M${n(p)} 0`,
+ `L${n(w - p)} 0`,
+ `C${n(w - p + a)} 0 ${n(w - p + a + b)} 0 ${n(w - p + k)} ${n(d)}`,
+ `A${n(r)} ${n(r)} 0 0 1 ${n(w - d)} ${n(p - k)}`,
+ `C${n(w)} ${n(p - a - b)} ${n(w)} ${n(p - a)} ${n(w)} ${n(p)}`,
+ `L${n(w)} ${n(h - p)}`,
+ `C${n(w)} ${n(h - p + a)} ${n(w)} ${n(h - p + a + b)} ${n(w - d)} ${n(h - p + k)}`,
+ `A${n(r)} ${n(r)} 0 0 1 ${n(w - p + k)} ${n(h - d)}`,
+ `C${n(w - p + a + b)} ${n(h)} ${n(w - p + a)} ${n(h)} ${n(w - p)} ${n(h)}`,
+ `L${n(p)} ${n(h)}`,
+ `C${n(p - a)} ${n(h)} ${n(p - a - b)} ${n(h)} ${n(p - k)} ${n(h - d)}`,
+ `A${n(r)} ${n(r)} 0 0 1 ${n(d)} ${n(h - p + k)}`,
+ `C0 ${n(h - p + a + b)} 0 ${n(h - p + a)} 0 ${n(h - p)}`,
+ `L0 ${n(p)}`,
+ `C0 ${n(p - a)} 0 ${n(p - a - b)} ${n(d)} ${n(p - k)}`,
+ `A${n(r)} ${n(r)} 0 0 1 ${n(p - k)} ${n(d)}`,
+ `C${n(p - a - b)} 0 ${n(p - a)} 0 ${n(p)} 0`,
+ 'Z',
+ ].join('');
+}
diff --git a/public/js/ui.js b/public/js/ui.js
index 1aae42b..a85705b 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=a1b61636';
+import { widgetSrc, cardPreset, uniqueTitle, WIDGET_DESIGN } from '/js/widget-types.js?v=d36b0153';
import {
mk,
clr,
@@ -17,6 +17,10 @@ import {
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';
+import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1';
+import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233';
+
+const PHONE_W = 393;
let _state = null;
export function initUI(state) {
@@ -104,7 +108,7 @@ function mkMiniIcon(child, pointerEvents) {
/** 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';
+ wrap.className = 'dyn-fold-wrap glass-surface';
css(wrap, {
'--br': Math.round(size * 0.24) + 'px',
'--gap': Math.round(size * 0.04) + 'px',
@@ -139,10 +143,8 @@ export function mkFolder(item) {
box.className = 'dyn-folder-box';
css(box, { '--iw': iw + 'px' });
const wrap = mk('div');
- wrap.className = 'folder-icon-grid';
- const g = mk('div');
- g.className = 'folder-icon-grid-sheen';
- wrap.appendChild(g);
+ wrap.className = 'folder-icon-grid glass-surface';
+ wrap.appendChild(mkGlassRim(iw, iw, smoothRectPath(iw, iw, 16, 0)));
(item.children || [])
.slice(0, 9)
.map(id => items().find(i => i.id === id))
@@ -224,7 +226,9 @@ export function openFolderDesktop(folder) {
}
/* Escape and the tile's own toggle arrive here alike, and a badge registered
to an element that has gone keeps the dashboard repainting it. */
+ let stopGlass = () => {};
ov.addEventListener('close', () => {
+ stopGlass();
registeredBadges.forEach(el => BEL().forEach((_, id) => bunreg(id, el)));
ov.remove();
folderOverlay = null;
@@ -242,6 +246,7 @@ export function openFolderDesktop(folder) {
tiles under the scrim were reachable by Tab and by a screen reader. */
ov.showModal();
ov.focus();
+ stopGlass = observeGlass(box, 28, 0.2);
}
function mFolder(item, cw, rh, isz, ir, im, sc) {
@@ -306,9 +311,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
css(box, { '--sz': eff + 'px' });
box.style.pointerEvents = 'none';
const wrap = mkFolderGlyph(item, eff);
- const sheen = mk('div');
- sheen.className = 'dyn-fold-sheen';
- wrap.prepend(sheen);
+ wrap.appendChild(mkGlassRim(eff, eff, smoothRectPath(eff, eff, Math.round(eff * 0.24), 0)));
box.appendChild(wrap);
const fb_ = mk('div');
fb_.className = 'badge';
@@ -402,7 +405,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
setUserText(titleEl, folder.label || t('type.folder'));
const box = mk('div');
- box.className = 'folder-box-mobile dyn-box-mob';
+ box.className = 'dyn-box-mob glass-surface';
css(box, {
'--left': boxLeft + 'px',
'--bw': boxW + 'px',
@@ -413,6 +416,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
'--ph': padH - badgeOvh + 'px',
'--pb': padVB - badgeOvh + 'px',
});
+ const boxD = smoothRectPath(boxW, boxH, boxR, 0.2);
+ box.style.clipPath = `path('${boxD}')`;
+ box.appendChild(mkGlassRim(boxW, boxH, boxD));
const clipW = mk('div');
clipW.className = 'dyn-clip';
@@ -782,16 +788,14 @@ export function buildMobile() {
const dockIr = Math.round(dockIconSz * 0.225),
dockIm = Math.round(dockIconSz * 0.64);
const dockGap = Math.round(9 * sc);
- /* Sized to what it holds, not to the window. The grid gains columns on a wide
- screen while the dock keeps four icons, and a stretched bar reads as empty
- rather than as a dock. */
- const dockContentW = dock.length
- ? dock.length * dockIconSz + (dock.length - 1) * Math.round(22 * sc) + dockPad * 2
- : maxDockW;
- const dockW = Math.min(maxDockW, dockContentW);
+ const dockW = Math.min(maxDockW, Math.round((PHONE_W - 18) * sc));
+ const spare = dockW - dockPad * 2 - dock.length * dockIconSz;
+ const maxIconGap = Math.round(22 * sc);
+ const dockIconGap = dock.length > 1 ? Math.max(0, Math.min(maxIconGap, Math.floor(spare / (dock.length - 1)))) : 0;
dk.hidden = !dock.length;
- dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;border-radius:${Math.round(44 * sc)}px;z-index:400;`;
+ dk.style.cssText = `position:fixed;left:50%;bottom:${dockGap}px;transform:translateX(-50%);width:${dockW}px;height:${dh}px;padding:0 ${dockPad}px;gap:${dockIconGap}px;z-index:400;`;
dk.replaceChildren();
+ observeGlass(dk, Math.round(40 * sc), 0.2);
dock.forEach(item => {
const a = mk('a', { href: item.href, target: '_blank', rel: 'noreferrer noopener' });
a.className = 'dyn-dock-icon';
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index 8e99c9e..9b2b7d8 100644
--- a/public/js/widget-config-form.js
+++ b/public/js/widget-config-form.js
@@ -4,7 +4,7 @@
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=fd784739';
-import { createListbox } from '/js/listbox.js?v=a67e9c98';
+import { createListbox } from '/js/listbox.js?v=bedd1104';
import { renderColorControl } from '/js/admin-color-control.js?v=837961fa';
import {
seedCarried,
diff --git a/public/js/widget-types.js b/public/js/widget-types.js
index 1a8e252..6b64427 100644
--- a/public/js/widget-types.js
+++ b/public/js/widget-types.js
@@ -13,10 +13,6 @@ export const WIDGET_ROWS = {
desktop: { small: 0, medium: 0, large: 2, xlarge: 3 },
mobile: { small: 2, medium: 2, large: 4, xlarge: 6 },
};
-export const WIDGET_COST = {
- desktop: { small: 1, medium: 2, large: 4, xlarge: 6 },
- mobile: { small: 4, medium: 8, large: 16, xlarge: 24 },
-};
/* The iframe URL, from the manifest entry in `reg`. The cache version is hashed
from file content at release, never maintained by hand. */
diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md
index b642e3e..98eaa65 100644
--- a/src/content/docs/docs/badges.md
+++ b/src/content/docs/docs/badges.md
@@ -8,7 +8,7 @@ description: Add health checks, fixed labels and live values from any API to Sta
A badge is the small pill on an app tile. Each app can have three kinds.
- 
+ 
## Health check
@@ -35,7 +35,7 @@ Tick **Secret** on a header to keep its value on the server. Poll sets how often
One API can feed up to five values. Press Add Label for each.
- 
+ 
- **Show From** keeps a value hidden below that count.
diff --git a/src/content/docs/docs/customization.md b/src/content/docs/docs/customization.md
index 05be575..478da9e 100644
--- a/src/content/docs/docs/customization.md
+++ b/src/content/docs/docs/customization.md
@@ -19,7 +19,7 @@ Browsers only allow Keep Screen Awake over HTTPS. On a plain HTTP address the to
- 
+ 
Appearance, in the admin UI.
diff --git a/src/content/docs/docs/index.md b/src/content/docs/docs/index.md
index f9cac65..4adc261 100644
--- a/src/content/docs/docs/index.md
+++ b/src/content/docs/docs/index.md
@@ -5,7 +5,7 @@ description: Stackyard is a calm, self-hosted homelab dashboard for your apps, w
Stackyard is a self-hosted dashboard for your homelab. Apps, folders, a few widgets and status badges, in one container.
-
+
- **Calm.** A health badge shows only when something is wrong.
- **Badges from any API.** Pick a value from a service's API response. No code. See [Badges](/docs/badges/).
diff --git a/src/content/docs/docs/settings-reference.md b/src/content/docs/docs/settings-reference.md
index ebe7f87..84dc741 100644
--- a/src/content/docs/docs/settings-reference.md
+++ b/src/content/docs/docs/settings-reference.md
@@ -15,8 +15,6 @@ At the top, before any group:
| Setting | What it does |
| --- | --- |
-| Title | The name of this Stackyard instance. |
-| Description | Free text shown alongside the title. |
| Host IP | Your server's own address. Setting it lets badges and widgets reach services on that IP, which the SSRF guard would otherwise block. See [Security](/docs/security/). |
### Language