diff --git a/astro.config.mjs b/astro.config.mjs
index 6f57d75..a6a5cf3 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -27,34 +27,27 @@ export default defineConfig({
description:
'A calm, customizable dashboard for your services, designed to be useful without becoming another wall of data.',
logo: {
- light: './public/img/stackyard-wordmark-light.svg',
- dark: './public/img/stackyard-wordmark-dark.svg',
+ light: './public/img/stackyard-wordmark-light-b3140d1e.svg',
+ dark: './public/img/stackyard-wordmark-dark-4680834d.svg',
alt: '',
replacesTitle: true,
},
favicon: '/favicon.svg',
- components: { Head: './src/components/Head.astro' },
- customCss: ['./src/styles/fonts.css', './src/styles/docs.css'],
+ components: {
+ Head: './src/components/Head.astro',
+ Sidebar: './src/components/Sidebar.astro',
+ },
+ customCss: ['./src/styles/docs.css'],
head: [
- {
- tag: 'link',
- attrs: {
- rel: 'preload',
- as: 'font',
- type: 'font/woff2',
- href: '/fonts/inter-latin.woff2',
- crossorigin: true,
- },
- },
{ tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } },
{ tag: 'link', attrs: { rel: 'manifest', href: '/manifest.webmanifest' } },
{ tag: 'meta', attrs: { name: 'theme-color', content: '#0d1117' } },
- { tag: 'meta', attrs: { property: 'og:image', content: `${SITE}/img/og-64722d2b.jpg` } },
+ { tag: 'meta', attrs: { property: 'og:image', content: `${SITE}/img/og-48fdebbd.jpg` } },
{ tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } },
{ tag: 'meta', attrs: { property: 'og:image:height', content: '630' } },
{ tag: 'meta', attrs: { property: 'og:site_name', content: 'Stackyard' } },
{ tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } },
- { tag: 'meta', attrs: { name: 'twitter:image', content: `${SITE}/img/og-64722d2b.jpg` } },
+ { tag: 'meta', attrs: { name: 'twitter:image', content: `${SITE}/img/og-48fdebbd.jpg` } },
],
expressiveCode: {
diff --git a/public/_headers b/public/_headers
index e7e92a6..d76250d 100644
--- a/public/_headers
+++ b/public/_headers
@@ -8,9 +8,6 @@
/_astro/*
Cache-Control: public, max-age=31536000, immutable
-/fonts/*
- Cache-Control: public, max-age=31536000, immutable
-
/img/*
Cache-Control: public, max-age=31536000, immutable
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index 1c10a51..20172d5 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-maskable.png b/public/favicon-maskable.png
index 845f0e2..0c0b5c5 100644
Binary files a/public/favicon-maskable.png and b/public/favicon-maskable.png differ
diff --git a/public/favicon.png b/public/favicon.png
index 75b328c..ade5d29 100644
Binary files a/public/favicon.png and b/public/favicon.png differ
diff --git a/public/favicon.svg b/public/favicon.svg
index 38f2d04..5903e9d 100644
--- a/public/favicon.svg
+++ b/public/favicon.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/public/fonts/inter-LICENSE.txt b/public/fonts/inter-LICENSE.txt
deleted file mode 100644
index 9b2ca37..0000000
--- a/public/fonts/inter-LICENSE.txt
+++ /dev/null
@@ -1,92 +0,0 @@
-Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-http://scripts.sil.org/OFL
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION AND CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/inter-latin.woff2 b/public/fonts/inter-latin.woff2
deleted file mode 100644
index e57a265..0000000
Binary files a/public/fonts/inter-latin.woff2 and /dev/null differ
diff --git a/public/icons/authelia.svg b/public/icons/authelia.svg
new file mode 100644
index 0000000..02b12b9
--- /dev/null
+++ b/public/icons/authelia.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/authentik.svg b/public/icons/authentik.svg
deleted file mode 100644
index 9933084..0000000
--- a/public/icons/authentik.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/public/icons/filebrowser-quantum.svg b/public/icons/filebrowser-quantum.svg
new file mode 100644
index 0000000..04e878c
--- /dev/null
+++ b/public/icons/filebrowser-quantum.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/icons/immich.svg b/public/icons/immich.svg
deleted file mode 100644
index 5c758ff..0000000
--- a/public/icons/immich.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/icons/jellyfin.svg b/public/icons/jellyfin.svg
deleted file mode 100644
index c62f104..0000000
--- a/public/icons/jellyfin.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/icons/pi-hole.svg b/public/icons/pi-hole.svg
deleted file mode 100644
index 5b023fc..0000000
--- a/public/icons/pi-hole.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/icons/portainer.svg b/public/icons/portainer.svg
deleted file mode 100644
index 6e8805c..0000000
--- a/public/icons/portainer.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/public/icons/seerr-light.svg b/public/icons/seerr-light.svg
new file mode 100644
index 0000000..0ab3668
--- /dev/null
+++ b/public/icons/seerr-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/seerr.svg b/public/icons/seerr.svg
deleted file mode 100644
index bf300a5..0000000
--- a/public/icons/seerr.svg
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/public/icons/settings-toggles.svg b/public/icons/settings-toggles.svg
new file mode 100644
index 0000000..4213c1a
--- /dev/null
+++ b/public/icons/settings-toggles.svg
@@ -0,0 +1 @@
+
diff --git a/public/icons/settings.svg b/public/icons/settings.svg
deleted file mode 100644
index c931fca..0000000
--- a/public/icons/settings.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/public/img/admin/app-edit-badge-03598133.png b/public/img/admin/app-edit-badge-03598133.png
new file mode 100644
index 0000000..76fef8d
Binary files /dev/null and b/public/img/admin/app-edit-badge-03598133.png differ
diff --git a/public/img/admin/app-edit-badge-98d02665.png b/public/img/admin/app-edit-badge-98d02665.png
deleted file mode 100644
index bcb6d0a..0000000
Binary files a/public/img/admin/app-edit-badge-98d02665.png and /dev/null differ
diff --git a/public/img/admin/app-edit-badge-dark-adce9ae8.png b/public/img/admin/app-edit-badge-dark-adce9ae8.png
new file mode 100644
index 0000000..6eb58f5
Binary files /dev/null and b/public/img/admin/app-edit-badge-dark-adce9ae8.png differ
diff --git a/public/img/admin/app-edit-badge-dark-cee1d99a.png b/public/img/admin/app-edit-badge-dark-cee1d99a.png
deleted file mode 100644
index 1ac71c6..0000000
Binary files a/public/img/admin/app-edit-badge-dark-cee1d99a.png and /dev/null differ
diff --git a/public/img/admin/app-edit-basics-b3c6980e.png b/public/img/admin/app-edit-basics-b3c6980e.png
deleted file mode 100644
index 025f523..0000000
Binary files a/public/img/admin/app-edit-basics-b3c6980e.png and /dev/null differ
diff --git a/public/img/admin/app-edit-basics-dark-2910e8f5.png b/public/img/admin/app-edit-basics-dark-2910e8f5.png
deleted file mode 100644
index dc95d36..0000000
Binary files a/public/img/admin/app-edit-basics-dark-2910e8f5.png and /dev/null differ
diff --git a/public/img/admin/app-edit-labels-4eb0fb0b.png b/public/img/admin/app-edit-labels-4eb0fb0b.png
new file mode 100644
index 0000000..efc4659
Binary files /dev/null and b/public/img/admin/app-edit-labels-4eb0fb0b.png differ
diff --git a/public/img/admin/app-edit-labels-af3ec51a.png b/public/img/admin/app-edit-labels-af3ec51a.png
deleted file mode 100644
index e944963..0000000
Binary files a/public/img/admin/app-edit-labels-af3ec51a.png and /dev/null differ
diff --git a/public/img/admin/app-edit-labels-dark-07bd1535.png b/public/img/admin/app-edit-labels-dark-07bd1535.png
new file mode 100644
index 0000000..195fc6f
Binary files /dev/null and b/public/img/admin/app-edit-labels-dark-07bd1535.png differ
diff --git a/public/img/admin/app-edit-labels-dark-2ff5328b.png b/public/img/admin/app-edit-labels-dark-2ff5328b.png
deleted file mode 100644
index 12bc4a2..0000000
Binary files a/public/img/admin/app-edit-labels-dark-2ff5328b.png and /dev/null differ
diff --git a/public/img/admin/appearance-31fd5463.png b/public/img/admin/appearance-31fd5463.png
new file mode 100644
index 0000000..1bc096c
Binary files /dev/null and b/public/img/admin/appearance-31fd5463.png differ
diff --git a/public/img/admin/appearance-dark-449811e9.png b/public/img/admin/appearance-dark-449811e9.png
deleted file mode 100644
index 75c205b..0000000
Binary files a/public/img/admin/appearance-dark-449811e9.png and /dev/null differ
diff --git a/public/img/admin/appearance-dark-cb2e78c0.png b/public/img/admin/appearance-dark-cb2e78c0.png
new file mode 100644
index 0000000..e179287
Binary files /dev/null and b/public/img/admin/appearance-dark-cb2e78c0.png differ
diff --git a/public/img/admin/appearance-e9d99c15.png b/public/img/admin/appearance-e9d99c15.png
deleted file mode 100644
index 280d1af..0000000
Binary files a/public/img/admin/appearance-e9d99c15.png and /dev/null differ
diff --git a/public/img/admin/dashboard-apps-3dd6a50c.png b/public/img/admin/dashboard-apps-3dd6a50c.png
deleted file mode 100644
index 5b1bd0e..0000000
Binary files a/public/img/admin/dashboard-apps-3dd6a50c.png and /dev/null differ
diff --git a/public/img/admin/dashboard-apps-dark-34e8c0e1.png b/public/img/admin/dashboard-apps-dark-34e8c0e1.png
deleted file mode 100644
index 1c8684f..0000000
Binary files a/public/img/admin/dashboard-apps-dark-34e8c0e1.png and /dev/null differ
diff --git a/public/img/admin/dashboard-list-0d98d7b7.png b/public/img/admin/dashboard-list-0d98d7b7.png
deleted file mode 100644
index 023b3ad..0000000
Binary files a/public/img/admin/dashboard-list-0d98d7b7.png and /dev/null differ
diff --git a/public/img/admin/dashboard-list-dark-e87991d0.png b/public/img/admin/dashboard-list-dark-e87991d0.png
deleted file mode 100644
index 2df11d9..0000000
Binary files a/public/img/admin/dashboard-list-dark-e87991d0.png and /dev/null differ
diff --git a/public/img/admin/general-aeebf7aa.png b/public/img/admin/general-aeebf7aa.png
deleted file mode 100644
index 7cf9ef1..0000000
Binary files a/public/img/admin/general-aeebf7aa.png and /dev/null differ
diff --git a/public/img/dashboard-7d037291.jpg b/public/img/dashboard-7d037291.jpg
new file mode 100644
index 0000000..3bd2c36
Binary files /dev/null and b/public/img/dashboard-7d037291.jpg differ
diff --git a/public/img/dashboard-b3d50cc8.jpg b/public/img/dashboard-b3d50cc8.jpg
deleted file mode 100644
index c1d2170..0000000
Binary files a/public/img/dashboard-b3d50cc8.jpg and /dev/null differ
diff --git a/public/img/dashboard/badge-labels-c9fda069.png b/public/img/dashboard/badge-labels-c9fda069.png
deleted file mode 100644
index a4fbb01..0000000
Binary files a/public/img/dashboard/badge-labels-c9fda069.png and /dev/null differ
diff --git a/public/img/dashboard/badge-list-a6444739.png b/public/img/dashboard/badge-list-a6444739.png
deleted file mode 100644
index 4d3e7ac..0000000
Binary files a/public/img/dashboard/badge-list-a6444739.png and /dev/null differ
diff --git a/public/img/og-48fdebbd.jpg b/public/img/og-48fdebbd.jpg
new file mode 100644
index 0000000..714169b
Binary files /dev/null and b/public/img/og-48fdebbd.jpg differ
diff --git a/public/img/og-64722d2b.jpg b/public/img/og-64722d2b.jpg
deleted file mode 100644
index 8f50d38..0000000
Binary files a/public/img/og-64722d2b.jpg and /dev/null differ
diff --git a/public/img/stackyard-mark-0ce25219.svg b/public/img/stackyard-mark-0ce25219.svg
new file mode 100644
index 0000000..aa839f5
--- /dev/null
+++ b/public/img/stackyard-mark-0ce25219.svg
@@ -0,0 +1 @@
+
diff --git a/public/img/stackyard-mark-light-c3aadde5.svg b/public/img/stackyard-mark-light-c3aadde5.svg
new file mode 100644
index 0000000..635c805
--- /dev/null
+++ b/public/img/stackyard-mark-light-c3aadde5.svg
@@ -0,0 +1 @@
+
diff --git a/public/img/stackyard-mark-light.svg b/public/img/stackyard-mark-light.svg
deleted file mode 100644
index 7fa62b2..0000000
--- a/public/img/stackyard-mark-light.svg
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/img/stackyard-mark.svg b/public/img/stackyard-mark.svg
deleted file mode 100644
index 2e7fd7f..0000000
--- a/public/img/stackyard-mark.svg
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/img/stackyard-wordmark-dark-4680834d.svg b/public/img/stackyard-wordmark-dark-4680834d.svg
new file mode 100644
index 0000000..2fafe7f
--- /dev/null
+++ b/public/img/stackyard-wordmark-dark-4680834d.svg
@@ -0,0 +1 @@
+
diff --git a/public/img/stackyard-wordmark-dark.svg b/public/img/stackyard-wordmark-dark.svg
deleted file mode 100644
index 33a4b53..0000000
--- a/public/img/stackyard-wordmark-dark.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/public/img/stackyard-wordmark-light-b3140d1e.svg b/public/img/stackyard-wordmark-light-b3140d1e.svg
new file mode 100644
index 0000000..a5c94c7
--- /dev/null
+++ b/public/img/stackyard-wordmark-light-b3140d1e.svg
@@ -0,0 +1 @@
+
diff --git a/public/img/stackyard-wordmark-light.svg b/public/img/stackyard-wordmark-light.svg
deleted file mode 100644
index b146d70..0000000
--- a/public/img/stackyard-wordmark-light.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js
index 43cd9ef..ff9eaeb 100644
--- a/public/js/admin-app-form.js
+++ b/public/js/admin-app-form.js
@@ -1,4 +1,4 @@
-import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=55685187';
+import { clr, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=eadafbcd';
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=831e219e';
@@ -10,24 +10,24 @@ import {
failureIsMissingApiPath,
nextActiveIndex,
sameIconName,
-} from '/js/admin-logic.js?v=e3673bd7';
+} from '/js/admin-logic.js?v=cbb7417d';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import {
toast,
- ag,
- ap,
+ apiGet,
+ apiPost,
PE_SVG,
initInlineEdit,
reveal,
setTogDisabled,
swapContent,
-} 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';
+} from '/js/admin-shared.js?v=d2e8b6dc';
+import { createListbox } from '/js/listbox.js?v=f057351d';
+import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b';
+import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e963853a';
+import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=e4ea7820';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
-import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
export function buildFolderForm(body, item) {
const children = item?.children || [];
@@ -208,7 +208,7 @@ export function buildAppForm(body, item) {
/* The markup carries no style attribute, so the preview is painted here. */
const pv0 = el('ipv');
- if (pv0) pv0.style.background = rc(state.scol);
+ if (pv0) pv0.style.background = clr(state.scol);
renderColorControl(el('icon-color-slot'), {
value: state.scol || 'auto',
idPrefix: 'icon-col',
@@ -217,7 +217,7 @@ export function buildAppForm(body, item) {
onChange(v) {
state.scol = v;
const pv = el('ipv');
- if (pv) pv.style.background = rc(state.scol);
+ if (pv) pv.style.background = clr(state.scol);
},
});
renderColorControl(el('static-color-slot'), {
@@ -621,7 +621,7 @@ function renderIconVariants(variants) {
async function loadIconVariants(ref) {
if (!ref || ref.startsWith('http://') || ref.startsWith('https://') || ref.includes('/')) return;
try {
- const d = await ag(`/api/icons/variants?ref=${encodeURIComponent(ref)}`);
+ const d = await apiGet(`/api/icons/variants?ref=${encodeURIComponent(ref)}`);
if (inpById('ip-in')?.value !== ref) return;
const variants = d.variants || [];
/* The catalogue lists this name but holds no file under it. */
@@ -642,7 +642,7 @@ function wireIcon() {
const mine = ++run;
const q = v.replace(/\.(svg|png|ico)$/i, '');
try {
- const d = await ag(`/api/icons/search?q=${encodeURIComponent(q)}`);
+ const d = await apiGet(`/api/icons/search?q=${encodeURIComponent(q)}`);
if (mine !== run) return;
showIPRes(d.results || [], v);
} catch {
@@ -827,7 +827,7 @@ function updPrev(explicit) {
const p = el('ipv');
if (!p) return;
const run = ++prevRun;
- p.style.background = rc(state.scol);
+ p.style.background = clr(state.scol);
if (!state.siurl) {
setInitialGlyph(p);
return;
@@ -863,7 +863,7 @@ async function testPing() {
st.textContent = t('app.testing');
const skipTls = inpById('f-skip-tls')?.checked || false;
try {
- const r = await ap('/api/ping', { url, skipTls });
+ const r = await apiPost('/api/ping', { url, skipTls });
st.textContent = r.ok
? '✓ ' + t('app.reachable', { status: r.status })
: '✗ ' + t('app.httpError', { status: r.status });
@@ -920,21 +920,21 @@ function renderKvRows(host, rows, ph) {
const defaultValuePlaceholder = ph => ph.split('=')[1] || 'value';
function kvRowEl(host, rows, row, ph) {
- const el = document.createElement('div');
- el.className = 'kv-row';
+ const rowEl = document.createElement('div');
+ rowEl.className = 'kv-row';
const valPh = row.secret && row.valueSet && row.value === '' ? 'Configured' : defaultValuePlaceholder(ph);
setHtml(
- el,
+ rowEl,
html`
${t('app.secret')}
✕ `,
);
- const kEl = qi('.kv-k', el),
- vEl = qi('.kv-v', el),
- cEl = qi('.kv-cred input', el),
- dEl = qSel('.kv-del', el);
+ const kEl = qi('.kv-k', rowEl),
+ vEl = qi('.kv-v', rowEl),
+ cEl = qi('.kv-cred input', rowEl),
+ dEl = qSel('.kv-del', rowEl);
kEl.oninput = () => {
row.key = kEl.value;
};
@@ -959,7 +959,7 @@ function kvRowEl(host, rows, row, ph) {
if (idx >= 0) rows.splice(idx, 1);
renderKvRows(host, rows, ph);
};
- return el;
+ return rowEl;
}
async function fetchBadge() {
@@ -980,7 +980,7 @@ async function fetchBadge() {
const params = serializeKvRows(state._bpar);
const headers = serializeKvRows(state._bhdr);
const skipTls = inpById('f-skip-tls')?.checked || false;
- const r = await ap('/api/badge-proxy', {
+ const r = await apiPost('/api/badge-proxy', {
url,
params,
headers,
@@ -1007,7 +1007,7 @@ async function fetchBadge() {
const tone = missingPath || advice.tone === TONE.WARN ? 'var(--warning)' : 'var(--danger)';
st.style.cssText = 'margin-top:4px;color:' + tone;
if (missingPath) st.textContent = t('app.needsApiPathError');
- else st.textContent = advice.tone === TONE.WARN ? advice.message : '✗ ' + advice.message;
+ else st.textContent = (advice.tone === TONE.WARN ? '' : '✗ ') + t(advice.key, advice.vars);
}
if (advice.openAuth && !missingPath) {
const authCb = inpById('auth-en');
diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js
index 1697b38..bf26807 100644
--- a/public/js/admin-auth.js
+++ b/public/js/admin-auth.js
@@ -1,11 +1,11 @@
-import { ag, ap } from '/js/admin-shared.js?v=52e149f5';
+import { apiGet, apiPost } from '/js/admin-shared.js?v=d2e8b6dc';
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=55685187';
+import { el, inp as inpById, qa } from '/js/utils.js?v=eadafbcd';
export async function checkAuth(onLogin) {
try {
- const d = await ag('/api/auth/check');
+ const d = await apiGet('/api/auth/check');
if (!d.enabled || d.authenticated) return true;
showLoginScreen(onLogin);
return false;
@@ -39,7 +39,7 @@ function showLoginScreen(onLogin) {
if (btn) btn.disabled = true;
if (err) err.style.display = 'none';
try {
- await ap('/api/auth/login', { password: pw?.value || '' });
+ await apiPost('/api/auth/login', { password: pw?.value || '' });
if (s) s.style.display = 'none';
onLogin?.();
} catch (e) {
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index 690d436..18a7e73 100644
--- a/public/js/admin-color-control.js
+++ b/public/js/admin-color-control.js
@@ -1,8 +1,8 @@
-import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=52e149f5';
+import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=d2e8b6dc';
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=55685187';
-import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { qa, q } from '/js/utils.js?v=eadafbcd';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
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'];
@@ -158,8 +158,8 @@ export function renderColorControl(
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`
-
`;
+ const slider = (name, cls, id, max, val, lo, hi) => html`
+ `;
const hueRow = hues.length
? html`${t('color.system')} ${hues.map(kwSwatch)}
`
: '';
@@ -228,11 +228,6 @@ export function renderColorControl(
target on a phone. */
sEl.style.backgroundImage = `linear-gradient(var(--slider-dir), ${_hsvToHex(h, 0, v)}, ${_hsvToHex(h, 100, v)})`;
vEl.style.backgroundImage = `linear-gradient(var(--slider-dir), #000, ${_hsvToHex(h, 100, 100)})`;
- /* Each knob previews what its own slider is set to: the hue on its own, the
- resulting colour for the other two. */
- hEl.style.setProperty('--knob-fill', _hsvToHex(h, 100, 100));
- sEl.style.setProperty('--knob-fill', hex);
- vEl.style.setProperty('--knob-fill', hex);
qa('.cc-swatch', container).forEach(b => {
let on = false;
if (mode !== 'color') on = b.dataset.v === mode;
@@ -288,12 +283,12 @@ export function renderColorControl(
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;
+ const { value: parsed, ok } = normalizeColorInput(val);
+ const hv = ok ? _hexToHsv(parsed) : null;
if (hv) {
mode = 'color';
showTune = true;
- setSliders(value);
+ setSliders(parsed);
} else if (val) toast(t('toast.colorInvalid'), 'err');
commit();
},
diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js
index 44b450c..b624a1a 100644
--- a/public/js/admin-drag.js
+++ b/public/js/admin-drag.js
@@ -11,8 +11,8 @@
import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76';
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';
+import { snapshotItems } from '/js/admin-save-logic.js?v=60a82419';
+import { qa } from '/js/utils.js?v=eadafbcd';
/** @type {(before: unknown) => void} */
let _save = () => {};
diff --git a/public/js/admin-error.js b/public/js/admin-error.js
index c821a88..f12a64b 100644
--- a/public/js/admin-error.js
+++ b/public/js/admin-error.js
@@ -1,5 +1,9 @@
-/* Maps the API's `kind` to what the admin UI should do about it. Keep it free
- of the DOM and of imports: api/test loads it directly. */
+/* Maps the API's structured error to what the admin UI should do about it. Keep
+ it free of the DOM and of imports: its tests load it directly.
+
+ The API's `code` is the whole input. `error` is prose the server wrote for a
+ log; it is never translated and never shown. Advice therefore carries a
+ translation key and its variables, never finished text. */
export const KIND = Object.freeze({
NETWORK: 'network',
@@ -13,80 +17,71 @@ export const KIND = Object.freeze({
export const TONE = Object.freeze({ WARN: 'warn', ERROR: 'error' });
-/* The reason code the API sends with a block that ALLOW_PRIVATE_IPS would let
- through. The message itself never names the address. */
-const PRIVATE_ADDRESS = 'private-address';
+const BY_CODE = Object.freeze({
+ 'blocked.private-address': 'adminError.privateAddress',
+ 'invalid.retype': 'adminError.retype',
+ 'upstream.redirect': 'adminError.redirect',
+ 'network.tls-ignored': 'adminError.tlsIgnored',
+ 'network.tls-untrusted': 'adminError.tlsUntrusted',
+});
-const PRIVATE_ADDRESS_ADVICE =
- 'Most homelab services live on private IPs. Set ALLOW_PRIVATE_IPS=true and restart the container.';
+const BY_KIND = Object.freeze({
+ [KIND.NETWORK]: 'adminError.unreachable',
+ [KIND.TIMEOUT]: 'adminError.unreachable',
+ [KIND.BLOCKED]: 'adminError.genericBlocked',
+ [KIND.AUTH]: 'adminError.sessionExpired',
+ [KIND.UPSTREAM]: 'adminError.genericUpstream',
+ [KIND.INVALID]: 'adminError.genericInvalid',
+ [KIND.INTERNAL]: 'adminError.genericInternal',
+});
-const blockedForPrivateAddress = ({ kind, detail }) => kind === KIND.BLOCKED && detail?.reason === PRIVATE_ADDRESS;
+/* Each key is a whole sentence. Composing one from a fragment and a number puts
+ word order in the code and breaks every language that does not share it. */
+function statusKey(status) {
+ if (status === 404) return 'adminError.statusNotFound';
+ if (status === 405) return 'adminError.statusMethod';
+ if (status === 407) return 'adminError.statusProxyAuth';
+ if (status >= 500) return 'adminError.statusServer';
+ return 'adminError.statusOther';
+}
-/* An unknown or missing kind degrades to INTERNAL. */
export function readError(e) {
const kind = e && typeof e.kind === 'string' && Object.values(KIND).includes(e.kind) ? e.kind : KIND.INTERNAL;
const detail = e && e.detail && typeof e.detail === 'object' ? e.detail : null;
- return { kind, detail, message: (e && e.message) || '' };
+ const code = e && typeof e.code === 'string' && e.code ? e.code : kind;
+ return { kind, code, detail };
+}
+
+function adviceFor(read) {
+ const { kind, code, detail } = read;
+ const status = detail && typeof detail.status === 'number' ? detail.status : null;
+ if (code === 'upstream.status' && status !== null) return { key: statusKey(status), vars: { status } };
+ if (BY_CODE[code]) {
+ return status !== null ? { key: BY_CODE[code], vars: { status } } : { key: BY_CODE[code] };
+ }
+ return { key: BY_KIND[kind] || BY_KIND[KIND.INTERNAL] };
}
export function badgeErrorAdvice(e) {
const read = readError(e);
- const { kind, detail, message } = read;
+ const { kind, code, detail } = read;
+ const status = detail && typeof detail.status === 'number' ? detail.status : null;
if (kind === KIND.AUTH) {
- return {
- tone: TONE.ERROR,
- message: 'Your session has expired. Sign in again to continue.',
- openAuth: false,
- sessionExpired: true,
- };
+ return { tone: TONE.ERROR, code, key: 'adminError.sessionExpired', openAuth: false, sessionExpired: true };
}
- if (kind === KIND.UPSTREAM && (detail?.status === 401 || detail?.status === 403)) {
- return {
- tone: TONE.WARN,
- message: 'Authentication required. Enable the Authentication toggle below and add your API key.',
- openAuth: true,
- sessionExpired: false,
- };
+ if (kind === KIND.UPSTREAM && (status === 401 || status === 403)) {
+ return { tone: TONE.WARN, code, key: 'adminError.authRequired', openAuth: true, sessionExpired: false };
}
- if (kind === KIND.NETWORK || kind === KIND.TIMEOUT) {
- return {
- tone: TONE.WARN,
- message:
- "Can't reach this address from Docker. Try using the container name, e.g. http://container-name:8181/api/v2",
- openAuth: false,
- sessionExpired: false,
- };
- }
-
- if (blockedForPrivateAddress(read)) {
- return {
- tone: TONE.WARN,
- message: `${message} ${PRIVATE_ADDRESS_ADVICE}`,
- openAuth: false,
- sessionExpired: false,
- };
- }
-
- /* BLOCKED carries a reason this project wrote, so it is shown verbatim. */
- return {
- tone: TONE.ERROR,
- message: message || 'Request failed.',
- openAuth: false,
- sessionExpired: false,
- };
+ const warn = kind === KIND.NETWORK || kind === KIND.TIMEOUT || code === 'blocked.private-address';
+ return { ...adviceFor(read), code, tone: warn ? TONE.WARN : TONE.ERROR, openAuth: false, sessionExpired: false };
}
-/* Same shape as badgeErrorAdvice: a settings Fetch and a badge test report the
- same failures and must not disagree about the tone. */
+/* Same wording as badgeErrorAdvice: a settings Fetch and a badge test report the
+ same failures and must not disagree about either the text or the tone. */
export function optionsErrorAdvice(e) {
- const read = readError(e);
- const { kind, message } = read;
- if (blockedForPrivateAddress(read)) {
- return { tone: TONE.WARN, message: `${message} ${PRIVATE_ADDRESS_ADVICE}` };
- }
- if (kind === KIND.INVALID && message) return { tone: TONE.ERROR, message };
- return { tone: TONE.ERROR, message: 'Fetch failed: ' + (message || 'Request failed.') };
+ const { tone, key, vars, code } = badgeErrorAdvice(e);
+ return vars ? { tone, code, key, vars } : { tone, code, key };
}
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index d33d7d8..9b7e806 100644
--- a/public/js/admin-list.js
+++ b/public/js/admin-list.js
@@ -12,16 +12,16 @@
reads and writes them too. */
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=a9bbc794';
-import { paintIcon } from '/js/admin-shared.js?v=52e149f5';
-import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=55685187';
+import { snapshotItems } from '/js/admin-save-logic.js?v=60a82419';
+import { reorderItems } from '/js/admin-logic.js?v=cbb7417d';
+import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=36006b2b';
+import { paintIcon } from '/js/admin-shared.js?v=d2e8b6dc';
+import { clr, el, qa, setUserText } from '/js/utils.js?v=eadafbcd';
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=17a37df4';
-import { widgetGlyph } from '/js/widget-glyphs.js?v=3adb57ad';
-import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { sizeLabel } from '/js/admin-widget-form.js?v=fd3dcc6c';
+import { widgetGlyph } from '/js/widget-glyphs.js?v=e1d968ad';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
/** @type {{ openModal: (idx: number|null) => void,
openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void,
@@ -48,9 +48,9 @@ const SIZE_ICONS = {
xlarge: iconSvg('xlarge', 26),
};
function svgNode(markup) {
- const t = document.createElement('template');
- setHtml(t, raw(markup));
- return t.content.firstElementChild;
+ const tpl = document.createElement('template');
+ setHtml(tpl, raw(markup));
+ return tpl.content.firstElementChild;
}
function moveRow(item, dir, opts = {}) {
@@ -94,7 +94,7 @@ export function mkRow(item, idx, { indent = false, childIdx = null, folderId = n
if (_filtering) handle.style.visibility = 'hidden';
const ico = document.createElement('div');
ico.className = 'rico';
- ico.style.background = rc(item.color);
+ ico.style.background = clr(item.color);
if (item.type === 'folder') {
ico.appendChild(svgNode(FOLDER_ICON));
} else if (item.type === 'widget') {
diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js
index 626d7bf..f183948 100644
--- a/public/js/admin-logic.js
+++ b/public/js/admin-logic.js
@@ -303,15 +303,20 @@ export function isBareHostUrl(url) {
}
/** Whether a failed badge fetch should report the missing API path instead of
- the failure itself. A session that has expired is reported as itself: the
- address is not why the request failed.
+ the failure itself.
+
+ Shown only where a missing path explains the failure: a bare address that
+ answered 404 or redirected, usually to a login page. Anything else is
+ reported as itself.
@param {string} url
- @param {{ sessionExpired?: boolean }} advice
+ @param {{ sessionExpired?: boolean, code?: string, vars?: { status?: number } }} advice
@returns {boolean} */
export function failureIsMissingApiPath(url, advice) {
- if (advice && advice.sessionExpired) return false;
- return isBareHostUrl(url);
+ if (!advice || advice.sessionExpired) return false;
+ if (!isBareHostUrl(url)) return false;
+ if (advice.code === 'upstream.status') return advice.vars?.status === 404;
+ return advice.code === 'upstream.redirect';
}
/** Compares what `read` returns now with what it returned at the last reset.
diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js
index 8e13495..6ac88ae 100644
--- a/public/js/admin-save-logic.js
+++ b/public/js/admin-save-logic.js
@@ -113,8 +113,9 @@ export function buildActivityLabel(path, style) {
/** @param {any} v @param {any} [orig] @param {Iterable} [takenIds] */
export function buildAppItem(v, orig, takenIds = []) {
- if (!v.label) return { error: 'Name required' };
- if (!v.href) return { error: 'URL required' };
+ /* A key, not a sentence. This module stays free of imports. */
+ if (!v.label) return { errorKey: 'toast.nameRequired' };
+ if (!v.href) return { errorKey: 'toast.urlRequired' };
const DEFCOL = '#0289ff';
/* One badges any count above zero, which is the default, so it is not stored. */
const custMin = Number.isFinite(v.custMin) && v.custMin > 1 ? Math.floor(v.custMin) : undefined;
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index d3fed7b..548323a 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=52e149f5';
+import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=d2e8b6dc';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import {
@@ -7,9 +7,11 @@ import {
clearsStoredPassword,
createDirtyTracker,
BLOCK,
-} from '/js/admin-logic.js?v=e3673bd7';
+} from '/js/admin-logic.js?v=cbb7417d';
import { confirmText } from '/js/modal.js?v=11fa1eff';
-import { el, inp, setUserText } from '/js/utils.js?v=55685187';
+import { el, inp, setUserText } from '/js/utils.js?v=eadafbcd';
+import { renderColorControl } from '/js/admin-color-control.js?v=e963853a';
+import { BACKDROP } from '/js/background.js?v=175d9a7f';
/* Mirrors the server's rule: auth cannot be switched on with no password. */
let _passwordSet = false;
@@ -52,7 +54,7 @@ const readWallpaperForm = () =>
_val('bg-col-inp', 'bg-col'),
_val('bg-url-inp', 'bg-url'),
_val('bg-fit'),
- _val('bg-color-inp', 'bg-color'),
+ _val('bg-color-val'),
_val('bg-apikey-inp', 'bg-apikey'),
]);
@@ -110,6 +112,15 @@ function syncSessionRows(now = false) {
reveal(el('revoke-tip-wrap'), canRevoke, now);
}
+/* Rebuild on every load. The control takes its value at render time and has no
+ setter, so reusing one shows a stale colour and saves it. */
+function renderBgColor(value) {
+ const slot = el('bg-color-slot');
+ if (!slot) return;
+ slot.textContent = '';
+ renderColorControl(slot, { value, idPrefix: 'bg-color', label: t('appearance.color') });
+}
+
export function loadSettings(c) {
const s = c.settings || {};
const ld = inp('set-lbl-d');
@@ -141,7 +152,7 @@ export function loadSettings(c) {
const apiEl = inp('bg-apikey-inp') || inp('bg-apikey');
if (apiEl) {
apiEl.placeholder = '●●●●●●●●●● (configured)';
- ag('/api/settings/unsplash-key')
+ apiGet('/api/settings/unsplash-key')
.then(d => {
const vEl = el('ie-apikey-v');
if (!d.configured) {
@@ -157,19 +168,17 @@ export function loadSettings(c) {
if (colEl) colEl.value = bg.collection || '';
const urlEl = inp('bg-url');
if (urlEl) urlEl.value = bg.url || '';
- const colorEl = inp('bg-color');
- if (colorEl) colorEl.value = bg.color || '';
const brEl = inp('bg-br');
const brVal = el('bg-br-val');
- function updateSliderFill(el) {
- if (!el) return;
- const min = parseFloat(el.min) || 0.1,
- max = parseFloat(el.max) || 1.0;
- const pct = ((parseFloat(el.value) - min) / (max - min)) * 100;
+ function updateSliderFill(slider) {
+ if (!slider) return;
+ const min = parseFloat(slider.min) || 0.1,
+ max = parseFloat(slider.max) || 1.0;
+ const pct = ((parseFloat(slider.value) - min) / (max - min)) * 100;
/* backgroundImage, never the background shorthand. The shorthand resets
background-clip, which is what keeps the track thin inside the 44px touch
target on a phone. */
- el.style.backgroundImage = `linear-gradient(var(--slider-dir), var(--ac) 0%, var(--ac) ${pct}%, var(--bd-inner) ${pct}%, var(--bd-inner) 100%)`;
+ slider.style.backgroundImage = `linear-gradient(var(--slider-dir), var(--ac) 0%, var(--ac) ${pct}%, var(--bd-inner) ${pct}%, var(--bd-inner) 100%)`;
}
if (brEl) {
brEl.value = bg.brightness ?? 0.62;
@@ -207,9 +216,8 @@ export function loadSettings(c) {
_si('bg-url-inp', s.background?.url || '');
_si('bg-fit', s.background?.fit === 'fit' ? 'fit' : 'fill');
showWallpaperFile(s.background?.url || '');
- _si('bg-color-inp', s.background?.color || '');
+ renderBgColor(s.background?.color || BACKDROP);
_sv('ie-bgurl-v', s.background?.url, 'Image URL');
- _sv('ie-bgcolor-v', s.background?.color, '#rrggbb or any CSS color');
const ipEl = inp('srv-ip');
if (ipEl) ipEl.value = s.server?.hostIp || '';
@@ -235,7 +243,7 @@ export function loadSettings(c) {
const secLogout = el('sec-logout');
const secRevoke = inp('sec-revoke');
secLogout?.addEventListener('click', async () => {
- await ap('/api/auth/logout', {}).catch(() => {});
+ await apiPost('/api/auth/logout', {}).catch(() => {});
location.reload();
});
secRevoke?.addEventListener('click', async () => {
@@ -249,7 +257,7 @@ export function loadSettings(c) {
if (!ok) return;
secRevoke.disabled = true;
try {
- await ap('/api/auth/revoke-sessions', {});
+ await apiPost('/api/auth/revoke-sessions', {});
toast(t('toast.sessionsRevoked'), 'ok');
} catch (e) {
toast(e.message || t('toast.saveFailed'), 'err');
@@ -267,7 +275,7 @@ export function loadSettings(c) {
async function syncAuthFromServer() {
let d;
try {
- d = await ag('/api/auth/check');
+ d = await apiGet('/api/auth/check');
} catch {
return;
}
@@ -303,12 +311,14 @@ export function showWallpaperFile(url) {
export function showBgFields(type) {
const host = el('bg-unsplash-fields')?.parentElement;
swapContent(host, () => {
- ['unsplash', 'url', 'color'].forEach(t => {
- const node = el(`bg-${t}-fields`);
- if (node) node.classList.toggle('d-none', t !== type);
+ ['unsplash', 'url', 'color'].forEach(kind => {
+ const node = el(`bg-${kind}-fields`);
+ if (node) node.classList.toggle('d-none', kind !== type);
});
const brRow = el('bg-brightness-row');
if (brRow) brRow.classList.toggle('d-none', type === 'color');
+ el('bgcol-hint')?.classList.toggle('d-none', type !== 'unsplash');
+ el('bg-url-hint')?.classList.toggle('d-none', type !== 'url');
});
}
/** @param {Event} [e] */
@@ -316,10 +326,10 @@ async function saveLabels(e) {
const toggled = /** @type {HTMLInputElement|null} */ (e?.target ?? null);
const wasChecked = toggled ? toggled.checked : false;
try {
- const c = await ag('/api/config');
+ const c = await apiGet('/api/config');
c.settings = c.settings || {};
c.settings.showLabels = { desktop: inp('set-lbl-d')?.checked !== false, ios: inp('set-lbl-m')?.checked || false };
- await ap('/api/config', c);
+ await apiPost('/api/config', c);
toast(t('toast.saved'));
} catch (err) {
/* Put the box back on a failure, or it shows a setting the server was never
@@ -332,10 +342,10 @@ async function saveKeepAwake(e) {
const toggled = /** @type {HTMLInputElement|null} */ (e?.target ?? null);
const wasChecked = toggled ? toggled.checked : false;
try {
- const c = await ag('/api/config');
+ const c = await apiGet('/api/config');
c.settings = c.settings || {};
c.settings.keepAwake = !!inp('set-awake')?.checked;
- await ap('/api/config', c);
+ await apiPost('/api/config', c);
toast(t('toast.saved'));
} catch (err) {
/* Put the box back on a failure, or it shows a setting the server was never
@@ -355,17 +365,17 @@ async function saveWallpaper() {
bg.url = (inp('bg-url-inp') || inp('bg-url'))?.value?.trim() || '';
bg.fit = inp('bg-fit')?.value === 'fit' ? 'fit' : 'fill';
} else if (type === 'color') {
- bg.color = (inp('bg-color-inp') || inp('bg-color'))?.value?.trim() || '';
+ bg.color = inp('bg-color-val')?.value?.trim() || '';
}
- const c = await ag('/api/config');
+ const c = await apiGet('/api/config');
c.settings = c.settings || {};
c.settings.background = bg;
- await ap('/api/config', c);
+ await apiPost('/api/config', c);
/* After the main config. GET /api/config strips the key, so a config write
that follows would overwrite it with nothing. */
if (type === 'unsplash') {
const keyVal = (inp('bg-apikey-inp') || inp('bg-apikey'))?.value?.trim() || '';
- if (keyVal) await ap('/api/settings/unsplash-key', { apiKey: keyVal });
+ if (keyVal) await apiPost('/api/settings/unsplash-key', { apiKey: keyVal });
}
_bgTrack?.reset();
toast(t('toast.saved'));
@@ -401,7 +411,7 @@ async function saveServer() {
}
let probe;
try {
- probe = await ap('/api/docker/test', { url });
+ probe = await apiPost('/api/docker/test', { url });
} catch (e) {
toast(t('toast.saveFailed', { err: e.message }), 'err');
return;
@@ -436,7 +446,7 @@ async function saveServer() {
}
try {
- const c = await ag('/api/config');
+ const c = await apiGet('/api/config');
c.settings = c.settings || {};
const prevLang = c.settings.language || 'en';
const dockerEnabled = inp('srv-docker-en')?.checked || false;
@@ -451,17 +461,17 @@ async function saveServer() {
c.settings.language = inp('lang-sel')?.value || 'en';
const langChanged = c.settings.language !== prevLang;
- await ap('/api/config', c);
+ await apiPost('/api/config', c);
if (shouldWritePassword({ enabled, newPassword: pw })) {
- await ap('/api/auth/set-password', { password: pw });
+ await apiPost('/api/auth/set-password', { password: pw });
const pwEl = inp('sec-pw');
if (pwEl) {
pwEl.value = '';
pwEl.placeholder = '●●●●●●●●●● (configured)';
}
}
- await ap('/api/auth/toggle', { enabled });
+ await apiPost('/api/auth/toggle', { enabled });
if (!enabled) {
const pwEl = inp('sec-pw');
if (pwEl) {
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index 6643998..32fc5f0 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -1,20 +1,20 @@
/* 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=55685187';
+import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=cbb7417d';
+import { el, q } from '/js/utils.js?v=eadafbcd';
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';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
export const API = '';
let tt;
let _toastWired = false;
-/** @param {string} m @param {'ok'|'err'} [t] @returns {void} */
-export const toast = (m, t = 'ok') => {
+/** @param {string} m @param {'ok'|'err'} [tone] @returns {void} */
+export const toast = (m, tone = 'ok') => {
const e = el('toast');
e.textContent = m;
- e.className = `show ${t}`;
+ e.className = `show ${tone}`;
clearTimeout(tt);
if (!_toastWired) {
_toastWired = true;
@@ -33,20 +33,23 @@ export const toast = (m, t = 'ok') => {
e.className = '';
});
}
- const ms = toastHoldMs(t, m, 'show');
+ const ms = toastHoldMs(tone, m, 'show');
if (ms != null) tt = setTimeout(() => (e.className = ''), ms);
};
-/* Carry `kind` and `detail`, so callers branch on data, never on message
- text. */
+/* Carry `kind`, `code` and `detail`, so callers branch on data, never on
+ message text. Dropping a field here fails silently: the advice falls back to
+ the kind and the screen says something true but never the specific sentence. */
/** An error carrying the API's structured fields.
- @typedef {Error & { status?: number, kind?: string, detail?: Record }} ApiError */
+ @typedef {Error & { status?: number, kind?: string, code?: string,
+ detail?: Record }} ApiError */
/** @param {number} status @param {any} body @returns {ApiError} */
function tagged(status, body) {
const e = /** @type {ApiError} */ (new Error((body && body.error) || 'HTTP ' + status));
e.status = status;
if (body && typeof body.kind === 'string') e.kind = body.kind;
+ if (body && typeof body.code === 'string') e.code = body.code;
if (body && body.detail && typeof body.detail === 'object') e.detail = body.detail;
return e;
}
@@ -75,22 +78,22 @@ function reauthenticate() {
/* Retried once only. A second 401 after a successful sign-in is the server
refusing the request itself. */
-export const ag = async (p, recover = true) => {
+export const apiGet = async (p, recover = true) => {
const r = await fetch(API + p, { cache: 'no-store' });
- if (recover && recoversSession(p, r.status) && (await reauthenticate())) return ag(p, false);
+ if (recover && recoversSession(p, r.status) && (await reauthenticate())) return apiGet(p, false);
if (!r.ok) {
const d = r.status === 401 ? null : await r.json().catch(() => null);
throw tagged(r.status, d || (r.status === 401 ? { error: 'Unauthorised', kind: 'auth' } : null));
}
return r.json();
};
-export const ap = async (p, b, recover = true) => {
+export const apiPost = async (p, b, recover = true) => {
const r = await fetch(API + p, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(b),
});
- if (recover && recoversSession(p, r.status) && (await reauthenticate())) return ap(p, b, false);
+ if (recover && recoversSession(p, r.status) && (await reauthenticate())) return apiPost(p, b, false);
if (!r.ok) {
const d = await r.json().catch(() => null);
throw tagged(r.status, d || (r.status === 401 ? { error: 'Unauthorised', kind: 'auth' } : null));
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 6087cf5..efc1580 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,12 +1,12 @@
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 { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=d2e8b6dc';
+import { createListbox } from '/js/listbox.js?v=f057351d';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=73604add';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=e3673bd7';
+import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=cbb7417d';
import { t } from '/js/i18n.js?v=1f1ea9c1';
-import { q, qi, qa } from '/js/utils.js?v=55685187';
-import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { q, qi, qa } from '/js/utils.js?v=eadafbcd';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']);
diff --git a/public/js/admin.js b/public/js/admin.js
index db66105..564171f 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,7 +1,7 @@
-import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=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 { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=58e2b685';
+import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=2bd61e6c';
+import { initList, render, syncFilterUI } from '/js/admin-list.js?v=4e82ea4a';
+import { resolveAdminSection } from '/js/admin-logic.js?v=cbb7417d';
import {
buildAppItem,
claimFolderChildren,
@@ -9,18 +9,26 @@ import {
saveWithRevert,
snapshotItems,
upsertItem,
-} 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';
+} from '/js/admin-save-logic.js?v=60a82419';
+import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=23cfe19f';
+import {
+ apiGet,
+ apiPost,
+ initInlineEdit,
+ paintIcon,
+ reveal,
+ setReauthHandler,
+ toast,
+} from '/js/admin-shared.js?v=d2e8b6dc';
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=17a37df4';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=fd3dcc6c';
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=32f787c3';
+import { createListbox } from '/js/listbox.js?v=f057351d';
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';
-import { ensureSprite, iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { ensureSprite, iconSvg } from '/js/icon-set.js?v=08b74a28';
import {
clearSkipTls,
convert,
@@ -29,7 +37,7 @@ import {
NOTE,
parseErrorsAsSkipped,
SKIP,
-} from '/js/import-foreign.js?v=d154ca56';
+} from '/js/import-foreign.js?v=f9c0a120';
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607';
import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff';
import {
@@ -40,11 +48,10 @@ import {
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';
+} from '/js/theme.js?v=eeafa4b5';
+import { el, inp, q, qa, clr, setUserText, tgt } from '/js/utils.js?v=eadafbcd';
+import { applyBackground, resolveBackground } from '/js/background.js?v=175d9a7f';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c';
-import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
ensureSprite();
@@ -63,7 +70,7 @@ onLayoutChange(_syncMobile, _mobileAtLoad);
async function load() {
await loadLocalIcons();
- const c = await ag('/api/config');
+ const c = await apiGet('/api/config');
state.items = c.items || [];
state._settings = c.settings || {};
await initI18n(c.settings?.language || 'en');
@@ -71,7 +78,7 @@ async function load() {
initVersion();
syncPickerLabels();
try {
- const wr = await ag('/api/widgets');
+ const wr = await apiGet('/api/widgets');
state._widgetReg = Object.create(null);
(wr.widgets || []).forEach(w => {
state._widgetReg[w.name] = w;
@@ -92,40 +99,13 @@ async function load() {
}
async function applyBg() {
- const root = document.documentElement;
+ const s = (state._settings && state._settings.background) || {};
+ let bg = null;
try {
- const bg = (state._settings && state._settings.background) || {};
- if (bg.type === 'color' && bg.color) {
- root.style.setProperty('--bg-image', 'none');
- root.style.setProperty('--bg-color', String(bg.color).replace(/[^a-zA-Z0-9#(),.\s%]/g, ''));
- root.style.setProperty('--bg-brightness', '1');
- root.style.setProperty('--bg-size', 'cover');
- } else if (bg.type === 'url' && bg.url) {
- root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(bg.url)}')`);
- root.style.setProperty('--bg-color', '#0d1117');
- root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62));
- root.style.setProperty('--bg-size', bg.fit === 'fit' ? 'contain' : 'cover');
- } else if (bg.type === 'unsplash') {
- let url = loadWallpaper(bg);
- if (!url) {
- const r = await fetch('/api/wallpaper', { cache: 'no-store' });
- const d = await r.json();
- url = d.url || null;
- if (url) saveWallpaper(url, bg);
- }
- if (url) {
- const shown = url;
- const img = new Image();
- img.onload = () => {
- root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`);
- root.style.setProperty('--bg-color', '#0d1117');
- root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62));
- root.style.setProperty('--bg-size', 'cover');
- };
- img.src = shown;
- }
- }
+ bg = await resolveBackground(s);
} catch {}
+ if (bg) applyBackground(document.documentElement, bg);
+ else if (s.type === 'unsplash') toast(t('toast.wallpaperUnavailable'), 'err');
}
/** Returns whether the write reached the server. */
async function save() {
@@ -133,9 +113,9 @@ async function save() {
state.saving = true;
let ok = false;
try {
- const full = await ag('/api/config');
+ const full = await apiGet('/api/config');
full.items = state.items;
- await ap('/api/config', full);
+ await apiPost('/api/config', full);
_savedItems = JSON.stringify(state.items);
toast(t('toast.saved'));
ok = true;
@@ -157,14 +137,14 @@ async function appendAndSave(newItems) {
if (state.saving) throw new Error('A save is already in progress');
state.saving = true;
try {
- const full = await ag('/api/config');
+ const full = await apiGet('/api/config');
const current = Array.isArray(full.items) ? full.items : [];
/* Ids were allocated against the list the preview was built from. */
const taken = new Set(current.map(i => i && i.id));
const clash = newItems.find(i => taken.has(i.id));
if (clash) throw new Error(`${clash.label}: this id already exists. Reload and import again.`);
full.items = [...current, ...newItems];
- await ap('/api/config', full);
+ await apiPost('/api/config', full);
state.items = full.items;
_savedItems = JSON.stringify(state.items);
syncDashSave();
@@ -409,7 +389,7 @@ function openFolderPicker(appId, targetFolderId = null) {
});
const ri = document.createElement('span');
ri.className = 'fp-ic';
- ri.style.background = rc(app.color);
+ ri.style.background = clr(app.color);
paintIcon(ri, app.iconUrl, (app.label || '?')[0]);
const nm = document.createElement('span');
nm.className = 'fp-nm';
@@ -598,8 +578,8 @@ async function doSave(orig) {
orig,
state.items.map(i => i.id),
);
- if (res.error) {
- toast(res.error, 'err');
+ if (res.errorKey) {
+ toast(t(res.errorKey), 'err');
return;
}
item = res.item;
@@ -689,26 +669,11 @@ function initAllInlineEdits() {
fetchWallpaperLink(v.trim());
},
});
-
- const colorInp = document.createElement('input');
- colorInp.id = 'bg-color-inp';
- document.body.appendChild(colorInp);
- initInlineEdit('ie-bgcolor', 'bg-color-inp', {
- placeholder: '#0d1117',
- onCommit(val) {
- if (!val) return;
- const { value, ok } = normalizeColorInput(val);
- if (!ok) return toast(t('toast.colorInvalid'), 'err');
- colorInp.value = value;
- const rv = q('#ie-bgcolor .rv');
- if (rv) rv.textContent = value;
- },
- });
}
async function initVersion() {
try {
- const d = await ag('/api/version');
+ const d = await apiGet('/api/version');
const v = (d.current || d.version || '').replace(/^v/i, '');
if (v) {
const vEl = el('sidebar-version');
@@ -766,10 +731,6 @@ function initBgType() {
const apply = val => {
hidden.value = val;
showBgFields(val);
- const hint = el('bgcol-hint');
- if (hint) hint.style.display = val === 'unsplash' ? '' : 'none';
- const imgHint = el('bg-url-hint');
- if (imgHint) imgHint.style.display = val === 'url' ? '' : 'none';
};
const box = createListbox({
id: 'bg-type',
@@ -1027,7 +988,7 @@ addEventListener('beforeunload', e => {
el('btn-exp').onclick = async () => {
let url;
try {
- const config = await ag('/api/config/export');
+ const config = await apiGet('/api/config/export');
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -1084,8 +1045,8 @@ el('imp').onchange = async e => {
const before = snapshotItems(state.items);
state.items = d.items;
if (await saveOrRevert(before)) toast(t('toast.imported'));
- } catch (e) {
- toast(t('toast.importFailed', { err: e.message }), 'err');
+ } catch (err) {
+ toast(t('toast.importFailed', { err: err.message }), 'err');
}
tgt(e).value = '';
};
diff --git a/public/js/background.js b/public/js/background.js
new file mode 100644
index 0000000..b76503d
--- /dev/null
+++ b/public/js/background.js
@@ -0,0 +1,75 @@
+// @ts-check
+import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=eadafbcd';
+import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
+
+export const BACKDROP = '#0d1117';
+
+const DEFAULT_BRIGHTNESS = 0.62;
+
+/** @typedef {{ image: string, color: string, brightness: string, size: string,
+ url: string|null, fit: 'fit'|'fill' }} Background */
+
+/** @param {any} bg @param {string|null} [wallpaperUrl]
+ @returns {Background|null} */
+export function backgroundFor(bg, wallpaperUrl = null) {
+ const s = bg || {};
+ if (s.type === 'color' && s.color) {
+ return {
+ image: 'none',
+ color: cssColor(s.color, BACKDROP),
+ brightness: '1',
+ size: 'cover',
+ url: null,
+ fit: 'fill',
+ };
+ }
+ const url = s.type === 'url' ? s.url : s.type === 'unsplash' ? wallpaperUrl : null;
+ if (!url) return null;
+ const fit = s.type === 'url' && s.fit === 'fit' ? 'fit' : 'fill';
+ const brightness = Number(s.brightness ?? DEFAULT_BRIGHTNESS);
+ return {
+ image: `url('${sanitizeCssUrl(url)}')`,
+ color: BACKDROP,
+ brightness: String(Number.isFinite(brightness) ? brightness : DEFAULT_BRIGHTNESS),
+ size: fit === 'fit' ? 'contain' : 'cover',
+ url: sanitizeCssUrl(url),
+ fit,
+ };
+}
+
+/** @param {HTMLElement} root @param {Background} b */
+export function applyBackground(root, b) {
+ root.style.setProperty('--bg-image', b.image);
+ root.style.setProperty('--bg-color', b.color);
+ root.style.setProperty('--bg-brightness', b.brightness);
+ root.style.setProperty('--bg-size', b.size);
+}
+
+async function unsplashUrl(bg) {
+ const cached = loadWallpaper(bg);
+ if (cached) return cached;
+ const r = await fetch('/api/wallpaper', { cache: 'no-store' });
+ const d = await r.json();
+ const url = d.url || null;
+ if (url) saveWallpaper(url, bg);
+ return url;
+}
+
+const decoded = url =>
+ new Promise(resolve => {
+ const img = new Image();
+ img.onload = () => resolve(true);
+ img.onerror = () => resolve(false);
+ img.src = url;
+ });
+
+/** @param {any} bg @returns {Promise} */
+export async function resolveBackground(bg) {
+ const s = bg || {};
+ if (s.type === 'unsplash') {
+ const url = await unsplashUrl(s);
+ if (!url) return null;
+ return (await decoded(url)) ? backgroundFor(s, url) : null;
+ }
+ return backgroundFor(s);
+}
diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js
index bc61304..e02a1d5 100644
--- a/public/js/badge-logic.js
+++ b/public/js/badge-logic.js
@@ -64,7 +64,7 @@ export function resolveColor(c, palette) {
/** 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, palette) {
+export function cssUsableColor(c, palette) {
const v = resolveColor(c, palette);
if (!v) return '';
const supports = globalThis.CSS?.supports;
@@ -147,7 +147,7 @@ export function firingLabels(labels, values, palette) {
name: String(l.name || l.unit || l.path),
value: v,
unit: String(l.unit || ''),
- color: safeColor(l.color, palette) || safeColor('info', palette) || LABEL_DEFAULT_COLOR,
+ color: cssUsableColor(l.color, palette) || cssUsableColor('info', palette) || LABEL_DEFAULT_COLOR,
});
}
return out;
@@ -211,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, palette);
+ bg = cssUsableColor(custom.color, palette);
} else if (fixed) {
cls = 'badge on blue';
num = staticBdg.label.slice(0, 10);
- bg = safeColor(staticBdg.color, palette);
+ bg = cssUsableColor(staticBdg.color, palette);
} else if (!hideHealthy && hasHC) {
cls = 'badge on green';
} else {
@@ -257,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, palette) });
+ if (fixed) rows.push({ name: staticBdg.label, value: '', unit: '', color: cssUsableColor(staticBdg.color, palette) });
const more = Math.max(0, rows.length - 1);
if (more) {
diff --git a/public/js/brand-mark.js b/public/js/brand-mark.js
index 1e4c4ff..a82b167 100644
--- a/public/js/brand-mark.js
+++ b/public/js/brand-mark.js
@@ -3,4 +3,4 @@
name left there shadows the bundled artwork. */
export const BRAND_MARK =
- 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPgoKPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQuNiw3My40KSBzY2FsZSgwLjg0NikiPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMTAyNCkgc2NhbGUoMC4xLC0wLjEpIj48cGF0aCBmaWxsPSIjMDBEMkUwIiBkPSJNNDk3OCA5MTgwIGMtNzUgLTEzIC0xNDYgLTM5IC0yMzMgLTgzIC0xMTIgLTU3IC0yNDQgLTE5MCAtMzAyIC0zMDIgLTcwIC0xMzUgLTg4IC0yMTAgLTg4IC0zNjAgMCAtMTUwIDE4IC0yMjUgODggLTM2MCA1OCAtMTEyIDE5MCAtMjQ0IDMwMiAtMzAyIDE0MSAtNzIgMTkzIC04NCA0MzAgLTkzIDM1NCAtMTUgNjAyIC02NCA4ODUgLTE3NyA4MDMgLTMxOSAxNDAwIC0xMDI4IDE1NjkgLTE4NjMgMzkgLTE5NSA0NiAtMjcyIDQ2IC01MjAgMCAtMTU1IC01IC0yNzggLTE0IC0zMzUgLTUwIC0zMzIgLTEyOSAtNTc4IC0yODEgLTg3MCAtMTA4IC0yMDcgLTEyNSAtMjY2IC0xMjUgLTQzMCAwIC0xNTAgMTggLTIyNSA4OCAtMzYwIDU4IC0xMTIgMTkwIC0yNDQgMzAyIC0zMDIgMTM1IC03MCAyMTAgLTg4IDM2MCAtODggMTA4IDAgMTQzIDQgMjA1IDI0IDE3NCA1NCAzMzAgMTY3IDQyNCAzMDkgNjUgMTAwIDE5NSAzNjYgMjY5IDU1MiAxMzUgMzQzIDIxOSA2NzcgMjYzIDEwNTUgMTMgMTExIDE4IDIyNiAxOCA0NDUgMCAzMTggLTEzIDQ3MyAtNjAgNzMwIC0xMDIgNTUwIC0zMDkgMTA2NCAtNjE3IDE1MjUgLTY2OCAxMDAyIC0xNzQyIDE2NTYgLTI5MzcgMTc5MCAtMTY5IDE5IC01MjQgMjggLTU5MiAxNXoiLz48cGF0aCBmaWxsPSIjRjJGMkY3IiBkPSJNMjEzMSA3NTQ5IGMtMjg3IC01MSAtNTI5IC0yNjQgLTYyMyAtNTQ5IC0yOSAtODcgLTMyIC0xMDYgLTMyIC0yMzAgMCAtMTExIDMgLTE0OSAyMiAtMjEzIDU1IC0xOTEgMTczIC0zNTQgMzMwIC00NTQgMzIgLTIxIDQyNiAtMjQ5IDg3NSAtNTA4IDcwNyAtNDA3IDgxNCAtNDcyIDc5OSAtNDgzIC05IC03IC0zOTYgLTIzMiAtODYxIC01MDAgLTc0NyAtNDMxIC04NTIgLTQ5NSAtOTE3IC01NTUgLTMxNCAtMjkzIC0zMzggLTc2MyAtNTYgLTExMDQgODUgLTEwMyAyNTcgLTIxMCA0MDIgLTI0OSA0NyAtMTMgMTAyIC0xOCAxOTUgLTE4IDE0NiAwIDIxNCAxNCAzMzAgNjkgMzkgMTggNDQxIDI0NyA4OTQgNTA5IDQ1MyAyNjIgODI2IDQ3NiA4MjggNDc2IDEgMCAzIC00NDUgMyAtOTg5IDAgLTEwNzkgLTEgLTEwNjMgNTggLTEyMTMgNzcgLTE5OSAyNDkgLTM2OSA0NDggLTQ0NCA0NzAgLTE3NyA5NzYgMTA0IDEwNzAgNTkzIDEyIDYzIDE0IDMzOSAxNCAxNzIzIDAgMTA2NCAtNCAxNjg1IC0xMCAxNzUxIC0yNSAyNDcgLTExOSA0MzUgLTI4NCA1NzEgLTYxIDUxIC0yODQ0IDE2NjYgLTMwMTQgMTc0OSAtNDAgMjEgLTEwNCA0NSAtMTQwIDU0IC03OSAyMCAtMjU1IDI4IC0zMzEgMTR6Ii8+PC9nPjwvZz4KPC9zdmc+Cg==';
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPgoKPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjQuNiw3My40KSBzY2FsZSgwLjg0NikiPjxwYXRoIGZpbGw9IiMwMEQyRTAiIGQ9Ik01MTIgMTA1LjVBNDA2LjUgNDA2LjUgMCAwIDEgODY0LjA0IDcxNS4yNUE3NS41IDc1LjUgMCAwIDEgNzMzLjI3IDYzOS43NUEyNTUuNSAyNTUuNSAwIDAgMCA1MTIgMjU2LjVBNzUuNSA3NS41IDAgMCAxIDUxMiAxMDUuNVoiLz48cGF0aCBmaWxsPSIjRjJGMkY3IiBkPSJNNTUxLjUgNDQzLjU4TDI2NC44NSAyNzguMDhBNzkgNzkgMCAwIDAgMTg1Ljg1IDQxNC45Mkw0NzIuNSA1ODAuNDJBNzkgNzkgMCAwIDAgNTUxLjUgNDQzLjU4Wk00NzIuNSA0NDMuNThMMTg1Ljg1IDYwOS4wOEE3OSA3OSAwIDAgMCAyNjQuODUgNzQ1LjkyTDU1MS41IDU4MC40MkE3OSA3OSAwIDAgMCA0NzIuNSA0NDMuNThaTTQzMyA1MTJMNDMzIDg0M0E3OSA3OSAwIDAgMCA1OTEgODQzTDU5MSA1MTJBNzkgNzkgMCAwIDAgNDMzIDUxMloiLz48L2c+Cjwvc3ZnPgo=';
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 4d8ed12..aeb9726 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -11,6 +11,7 @@ import {
} from '/js/widget-types.js?v=9264dee5';
import {
el,
+ ICON_R,
isDashboardEmpty,
mk,
mkWrap as _mkWrap,
@@ -20,13 +21,12 @@ import {
q,
qa,
qi,
- sanitizeCssUrl,
setUserText,
teardownWidgets,
titleWhenTruncated,
-} from '/js/utils.js?v=55685187';
+} from '/js/utils.js?v=eadafbcd';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
-import { initSpotlight } from '/js/spotlight.js?v=f050b4e5';
+import { initSpotlight } from '/js/spotlight.js?v=9056fab5';
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';
@@ -40,8 +40,8 @@ import {
buildMobile,
resetMobileChrome,
mkFolderGlyph,
-} from '/js/ui.js?v=d8363297';
-import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=ad283693';
+} from '/js/ui.js?v=cdfe7685';
+import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b';
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';
@@ -52,14 +52,14 @@ import {
landingAfterSetup,
restorePage,
} from '/js/dashboard-logic.js?v=0d519f8b';
-import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
+import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=175d9a7f';
import { jitter } from '/js/jitter.js?v=4eeef4c9';
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 { ensureSprite, iconSvg } from '/js/icon-set.js?v=08b74a28';
import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43';
-import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=787bfdff';
+import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5';
ensureSprite();
@@ -69,10 +69,6 @@ let MOB = isMobileLayout();
const wCols = { d: WIDGET_COLS.desktop, m: WIDGET_COLS.mobile };
const wRows = { d: WIDGET_ROWS.desktop, m: WIDGET_ROWS.mobile };
const WH = { d: WIDGET_HEIGHTS };
-/* An app icon's corner is 22.37% of its width. A fixed radius only lands on that
- at one icon size, and the grid draws two: 72 with a label under it, 78
- without. */
-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;
@@ -166,16 +162,16 @@ const BEL = new Map();
/* Last painted appearance per badge element. Without it the same values are
rewritten thousands of times a session. */
const BSIG = new WeakMap();
-function breg(id, el) {
+function breg(id, badge) {
if (!BEL.has(id)) BEL.set(id, new Set());
- BEL.get(id).add(el);
+ BEL.get(id).add(badge);
/* Paint on registration. A poll only reaches ids it returns, so a fixed label
alone stays invisible, and a rebuild blanks every badge until the next
poll. */
bupd(id);
}
-function bunreg(id, el) {
- if (BEL.has(id)) BEL.get(id).delete(el);
+function bunreg(id, badge) {
+ if (BEL.has(id)) BEL.get(id).delete(badge);
}
function bupd(id) {
const els = BEL.get(id);
@@ -209,18 +205,18 @@ function bupd(id) {
const sig = badgeSignature({ cls, txt, unit, bg, aria, color, nextColor, rows });
- els.forEach(el => {
- if (BSIG.get(el) === sig) return;
- BSIG.set(el, sig);
- el.className = cls;
- let txtEl = el.firstElementChild;
+ els.forEach(badge => {
+ if (BSIG.get(badge) === sig) return;
+ BSIG.set(badge, sig);
+ badge.className = cls;
+ let txtEl = badge.firstElementChild;
let unitEl = txtEl?.nextElementSibling;
if (!txtEl) {
txtEl = document.createElement('span');
txtEl.className = 'badge-txt';
unitEl = document.createElement('span');
unitEl.className = 'badge-unit';
- el.append(txtEl, unitEl);
+ badge.append(txtEl, unitEl);
}
txtEl.textContent = num;
unitEl.textContent = unit ? ' ' + unit : '';
@@ -229,24 +225,24 @@ function bupd(id) {
badge left to speak for itself is never reached by a reader moving from
tile to tile. A name change is silent, which is what a figure on a timer
needs. */
- el.setAttribute('aria-hidden', 'true');
- el.removeAttribute('role');
- el.removeAttribute('aria-label');
- const tile = /** @type {HTMLElement|null} */ (el.closest('a, button, [role="button"]'));
+ badge.setAttribute('aria-hidden', 'true');
+ badge.removeAttribute('role');
+ badge.removeAttribute('aria-label');
+ const tile = /** @type {HTMLElement|null} */ (badge.closest('a, button, [role="button"]'));
const tileName = tile?.dataset.tileName;
if (tile && tileName) tile.setAttribute('aria-label', aria ? `${tileName}, ${aria}` : tileName);
/* Never the `background` shorthand. It resets background-clip, and the
pill behind is painted from this same value. */
- if (bg) el.style.setProperty('--badge-bg', bg);
- else el.style.removeProperty('--badge-bg');
- el.style.color = color;
- if (nextColor) el.style.setProperty('--badge-next', nextColor);
- else el.style.removeProperty('--badge-next');
+ if (bg) badge.style.setProperty('--badge-bg', bg);
+ else badge.style.removeProperty('--badge-bg');
+ badge.style.color = color;
+ if (nextColor) badge.style.setProperty('--badge-next', nextColor);
+ else badge.style.removeProperty('--badge-next');
/* No title attribute. The popover carries the reason, and a badge that can
be hovered would otherwise draw the browser's own tooltip beside it.
Removed rather than skipped: one set by an earlier paint would persist. */
- el.removeAttribute('title');
- wireBadgePopover(el, popover ? rows : null);
+ badge.removeAttribute('title');
+ wireBadgePopover(badge, popover ? rows : null);
});
}
@@ -428,7 +424,7 @@ function buildDesktop() {
dk.replaceChildren();
dock.forEach(item => dk.appendChild(mkDock(item)));
dk.hidden = !dock.length;
- observeGlass(dk, 45, 0.2);
+ observeGlass(dk, 38, 0.6);
}
/* Every page is mounted at once, so widgets the user has swiped away from keep
@@ -489,8 +485,8 @@ function goTo(n, dotEls, announce = true) {
storeSet(PAGE_STORE, String(pg));
const strip = el('pages');
syncPageInert(pg);
- const t = `translateX(${-pageDir() * pg * 100}vw)`;
- strip.style.transform = strip.style.webkitTransform = t;
+ const shift = `translateX(${-pageDir() * pg * 100}vw)`;
+ strip.style.transform = strip.style.webkitTransform = shift;
strip.style.willChange = 'transform';
strip.addEventListener(
'transitionend',
@@ -537,7 +533,6 @@ function syncMobPages() {
/* Matches --bg-base in tokens.css. It shows wherever a fitted wallpaper does
not reach. */
-const WALLPAPER_BACKDROP = '#0d1117';
/* A sampled wallpaper grid, or one tone for a solid colour. Null on both leaves
the labels as they are. */
@@ -574,7 +569,7 @@ function resampleBg() {
window.innerHeight,
_bgSample.brightness,
_bgSample.fit,
- WALLPAPER_BACKDROP,
+ BACKDROP,
),
tone: themeTone(),
};
@@ -585,48 +580,15 @@ async function applyBg() {
const root = document.documentElement;
bgTone = { grid: null, tone: themeTone() };
retone();
- try {
- const bg = S.background || {};
- if (bg.type === 'color' && bg.color) {
- const safeColor = String(bg.color).replace(/[^a-zA-Z0-9#(),.\s%]/g, '');
- root.style.setProperty('--bg-image', 'none');
- root.style.setProperty('--bg-color', safeColor);
- root.style.setProperty('--bg-brightness', '1');
- root.style.setProperty('--bg-size', 'cover');
- bgTone = { grid: null, tone: toneForColor(safeColor) ?? themeTone() };
- retone();
- } else if (bg.type === 'url' && bg.url) {
- const url = sanitizeCssUrl(bg.url);
- const brightness = Number(bg.brightness ?? 0.62);
- const fit = bg.fit === 'fit' ? 'fit' : 'fill';
- root.style.setProperty('--bg-image', `url('${url}')`);
- root.style.setProperty('--bg-color', WALLPAPER_BACKDROP);
- root.style.setProperty('--bg-brightness', String(brightness));
- root.style.setProperty('--bg-size', fit === 'fit' ? 'contain' : 'cover');
- sampleWallpaper(url, brightness, fit);
- } else if (bg.type === 'unsplash') {
- let url = loadWallpaper(bg);
- if (!url) {
- const r = await fetch('/api/wallpaper', { cache: 'no-store' });
- const d = await r.json();
- url = d.url || null;
- if (url) saveWallpaper(url, bg);
- }
- if (url) {
- const shown = url;
- const brightness = Number(bg.brightness ?? 0.62);
- const img = new Image();
- img.onload = () => {
- root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`);
- root.style.setProperty('--bg-color', WALLPAPER_BACKDROP);
- root.style.setProperty('--bg-brightness', String(brightness));
- root.style.setProperty('--bg-size', 'cover');
- sampleWallpaper(sanitizeCssUrl(shown), brightness, 'fill');
- };
- img.src = shown;
- }
- }
- } catch {}
+ const bg = await resolveBackground(S.background || {});
+ if (!bg) return;
+ applyBackground(root, bg);
+ if (bg.url) {
+ sampleWallpaper(bg.url, Number(bg.brightness), bg.fit);
+ return;
+ }
+ bgTone = { grid: null, tone: toneForColor(bg.color) ?? themeTone() };
+ retone();
}
function refreshBadges() {
diff --git a/public/js/icon-set.js b/public/js/icon-set.js
index 172afc7..c1c6d8a 100644
--- a/public/js/icon-set.js
+++ b/public/js/icon-set.js
@@ -112,6 +112,34 @@ const TOGGLES = {
};
const CLOUD = 'M6 20h10a4 4 0 0 0 0-8 5 5 0 0 0-9.6 1.2A3.5 3.5 0 0 0 6 20z';
+/* Keep the fractions. Whole units drift from the logo files. */
+function logoIcon() {
+ const f = n => +n.toFixed(3);
+ const k = 24 / 1024;
+ const R = 331 * k;
+ const wA = 151 * k;
+ const wB = 158 * k;
+ const at = (deg, r) => [f(12 + r * Math.cos((deg * Math.PI) / 180)), f(12 + r * Math.sin((deg * Math.PI) / 180))];
+ const capsule = end => {
+ const [dx, dy] = [end[0] - 12, end[1] - 12];
+ const n = [f((-dy / R) * (wB / 2)), f((dx / R) * (wB / 2))];
+ const r = f(wB / 2);
+ const d = `M${12 + n[0]} ${12 + n[1]}L${f(end[0] + n[0])} ${f(end[1] + n[1])}A${r} ${r} 0 0 0 ${f(end[0] - n[0])} ${f(end[1] - n[1])}L${12 - n[0]} ${12 - n[1]}A${r} ${r} 0 0 0 ${12 + n[0]} ${12 + n[1]}Z`;
+ return shape(d, 'ink', { lineAlt: { k: 'line', d: `M12 12L${end}` } });
+ };
+ const [ro, ri, h] = [f(R + wA / 2), f(R - wA / 2), f(wA / 2)];
+ const arc = `M${at(-90, ro)}A${ro} ${ro} 0 0 1 ${at(30, ro)}A${h} ${h} 0 0 1 ${at(30, ri)}A${ri} ${ri} 0 0 0 ${at(-90, ri)}A${h} ${h} 0 0 1 ${at(-90, ro)}Z`;
+ return {
+ inkMask: false,
+ shapes: [
+ capsule(at(210, R)),
+ capsule(at(150, R)),
+ capsule(at(90, R)),
+ shape(arc, 'accent', { lineAlt: { k: 'line', d: `M${at(-90, R)}A${f(R)} ${f(R)} 0 0 1 ${at(30, R)}` } }),
+ ],
+ };
+}
+
function sizeIcon(k) {
const [x, y, w, h] = { small: [5, 5, 14, 14], medium: [2, 7, 20, 10], large: [3, 3, 18, 18], xlarge: [5, 2, 14, 20] }[
k
@@ -220,7 +248,14 @@ export const ICONS = {
'bright-hi': { shapes: [dot(12, 12, 4), line('M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1 1M18 18l1 1M5 19l1-1M18 6l1-1')] },
external: { shapes: [line('M14 4h6v6M20 4l-9 9'), line('M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4')] },
docs: { shapes: [rect(4, 2, 16, 20, T.R_MD), bar(7, 7, 6), bar(7, 11, 10), bar(7, 15, 8)] },
- issue: { shapes: [circle(12, 12, 10), line('M12 7v6', 'cut'), detail(11, 16, 2, 2, 1, 'cut')] },
+ bug: {
+ shapes: [
+ shape('M9 6a3 3 0 0 1 6 0z', 'accent'),
+ shape('M6 14a6 6 0 0 1 12 0v2a6 6 0 0 1-12 0z'),
+ line('M12 11v8', 'cut'),
+ line('M2 9l2 2M2 15h2M2 21l2-2M22 9l-2 2M22 15h-2M22 21l-2-2'),
+ ],
+ },
info: { shapes: [circle(12, 12, 10), line('M12 11v6', 'cut'), detail(11, 6, 2, 2, 1, 'cut')] },
heart: { shapes: [shape('M12 21l-8-8a5 5 0 0 1 8-6 5 5 0 0 1 8 6z')] },
grip: {
@@ -237,6 +272,11 @@ export const ICONS = {
search: { shapes: [ring(10, 10, 7), line('M15 15l6 6')] },
'arrow-down': { shapes: [line('M12 4v15M6 13l6 6 6-6')] },
'arrow-up': { shapes: [line('M12 20V5M6 11l6-6 6 6')] },
+
+ logo: logoIcon(),
+ start: { inkMask: false, shapes: [line('M12 2v9M8 7l4 4 4-4', 'accent'), rect(2, 14, 20, 8, 4), bar(5, 17, 6)] },
+ run: { gap: 0, shapes: [rect(2, 4, 20, 16, 4), line('M6 9l3 3-3 3', 'accent'), bar(12, 14, 6)] },
+ create: { shapes: [rect(3, 5, 16, 16, 4), line('M19 3v4M17 5h4', 'accent'), bar(6, 10, 6), bar(6, 14, 10)] },
};
export function iconSvg(id, px = 24, variant = 'solid', cls = '') {
diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js
index fb87f57..6097b16 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=858f3f84';
+import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=60a82419';
import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f';
export const SKIP = Object.freeze({
@@ -188,8 +188,8 @@ function addApp(col, group, { label, href, iconUrl, container, pingUrl, skipTlsV
null,
col.taken,
);
- if (built.error) {
- col.skip(SKIP.NO_HREF, label, group, built.error);
+ if (built.errorKey) {
+ col.skip(SKIP.NO_HREF, label, group, built.errorKey);
return null;
}
col.taken.add(built.item.id);
diff --git a/public/js/listbox.js b/public/js/listbox.js
index 91cf92f..4f4f102 100644
--- a/public/js/listbox.js
+++ b/public/js/listbox.js
@@ -6,11 +6,11 @@
Do not use popover. The top layer makes iOS collapse its browser toolbar,
which exposes a strip of page canvas and misplaces the menu. */
-import { nextActiveIndex } from '/js/admin-logic.js?v=e3673bd7';
+import { nextActiveIndex } from '/js/admin-logic.js?v=cbb7417d';
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=55685187';
-import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { qa } from '/js/utils.js?v=eadafbcd';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev');
diff --git a/public/js/mobile-metrics.js b/public/js/mobile-metrics.js
index 43eb283..a4966be 100644
--- a/public/js/mobile-metrics.js
+++ b/public/js/mobile-metrics.js
@@ -41,7 +41,7 @@ export function mobileMetrics(vw, docked = true) {
const sc = Math.min(vw / BASE_VW, MAX_SC);
const sm = Math.round(18 * sc);
const dh = docked ? Math.round(108 * sc) : 0;
- const pillH = Math.round(34 * sc);
+ const pillH = Math.round(30 * sc);
const pillGap = Math.round(10 * sc);
/* Reserving less lets the pill sit on top of the last row. */
const dz = pillGap + pillH + Math.round(8 * sc);
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index 99ae680..edd3ac5 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=55685187';
+import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=eadafbcd';
import { t } from '/js/i18n.js?v=1f1ea9c1';
/* Attached to the window so a re-open can undo the previous one. */
@@ -28,10 +28,10 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
res.setAttribute('role', 'listbox');
res.setAttribute('aria-label', t('home.searchResults'));
- const render = q => {
+ const render = query => {
res.replaceChildren();
const apps = getItems().filter(i => i.type === 'app' || i.type === 'folder');
- const qq = q.toLowerCase().trim();
+ const qq = query.toLowerCase().trim();
cur = qq ? apps.filter(a => (a.label || a.id).toLowerCase().includes(qq)) : apps;
si = 0;
if (!cur.length) {
@@ -65,7 +65,8 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
a.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
a.style.cssText =
'display:flex;align-items:center;gap:18px;padding:14px 20px;text-decoration:none;cursor:pointer;-webkit-tap-highlight-color:transparent;';
- const doOpen = () => {
+ /* A click also follows the link. Opening here as well opens two tabs. */
+ const doOpen = viaTouch => {
close();
if (isFolder) {
if (MOB()) {
@@ -74,15 +75,15 @@ export function initSpotlight({ getItems, isMob, CB, iconChain, openFolderDeskto
} else openFolderDesktop(app);
} else if (app.system === 'settings' && app.href) {
window.location.href = app.href;
- } else if (app.href) {
+ } else if (viaTouch && app.href) {
window.open(app.href, '_blank', 'noreferrer,noopener');
}
};
a.addEventListener('touchend', e => {
e.preventDefault();
- doOpen();
+ doOpen(true);
});
- a.onclick = doOpen;
+ a.onclick = () => doOpen(false);
const ic = mk('div');
ic.className = 'sri';
diff --git a/public/js/theme.js b/public/js/theme.js
index 3a8dec9..7394727 100644
--- a/public/js/theme.js
+++ b/public/js/theme.js
@@ -12,12 +12,10 @@ export function normaliseMode(value) {
return THEME_MODES.includes(/** @type {any} */ (value)) ? String(value) : 'system';
}
-/** The theme a mode resolves to.
- @param {string} mode
- @param {boolean} prefersDark whether the device asks for a dark appearance */
-export function resolveTheme(mode, prefersDark) {
+/** @param {string} mode @param {boolean} systemDark */
+export function resolveTheme(mode, systemDark) {
const m = normaliseMode(mode);
- if (m === 'system') return prefersDark ? 'dark' : 'light';
+ if (m === 'system') return systemDark ? 'dark' : 'light';
return m;
}
diff --git a/public/js/ui.js b/public/js/ui.js
index 4f897d2..810ff72 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -6,6 +6,7 @@ import {
isDashboardEmpty,
renderEmptyState,
mkWrap as _mkWrap,
+ ICON_R,
mountScaledWidget,
pageDir,
teardownWidgets,
@@ -13,10 +14,10 @@ import {
q,
qa,
setUserText,
-} from '/js/utils.js?v=55685187';
+} from '/js/utils.js?v=eadafbcd';
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 { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300';
import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1';
import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233';
@@ -62,9 +63,9 @@ if (!_win.__wActiveMsgBound) {
});
}
-function css(el, props) {
- for (const [k, v] of Object.entries(props)) el.style.setProperty(k, v);
- return el;
+function css(node, props) {
+ for (const [k, v] of Object.entries(props)) node.style.setProperty(k, v);
+ return node;
}
function mkMiniIcon(child, pointerEvents) {
@@ -110,7 +111,7 @@ export function mkFolderGlyph(folder, size) {
const wrap = mk('div');
wrap.className = 'dyn-fold-wrap glass-surface';
css(wrap, {
- '--br': Math.round(size * 0.24) + 'px',
+ '--br': Math.round(size * ICON_R) + 'px',
'--gap': Math.round(size * 0.04) + 'px',
'--pad': Math.round(size * 0.1) + 'px',
});
@@ -220,7 +221,7 @@ export function openFolderDesktop(folder) {
box.appendChild(grid);
const registeredBadges = [];
children.forEach(c => bupd(c.id));
- qa('.badge', grid).forEach(el => registeredBadges.push(el));
+ qa('.badge', grid).forEach(badge => registeredBadges.push(badge));
function closeDesk() {
ov.close();
}
@@ -229,7 +230,7 @@ export function openFolderDesktop(folder) {
let stopGlass = () => {};
ov.addEventListener('close', () => {
stopGlass();
- registeredBadges.forEach(el => BEL().forEach((_, id) => bunreg(id, el)));
+ registeredBadges.forEach(badge => BEL().forEach((_, id) => bunreg(id, badge)));
ov.remove();
folderOverlay = null;
});
@@ -311,7 +312,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
css(box, { '--sz': eff + 'px' });
box.style.pointerEvents = 'none';
const wrap = mkFolderGlyph(item, eff);
- wrap.appendChild(mkGlassRim(eff, eff, smoothRectPath(eff, eff, Math.round(eff * 0.24), 0)));
+ wrap.appendChild(mkGlassRim(eff, eff, smoothRectPath(eff, eff, Math.round(eff * ICON_R), 0)));
box.appendChild(wrap);
const fb_ = mk('div');
fb_.className = 'badge';
@@ -322,7 +323,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
const l = mk('div');
l.className = 'dyn-fold-label';
l.dir = 'auto';
- css(l, { '--lfs': Math.max(9, Math.round(9 * sc)) + 'px', '--lw': cw - 4 + 'px' });
+ css(l, { '--lfs': Math.max(11, Math.round(12 * sc)) + 'px', '--lw': cw - 4 + 'px' });
setUserText(l, item.label || t('type.folder'));
a.appendChild(l);
}
@@ -355,7 +356,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
/* A badge registered to an element that has gone keeps the dashboard
repainting it. */
ov.addEventListener('close', () => {
- qa('.badge', ov).forEach(el => BEL().forEach((_, id) => bunreg(id, el)));
+ qa('.badge', ov).forEach(badge => BEL().forEach((_, id) => bunreg(id, badge)));
ov.remove();
folderOverlayMob = null;
});
@@ -372,13 +373,13 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
const innerW = boxW - padH * 2,
gap = Math.round(14 * ptScale);
const folderIconW = Math.min(Math.floor((innerW - gap * 2) / 3), isz);
- const folderIr = Math.round(folderIconW * 0.22),
+ const folderIr = Math.round(folderIconW * ICON_R),
folderIm = Math.round(folderIconW * 0.64);
const gridInnerW = folderIconW * 3 + gap * 2,
gridH = folderIconW * 3 + gap * 2;
- /* A badge sits 7px outside its icon corner. Without this inset the outer
+ /* A badge sits 11px outside its icon corner. Without this inset the outer
icons' badges cross into the next page and the viewport shows them. */
- const badgeOvh = Math.min(Math.ceil(7 * (sc || 1)), padH, padVT, padVB);
+ const badgeOvh = Math.min(Math.ceil(11 * (sc || 1)), padH, padVT, padVB);
const pageW = gridInnerW + badgeOvh * 2,
pageH = gridH + badgeOvh * 2;
const dotSz = Math.round(7 * ptScale);
@@ -545,9 +546,9 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) {
ov.addEventListener(
'touchend',
e => {
- const t = e.changedTouches[0],
+ const touch = e.changedTouches[0],
rb = box.getBoundingClientRect();
- if (t.clientX < rb.left || t.clientX > rb.right || t.clientY < rb.top || t.clientY > rb.bottom) {
+ if (touch.clientX < rb.left || touch.clientX > rb.right || touch.clientY < rb.top || touch.clientY > rb.bottom) {
e.preventDefault();
e.stopPropagation();
closeMob();
@@ -641,7 +642,7 @@ export function buildMobile() {
css(document.body, { '--mgw': Math.round(gridW) + 'px' });
const maxIsz = Math.round(74 * sc);
const isz = Math.round(Math.min(cw * 0.9, rh * 0.8, maxIsz));
- const ir = Math.round(isz * 0.225),
+ const ir = Math.round(isz * ICON_R),
im = Math.round(isz * 0.64);
const showLabel = S().showLabels?.ios === true;
/* The reference draws a 66 icon and a 28 widget corner, so a widget's corner
@@ -705,7 +706,7 @@ export function buildMobile() {
function mIcon(item) {
const eff = showLabel ? Math.round(isz * 0.82) : isz;
- const er = Math.round(eff * 0.225),
+ const er = Math.round(eff * ICON_R),
em = Math.round(eff * 0.64);
const a =
item.system === 'settings'
@@ -720,7 +721,7 @@ export function buildMobile() {
const l = mk('div');
l.className = 'dyn-mob-label';
l.dir = 'auto';
- css(l, { '--lfs': Math.max(9, Math.round(9 * sc)) + 'px', '--lw': '100%' });
+ css(l, { '--lfs': Math.max(11, Math.round(12 * sc)) + 'px', '--lw': '100%' });
setUserText(l, item.label || item.id);
a.appendChild(l);
}
@@ -786,7 +787,7 @@ export function buildMobile() {
const maxDockW = vw - Math.round(18 * sc);
const dockPad = Math.round(14 * sc);
const dockIconSz = Math.round(Math.min(isz, ((maxDockW - Math.round(28 * sc)) / 4) * 0.85));
- const dockIr = Math.round(dockIconSz * 0.225),
+ const dockIr = Math.round(dockIconSz * ICON_R),
dockIm = Math.round(dockIconSz * 0.64);
const dockGap = Math.round(9 * sc);
const dockW = Math.min(maxDockW, Math.round((PHONE_W - 18) * sc));
@@ -796,7 +797,7 @@ export function buildMobile() {
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;gap:${dockIconGap}px;z-index:400;`;
dk.replaceChildren();
- observeGlass(dk, Math.round(40 * sc), 0.2);
+ observeGlass(dk, Math.round(38 * sc), 0.6);
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/utils.js b/public/js/utils.js
index 9efdbc9..7a94913 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -3,12 +3,12 @@ import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
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 { BRAND_MARK } from '/js/brand-mark.js?v=e363e477';
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);
+export const mk = (tag, a = {}) => {
+ const e = document.createElement(tag);
Object.assign(e, a);
return e;
};
@@ -17,16 +17,24 @@ export const mk = (t, a = {}) => {
url() fetches from whatever host it names. */
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';
+
+/** Reject an invalid colour. Repairing one by deleting characters paints a
+ colour nobody chose.
+ @param {unknown} c @param {string} fallback @returns {string} */
+export const cssColor = (c, fallback) => {
+ const v = String(c ?? '').trim();
+ return v && SAFE_COLOR.test(v) ? v : fallback;
+};
+
export const clr = c => {
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;
+ return cssColor(c, DEFAULT_TILE_COLOR);
};
/* The plate is a colour the user chose, so the ink has to be measured from it.
White on the palette's own yellow reads at 1.5:1. */
-export const fb = (l, sz, plate) => {
+export const letterTile = (l, sz, plate) => {
const e = mk('span');
e.className = 'fb';
const tone = toneForColor(plate) ?? (pageTheme() === 'light' ? 'dark' : 'light');
@@ -113,6 +121,8 @@ export const qa = (sel, root = document) => /** @type {HTMLElement[]} */ ([...ro
@param {Event} e @returns {HTMLInputElement} */
export const tgt = e => /** @type {HTMLInputElement} */ (e.target);
+export const ICON_R = 0.26;
+
/* breg is passed in to avoid a circular import. */
export function mkWrap(item, sz, r, isz, cls, breg) {
const w = mk('div');
@@ -134,7 +144,7 @@ export function mkWrap(item, sz, r, isz, cls, breg) {
});
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));
+ img.onerror = () => img.replaceWith(letterTile(item.label, sz, wrapBg));
w.appendChild(img);
} else if (rawIcon) {
const chain = iconChain(rawIcon);
@@ -146,7 +156,7 @@ export function mkWrap(item, sz, r, isz, cls, breg) {
const tryNext = () => {
step++;
if (step < chain.length) img.src = chain[step];
- else img.replaceWith(fb(item.label, sz, wrapBg));
+ else img.replaceWith(letterTile(item.label, sz, wrapBg));
};
img.onerror = tryNext;
/* A 403 fires load, not onerror. A blocked image has zero dimensions. */
@@ -154,8 +164,8 @@ export function mkWrap(item, sz, r, isz, cls, breg) {
if (img.naturalWidth === 0) tryNext();
};
w.appendChild(img);
- } else w.appendChild(fb(item.label, sz, wrapBg));
- } else w.appendChild(fb(item.label, sz, wrapBg));
+ } else w.appendChild(letterTile(item.label, sz, wrapBg));
+ } else w.appendChild(letterTile(item.label, sz, wrapBg));
if (
breg &&
(item.monitoring?.healthcheck?.enabled ||
@@ -320,10 +330,10 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla
doc.addEventListener(
'touchstart',
e => {
- const t = e.touches[0];
- if (!t) return;
- sx = t.clientX;
- sy = t.clientY;
+ const touch = e.touches[0];
+ if (!touch) return;
+ sx = touch.clientX;
+ sy = touch.clientY;
moved = false;
},
{ passive: true },
@@ -331,19 +341,19 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla
doc.addEventListener(
'touchmove',
e => {
- const t = e.touches[0];
- if (!t) return;
- if (Math.abs(t.clientX - sx) > 8 || Math.abs(t.clientY - sy) > 8) moved = true;
+ const touch = e.touches[0];
+ if (!touch) return;
+ if (Math.abs(touch.clientX - sx) > 8 || Math.abs(touch.clientY - sy) > 8) moved = true;
},
{ passive: true },
);
doc.addEventListener(
'touchend',
e => {
- const t = e.changedTouches[0];
- if (!t) return;
- const dx = t.clientX - sx,
- dy = t.clientY - sy;
+ const touch = e.changedTouches[0];
+ if (!touch) return;
+ const dx = touch.clientX - sx,
+ dy = touch.clientY - sy;
if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy) * 1.4) {
/* horizontal swipe → page */
if (typeof onSwipe === 'function') onSwipe(dx < 0 ? 1 : -1);
@@ -351,11 +361,11 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla
}
if (!moved && overlayHref) {
/* tap on non-interactive area → open link */
- const tgt = e.target;
+ const target = e.target;
const interactive =
- tgt &&
- tgt.closest &&
- tgt.closest(
+ target &&
+ target.closest &&
+ target.closest(
'a,button,[role="button"],[onclick],.clickable,.bay,.val-row,.chart-wrap,input,select,textarea',
);
if (!interactive) window.open(overlayHref, '_blank', 'noopener,noreferrer');
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index f5f6b5b..e8c36ec 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=52e149f5';
-import { createListbox } from '/js/listbox.js?v=32f787c3';
-import { renderColorControl } from '/js/admin-color-control.js?v=42c11a9b';
+import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=d2e8b6dc';
+import { createListbox } from '/js/listbox.js?v=f057351d';
+import { renderColorControl } from '/js/admin-color-control.js?v=e963853a';
import {
seedCarried,
applyOptionSet,
@@ -13,9 +13,9 @@ import {
requiredFieldMissing,
groupBounds,
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=55685187';
+} from '/js/admin-logic.js?v=cbb7417d';
+import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=e4ea7820';
+import { qi } from '/js/utils.js?v=eadafbcd';
const PE =
' ';
@@ -227,7 +227,7 @@ function _picklist(field, value, ctx, size) {
status.className = 'row-status ok';
} catch (e) {
const advice = optionsErrorAdvice(e);
- status.textContent = advice.message;
+ status.textContent = t(advice.key, advice.vars);
status.className = 'row-status ' + (advice.tone === TONE.WARN ? 'warn' : 'err');
} finally {
btn.disabled = false;
@@ -318,7 +318,7 @@ function _select(field, value, ctx, config = {}) {
status.className = 'row-status ok';
} catch (e) {
const advice = optionsErrorAdvice(e);
- status.textContent = advice.message;
+ status.textContent = t(advice.key, advice.vars);
status.className = 'row-status ' + (advice.tone === TONE.WARN ? 'warn' : 'err');
} finally {
btn.disabled = false;
diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js
index 9025b7f..94382e1 100644
--- a/public/js/widget-glyphs.js
+++ b/public/js/widget-glyphs.js
@@ -1,5 +1,5 @@
// @ts-check
-import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+import { iconSvg } from '/js/icon-set.js?v=08b74a28';
/* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */
/** @type {Record} */
diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js
index 64ab9d2..85a360b 100644
--- a/public/js/widget-toolbox.js
+++ b/public/js/widget-toolbox.js
@@ -12,7 +12,7 @@ export { esc, html, setHtml };
survives esc() and still parses as a second declaration. Assign the result
through a specific CSSOM property, never a concatenated style string. */
const COLOR_RE = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
-export function safeColor(value, fallback) {
+export function colorOrFallback(value, fallback) {
return COLOR_RE.test(String(value ?? '').trim()) ? String(value).trim() : fallback;
}
diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html
index 8f7b7d5..d0fc383 100644
--- a/public/widgets/backup/backup.html
+++ b/public/widgets/backup/backup.html
@@ -121,7 +121,7 @@
diff --git a/src/components/IconSet.astro b/src/components/IconSet.astro
index 05048eb..b8632e3 100644
--- a/src/components/IconSet.astro
+++ b/src/components/IconSet.astro
@@ -1,25 +1,15 @@
---
-import { readRepoFile } from '../lib/repo';
+import { iconSprite, loadIconSet } from '../lib/icon-set';
+import { SIDEBAR_ICON_IDS } from '../lib/sidebar-icons';
-const source = readRepoFile('ui/js/icon-set.js');
-let sprite = '';
-let ids: string[] = [];
-if (source) {
- const mod = await import(/* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
- let defs = '';
- let syms = '';
- ids = Object.keys(mod.ICONS);
- for (const id of ids) {
- const s = mod.symbol(id, mod.ICONS[id]);
- defs += s.defs;
- syms += s.sym;
- }
- sprite = `${defs} ${syms} `;
-}
+const mod = await loadIconSet();
+const ids = mod ? Object.keys(mod.ICONS) : [];
+/* The sidebar sprite defines these ids on every docs page. Repeating them duplicates ids. */
+const sprite = await iconSprite(ids.filter((id) => !SIDEBAR_ICON_IDS.includes(id)));
---
{
- source ? (
+ mod ? (
{ids.map(id => (
diff --git a/src/components/Sidebar.astro b/src/components/Sidebar.astro
new file mode 100644
index 0000000..7f6df3f
--- /dev/null
+++ b/src/components/Sidebar.astro
@@ -0,0 +1,19 @@
+---
+import MobileMenuFooter from '@astrojs/starlight/components/MobileMenuFooter.astro';
+import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro';
+import SidebarSublist from './SidebarSublist.astro';
+import { iconSprite } from '../lib/icon-set';
+import { SIDEBAR_ICON_IDS } from '../lib/sidebar-icons';
+
+const { sidebar } = Astro.locals.starlightRoute;
+const sprite = await iconSprite(SIDEBAR_ICON_IDS);
+---
+
+
+
+
+
+
+
+
+
diff --git a/src/components/SidebarSublist.astro b/src/components/SidebarSublist.astro
new file mode 100644
index 0000000..053f7ca
--- /dev/null
+++ b/src/components/SidebarSublist.astro
@@ -0,0 +1,59 @@
+---
+import { Icon } from '@astrojs/starlight/components';
+import SidebarRestorePoint from '@astrojs/starlight/components/SidebarRestorePoint.astro';
+import { sidebarIcon } from '../lib/sidebar-icons';
+
+type Entry =
+ | { type: 'link'; label: string; href: string; isCurrent: boolean; attrs: Record
}
+ | { type: 'group'; label: string; entries: Entry[]; collapsed: boolean };
+
+interface Props {
+ sublist: Entry[];
+ nested?: boolean;
+}
+
+const { sublist, nested } = Astro.props;
+const hasCurrent = (entries: Entry[]): boolean =>
+ entries.some((e) => (e.type === 'link' ? e.isCurrent : hasCurrent(e.entries)));
+---
+
+
+ {
+ sublist.map((entry) => {
+ const icon = sidebarIcon(entry);
+ return (
+
+ {entry.type === 'link' ? (
+
+ {icon && (
+
+
+
+ )}
+ {entry.label}
+
+ ) : (
+
+
+ {icon && (
+
+
+
+ )}
+ {entry.label}
+
+
+
+
+
+ )}
+
+ );
+ })
+ }
+
diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md
index f855fe4..992e9c5 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
@@ -37,7 +37,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/contributing/design-system.mdx b/src/content/docs/docs/contributing/design-system.mdx
index 1d9da0b..2cdd12a 100644
--- a/src/content/docs/docs/contributing/design-system.mdx
+++ b/src/content/docs/docs/contributing/design-system.mdx
@@ -44,20 +44,20 @@ Use the files as they are. Do not recolour, stretch or redraw them.
diff --git a/src/content/docs/docs/customization.md b/src/content/docs/docs/customization.md
index d4adb31..a56efd2 100644
--- a/src/content/docs/docs/customization.md
+++ b/src/content/docs/docs/customization.md
@@ -21,7 +21,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 4d883c7..963a110 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/lib/icon-set.ts b/src/lib/icon-set.ts
new file mode 100644
index 0000000..ad7e12b
--- /dev/null
+++ b/src/lib/icon-set.ts
@@ -0,0 +1,54 @@
+import { readRepoFile } from './repo';
+
+type Shape = Record;
+type Icon = { shapes: Shape[]; gap?: number; inkMask?: boolean };
+type IconModule = {
+ ICONS: Record;
+ symbol: (id: string, icon: Icon) => { defs: string; sym: string };
+ rect: (...a: unknown[]) => Shape;
+ bar: (...a: unknown[]) => Shape;
+ line: (...a: unknown[]) => Shape;
+};
+
+let loaded: Promise | undefined;
+
+export function loadIconSet(): Promise {
+ loaded ??= (async () => {
+ const source = readRepoFile('ui/js/icon-set.js');
+ if (!source) return null;
+ return import(/* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
+ })();
+ return loaded;
+}
+
+/* Used only when the app checkout predates them. The build fails on a missing id. */
+function docIcons(m: IconModule): Record {
+ return {
+ start: {
+ inkMask: false,
+ shapes: [m.line('M12 2v9M8 7l4 4 4-4', 'accent'), m.rect(2, 14, 20, 8, 4), m.bar(5, 17, 6)],
+ },
+ run: {
+ gap: 0,
+ shapes: [m.rect(2, 4, 20, 16, 4), m.line('M6 9l3 3-3 3', 'accent'), m.bar(12, 14, 6)],
+ },
+ create: {
+ shapes: [m.rect(3, 5, 16, 16, 4), m.line('M19 3v4M17 5h4', 'accent'), m.bar(6, 10, 6), m.bar(6, 14, 10)],
+ },
+ };
+}
+
+export async function iconSprite(ids: string[]): Promise {
+ const m = await loadIconSet();
+ if (!m) return '';
+ const all = { ...docIcons(m), ...m.ICONS };
+ let defs = '';
+ let syms = '';
+ for (const id of new Set(ids)) {
+ if (!all[id]) throw new Error(`Unknown icon: ${id}`);
+ const s = m.symbol(id, all[id]);
+ defs += s.defs;
+ syms += s.sym;
+ }
+ return `${defs} ${syms} `;
+}
diff --git a/src/lib/sidebar-icons.ts b/src/lib/sidebar-icons.ts
new file mode 100644
index 0000000..d9d3162
--- /dev/null
+++ b/src/lib/sidebar-icons.ts
@@ -0,0 +1,35 @@
+/* Keys are sidebar group labels and link paths in astro.config.mjs. Renaming
+ either there drops the icon here. */
+const GROUPS: Record = {
+ 'Get started': 'start',
+ 'Build your dashboard': 'dashboard',
+ Widgets: 'widget',
+ 'Run Stackyard': 'run',
+ 'Create a widget': 'create',
+ Contributing: 'heart',
+ About: 'info',
+};
+
+const LINKS: Record = {
+ '/docs/settings-reference/': 'general',
+ '/docs/troubleshooting/': 'bug',
+ '/docs/widgets/': 'docs',
+ '/docs/widgets/backup/': 'backup',
+ '/docs/widgets/books/': 'books',
+ '/docs/widgets/clock/': 'clock',
+ '/docs/widgets/connections/': 'connections',
+ '/docs/widgets/custom/': 'edit',
+ '/docs/widgets/dashboard-switch/': 'switcher',
+ '/docs/widgets/disk-health/': 'disk',
+ '/docs/widgets/dns/': 'dns',
+ '/docs/widgets/github/': 'github',
+ '/docs/widgets/now-playing/': 'nowplaying',
+ '/docs/widgets/system-summary/': 'system',
+ '/docs/widgets/weather/': 'weather',
+};
+
+export const SIDEBAR_ICON_IDS = [...new Set([...Object.values(GROUPS), ...Object.values(LINKS)])];
+
+export function sidebarIcon(entry: { type: string; label: string; href?: string }): string | undefined {
+ return entry.type === 'group' ? GROUPS[entry.label] : LINKS[entry.href ?? ''];
+}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 0c887b9..00847fc 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,6 +1,5 @@
---
import { readAppVersion } from '../lib/repo';
-import '../styles/fonts.css';
const version = readAppVersion();
@@ -10,14 +9,14 @@ const description =
const site = Astro.site?.href.replace(/\/$/, '') ?? '';
const canonical = `${site}/`;
-const ogImage = `${site}/img/og-64722d2b.jpg`;
+const ogImage = `${site}/img/og-48fdebbd.jpg`;
const publisher = {
'@type': 'Organization',
'@id': `${site}/#organization`,
name: 'Stackyard',
url: `${site}/`,
- logo: `${site}/img/stackyard-wordmark-dark.svg`,
+ logo: `${site}/img/stackyard-wordmark-dark-4680834d.svg`,
sameAs: ['https://github.com/SandObserver/stackyard'],
};
@@ -86,13 +85,6 @@ const schema = {
-
-
+
Install
@@ -148,7 +140,7 @@ const schema = {