From 3b2545cc43cb7f4295584ae722d85d37403a4f6e Mon Sep 17 00:00:00 2001
From: SandObserver <260779319+SandObserver@users.noreply.github.com>
Date: Sat, 19 Sep 2026 10:14:36 -0400
Subject: [PATCH] Docs: add the interface icon set to the design system
---
public/js/admin-app-form.js | 12 +-
public/js/admin-auth.js | 4 +-
public/js/admin-color-control.js | 21 +-
public/js/admin-drag.js | 2 +-
public/js/admin-list.js | 26 +-
public/js/admin-settings.js | 4 +-
public/js/admin-shared.js | 6 +-
public/js/admin-widget-form.js | 22 +-
public/js/admin.js | 33 +--
public/js/dashboard.js | 12 +-
public/js/icon-set.js | 258 ++++++++++++++++++
public/js/listbox.js | 6 +-
public/js/settings-icon.js | 2 +-
public/js/spotlight.js | 2 +-
public/js/ui.js | 2 +-
public/js/utils.js | 4 +-
public/js/widget-config-form.js | 8 +-
public/js/widget-glyphs.js | 47 ++--
public/widgets/system-summary/index.html | 5 +-
src/components/IconSet.astro | 74 +++++
.../docs/docs/contributing/design-system.mdx | 39 ++-
.../docs/docs/create-a-widget/manifest.md | 18 +-
22 files changed, 473 insertions(+), 134 deletions(-)
create mode 100644 public/js/icon-set.js
create mode 100644 src/components/IconSet.astro
diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js
index a16f367..311ae52 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=5d2b6f16';
+import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=045df327';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c';
import { state } from '/js/admin-state.js?v=5a5d655f';
@@ -21,12 +21,13 @@ import {
reveal,
setTogDisabled,
swapContent,
-} from '/js/admin-shared.js?v=69f5c7f9';
-import { createListbox } from '/js/listbox.js?v=2b633751';
+} from '/js/admin-shared.js?v=dc0e02b6';
+import { createListbox } from '/js/listbox.js?v=96066369';
import { MAX_LABELS } from '/js/badge-logic.js?v=b3c8b6c2';
-import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=d0c89032';
+import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=a48fbc58';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
+import { iconSvg } from '/js/icon-set.js?v=606a68c6';
export function buildFolderForm(body, item) {
const children = item?.children || [];
@@ -437,8 +438,7 @@ function renderActLabels(host) {
const _optRow = label => html`${label} ${t('app.optional')} `;
-const GRIP_SVG =
- ' ';
+const GRIP_SVG = iconSvg('grip', 14);
function addActLabel() {
captureActLabels();
diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js
index 6add72f..214d378 100644
--- a/public/js/admin-auth.js
+++ b/public/js/admin-auth.js
@@ -1,7 +1,7 @@
-import { ag, ap } from '/js/admin-shared.js?v=69f5c7f9';
+import { ag, ap } from '/js/admin-shared.js?v=dc0e02b6';
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=5d2b6f16';
+import { el, inp as inpById, qa } from '/js/utils.js?v=045df327';
export async function checkAuth(onLogin) {
try {
diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js
index 26ba049..d21159b 100644
--- a/public/js/admin-color-control.js
+++ b/public/js/admin-color-control.js
@@ -1,22 +1,19 @@
-import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=69f5c7f9';
+import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=dc0e02b6';
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=5d2b6f16';
+import { qa, q } from '/js/utils.js?v=045df327';
+import { iconSvg } from '/js/icon-set.js?v=606a68c6';
const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'];
export const BADGE_DEFAULT = '#1e6ef4';
export const BADGE_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#1e6ef4', '#cb30df'];
const _ccIco = {
- hueLo:
- ' ',
- hueHi:
- ' ',
- satLo:
- ' ',
- satHi:
- ' ',
- brLo: ' ',
- brHi: ' ',
+ hueLo: iconSvg('hue-lo', 16),
+ hueHi: iconSvg('hue-hi', 16),
+ satLo: iconSvg('sat-lo', 16),
+ satHi: iconSvg('sat-hi', 16),
+ brLo: iconSvg('bright-lo', 16),
+ brHi: iconSvg('bright-hi', 16),
};
/* Normalise the hue before computing `x`. A negative hue otherwise mixes a
wrapped sextant with an unwrapped `x` and yields a negative channel.
diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js
index bc9d2d1..f8cf603 100644
--- a/public/js/admin-drag.js
+++ b/public/js/admin-drag.js
@@ -12,7 +12,7 @@
import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76';
import { state } from '/js/admin-state.js?v=5a5d655f';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
-import { qa } from '/js/utils.js?v=5d2b6f16';
+import { qa } from '/js/utils.js?v=045df327';
/** @type {(before: unknown) => void} */
let _save = () => {};
diff --git a/public/js/admin-list.js b/public/js/admin-list.js
index 3c08fe1..d2248c6 100644
--- a/public/js/admin-list.js
+++ b/public/js/admin-list.js
@@ -14,13 +14,14 @@
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
import { snapshotItems } from '/js/admin-save-logic.js?v=4f71ef6c';
import { reorderItems } from '/js/admin-logic.js?v=e3673bd7';
-import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=dbf4e7e6';
-import { paintIcon } from '/js/admin-shared.js?v=69f5c7f9';
-import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=5d2b6f16';
+import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=227e3153';
+import { paintIcon } from '/js/admin-shared.js?v=dc0e02b6';
+import { clr as rc, el, qa, setUserText } from '/js/utils.js?v=045df327';
import { 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=c4b11d2a';
-import { widgetGlyph } from '/js/widget-glyphs.js?v=12b0a947';
+import { sizeLabel } from '/js/admin-widget-form.js?v=3237dd3e';
+import { widgetGlyph } from '/js/widget-glyphs.js?v=3adb57ad';
+import { iconSvg } from '/js/icon-set.js?v=606a68c6';
/** @type {{ openModal: (idx: number|null) => void,
openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void,
@@ -39,17 +40,12 @@ export function initList(page) {
}
/* Constant markup only. No user data reaches these. */
-const FOLDER_ICON =
- ' ';
+const FOLDER_ICON = iconSvg('folder', 26);
const SIZE_ICONS = {
- small:
- ' ',
- medium:
- ' ',
- large:
- ' ',
- xlarge:
- ' ',
+ small: iconSvg('small', 26),
+ medium: iconSvg('medium', 26),
+ large: iconSvg('large', 26),
+ xlarge: iconSvg('xlarge', 26),
};
function svgNode(markup) {
const t = document.createElement('template');
diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js
index 708635d..c797fb6 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=69f5c7f9';
+import { toast, ag, ap, reveal, swapContent } from '/js/admin-shared.js?v=dc0e02b6';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { t } from '/js/i18n.js?v=1f1ea9c1';
import {
@@ -9,7 +9,7 @@ import {
BLOCK,
} from '/js/admin-logic.js?v=e3673bd7';
import { confirmText } from '/js/modal.js?v=11fa1eff';
-import { el, inp, setUserText } from '/js/utils.js?v=5d2b6f16';
+import { el, inp, setUserText } from '/js/utils.js?v=045df327';
/* Mirrors the server's rule: auth cannot be switched on with no password. */
let _passwordSet = false;
diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js
index 571ab88..bf59245 100644
--- a/public/js/admin-shared.js
+++ b/public/js/admin-shared.js
@@ -1,8 +1,9 @@
/* 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=5d2b6f16';
+import { el, q } from '/js/utils.js?v=045df327';
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';
export const API = '';
@@ -199,8 +200,7 @@ export function swapContent(node, apply) {
timer = setTimeout(end, 600);
}
-export const PE_SVG =
- ' ';
+export const PE_SVG = iconSvg('edit', 17);
/* `root` lets a caller wire a subtree that is not in the document yet. */
/** A function placeholder is resolved on use, not at wiring time: this runs
diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js
index 1b6d1f8..c44ded2 100644
--- a/public/js/admin-widget-form.js
+++ b/public/js/admin-widget-form.js
@@ -1,22 +1,14 @@
import { state } from '/js/admin-state.js?v=5a5d655f';
-import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=69f5c7f9';
-import { createListbox } from '/js/listbox.js?v=2b633751';
-import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=253fcdcd';
+import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=dc0e02b6';
+import { createListbox } from '/js/listbox.js?v=96066369';
+import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=e998ed94';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=e3673bd7';
import { t } from '/js/i18n.js?v=1f1ea9c1';
-import { q, qi, qa } from '/js/utils.js?v=5d2b6f16';
+import { q, qi, qa } from '/js/utils.js?v=045df327';
+import { iconSvg } from '/js/icon-set.js?v=606a68c6';
-const SIZE_ICONS = {
- small:
- ' ',
- medium:
- ' ',
- large:
- ' ',
- xlarge:
- ' ',
-};
+const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']);
const CUSTOM_SIZES = ['small', 'medium', 'large', 'xlarge'];
function widgetSizes(type) {
@@ -108,7 +100,7 @@ function _renderWidgetForm(body) {
scard.className = 'grp';
setHtml(
scard,
- html`
${_sizeOpts.map(s => html`${raw(SIZE_ICONS[s] || SIZE_ICONS.medium)} ${sizeLabel(s)} `)}
`,
+ html`${_sizeOpts.map(s => html`${raw(iconSvg(SIZES_WITH_ICONS.has(s) ? s : 'medium', 38))} ${sizeLabel(s)} `)}
`,
);
body.appendChild(scard);
qa('.tile-opt', scard).forEach(b =>
diff --git a/public/js/admin.js b/public/js/admin.js
index ac0c351..7490754 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1,6 +1,6 @@
-import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=7da1ff10';
-import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=e2646986';
-import { initList, render, syncFilterUI } from '/js/admin-list.js?v=c5d8bff8';
+import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=0b617803';
+import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9833ce5d';
+import { initList, render, syncFilterUI } from '/js/admin-list.js?v=1c65c8ad';
import { resolveAdminSection } from '/js/admin-logic.js?v=e3673bd7';
import {
buildAppItem,
@@ -10,16 +10,17 @@ import {
snapshotItems,
upsertItem,
} from '/js/admin-save-logic.js?v=4f71ef6c';
-import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=4d5e9919';
-import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=69f5c7f9';
+import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=002dde46';
+import { ag, ap, initInlineEdit, paintIcon, reveal, setReauthHandler, toast } from '/js/admin-shared.js?v=dc0e02b6';
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=5a5d655f';
-import { buildWidgetForm } from '/js/admin-widget-form.js?v=c4b11d2a';
+import { buildWidgetForm } from '/js/admin-widget-form.js?v=3237dd3e';
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=2b633751';
+import { createListbox } from '/js/listbox.js?v=96066369';
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 {
clearSkipTls,
convert,
@@ -32,11 +33,13 @@ import {
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607';
import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff';
import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=787bfdff';
-import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=5d2b6f16';
-import { normalizeColorInput } from '/js/admin-color-control.js?v=d0c89032';
+import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=045df327';
+import { normalizeColorInput } from '/js/admin-color-control.js?v=a48fbc58';
import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';
+ensureSprite();
+
/* A class rather than a bare media query. Some phones report a wider CSS
viewport than they have. The rule lives in layout.js, shared with the
dashboard, so the two screens cannot disagree about what mobile means. */
@@ -190,13 +193,6 @@ function showEditView() {
q('.cp')?.scrollTo?.(0, 0);
}
-const TYPE_ICONS = {
- app: ' ',
- widget:
- ' ',
- folder:
- ' ',
-};
/* Read at draw time. The catalog is not loaded when this module evaluates. */
const typeLabels = () => ({ app: t('type.app'), widget: t('type.widget'), folder: t('type.folder') });
@@ -216,10 +212,7 @@ function buildAddNewCard() {
b.dataset.ctype = kind;
b.setAttribute('aria-pressed', String(kind === state.ctype));
b.setAttribute('aria-label', t('type.addNew') + ': ' + label);
- setHtml(
- b,
- html`${raw(TYPE_ICONS[kind])} ${label} `,
- );
+ setHtml(b, html`${raw(iconSvg(kind, 38))} ${label} `);
b.onclick = () => {
if (state.ctype === kind) return;
state.ctype = kind;
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 00ba92b..463a913 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -23,9 +23,9 @@ import {
setUserText,
teardownWidgets,
titleWhenTruncated,
-} from '/js/utils.js?v=5d2b6f16';
+} from '/js/utils.js?v=045df327';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
-import { initSpotlight } from '/js/spotlight.js?v=50bd3af5';
+import { initSpotlight } from '/js/spotlight.js?v=38fe61b4';
import { html, setHtml, raw } from '/js/html.js?v=c71f8903';
import { initI18n, t, currentLang } from '/js/i18n.js?v=1f1ea9c1';
import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7';
@@ -39,7 +39,7 @@ import {
buildMobile,
resetMobileChrome,
mkFolderGlyph,
-} from '/js/ui.js?v=9317d147';
+} from '/js/ui.js?v=e28d57cd';
import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=b3c8b6c2';
import { formatNumber } from '/js/format-number.js?v=4a5ccef4';
import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3';
@@ -56,6 +56,9 @@ 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';
+
+ensureSprite();
/* Recomputed, never stored: the window can cross the breakpoint after load. */
let MOB = isMobileLayout();
@@ -665,8 +668,7 @@ async function pollHealth() {
}
}
-const EYE =
- ' ';
+const EYE = iconSvg('eye', 18);
function showSetupPrompt() {
return new Promise(resolve => {
diff --git a/public/js/icon-set.js b/public/js/icon-set.js
new file mode 100644
index 0000000..172afc7
--- /dev/null
+++ b/public/js/icon-set.js
@@ -0,0 +1,258 @@
+const T = { SW: 2, G: 2, R_LG: 4, R_MD: 3, R_SM: 2, BAR: 2, DOT: 2, NODE: 3, PAD: 3 };
+
+const ROLE = { ink: 'i', faint: 'i3', accent: 'a', cut: 'c' };
+const rl = r => ROLE[r] || r;
+
+export const rect = (x, y, w, h, r, role = 'ink', o = {}) => ({
+ k: 'area',
+ t: 'rect',
+ x,
+ y,
+ w,
+ h,
+ r,
+ role: rl(role),
+ ...o,
+});
+export const detail = (x, y, w, h, r, role = 'ink', o = {}) => ({
+ k: 'solid',
+ t: 'rect',
+ x,
+ y,
+ w,
+ h,
+ r,
+ role: rl(role),
+ ...o,
+});
+export const bar = (x, y, w, role = 'cut') => detail(x, y, w, T.BAR, T.BAR / 2, role);
+export const circle = (cx, cy, r, role = 'ink') => ({ k: 'area', t: 'circle', cx, cy, r, role: rl(role) });
+export const dot = (cx, cy, r, role = 'ink') => ({ k: 'solid', t: 'circle', cx, cy, r, role: rl(role) });
+export const shape = (d, role = 'ink', o = {}) => ({ k: 'area', t: 'path', d, role: rl(role), ...o });
+export const line = (d, role = 'ink') => ({ k: 'line', t: 'path', d, role: rl(role) });
+export const ring = (cx, cy, r, role = 'ink') => ({ k: 'line', t: 'circle', cx, cy, r, role: rl(role) });
+
+export const STYLES = {
+ stroke: { line: true, accent: 'ink' },
+ twotone: { line: true, accent: 'accent' },
+ duotone: { line: true, accent: 'accent', accentFill: true },
+ solid: { line: false, accent: 'accent' },
+ bulk: { line: false, accent: 'ink', inkFaint: true },
+};
+
+function colour(role, st) {
+ if (role === 'a') return st.accent === 'ink' ? 'i' : 'a';
+ if (role === 'i3') return 'i3';
+ return st.inkFaint ? 'i3' : 'i';
+}
+function strokeAttrs(sharp, w = T.SW) {
+ return ` stroke-width="${w}" stroke-linecap="${sharp ? 'square' : 'round'}" stroke-linejoin="${sharp ? 'miter' : 'round'}"`;
+}
+function geom(p, sharp, inset) {
+ const tr = p.transform ? ` transform="${p.transform}"` : '';
+ if (p.t === 'rect') {
+ const d = inset ? 1 : 0;
+ const r = sharp ? 0 : Math.max(0, p.r - d);
+ return ` `;
+ if (p.rj) return `${geom(p, sharp, false)} class="sy-${cls} sy-${cls}s"${strokeAttrs(sharp)}/>`;
+ return `${geom(p, sharp, false)} class="sy-${cls}"/>`;
+}
+function hole(p, sharp, grow) {
+ if (p.k === 'line')
+ return `${geom(p, sharp, false)} fill="none" stroke="#000"${strokeAttrs(sharp, T.SW + 2 * grow)}/>`;
+ return `${geom(p, sharp, false)} fill="#000"${grow ? ` stroke="#000"${strokeAttrs(sharp, 2 * grow)}` : ''}/>`;
+}
+function mask(id, content) {
+ return ` ${content} `;
+}
+
+export function symbol(id, ic, styleId = 'solid', sharp = false) {
+ const st = STYLES[styleId];
+ const gap = ic.gap ?? T.G;
+ const sid = `sy-${styleId}${sharp ? '-sharp' : ''}-${id}`;
+ const shapes = ic.shapes.map(p => (st.line && p.lineAlt ? { ...p, ...p.lineAlt } : p));
+ const ink = shapes.filter(p => p.role !== 'a' && (st.line || p.role !== 'c'));
+ const acc = shapes.filter(p => p.role === 'a');
+ let holes = st.line
+ ? ''
+ : shapes
+ .filter(p => p.role === 'c')
+ .map(p => hole(p, sharp, 0))
+ .join('');
+ if (ic.inkMask !== false) holes += acc.map(p => hole(p, sharp, gap)).join('');
+ let defs = '';
+ let inkAttr = '';
+ let accAttr = '';
+ if (holes) {
+ defs += mask(`${sid}-m`, holes);
+ inkAttr = ` mask="url(#${sid}-m)"`;
+ }
+ if (ic.accentCut) {
+ defs += mask(`${sid}-am`, ic.accentCut.map(p => hole(p, sharp, p.grow ?? 0)).join(''));
+ accAttr = ` mask="url(#${sid}-am)"`;
+ }
+ const body = `${ink.map(p => draw(p, st, sharp)).join('')} ${acc.map(p => draw(p, st, sharp)).join('')} `;
+ return { defs, sym: `${body} ` };
+}
+
+const knob = (x, y) => detail(x, y, 10, 4, 2, 'ink', { lineAlt: { x: x + 1, y: y + 1, w: 8, h: 2, r: 1 } });
+const TOGGLES = {
+ inkMask: false,
+ shapes: [rect(2, 3, 20, 8, 4, 'accent'), knob(10, 5), rect(2, 13, 20, 8, 4, 'faint'), knob(4, 15)],
+ accentCut: [detail(10, 5, 10, 4, 2)],
+};
+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';
+
+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
+ ];
+ const ix = x + T.PAD;
+ const iw = w - 2 * T.PAD;
+ const shapes = [rect(x, y, w, h, T.R_LG)];
+ if (k === 'medium') {
+ const cy = y + (h - T.BAR) / 2;
+ shapes.push(bar(ix, cy, 6, 'accent'), bar(ix + 6 + T.G, cy, iw - 6 - T.G));
+ return { gap: 0, shapes };
+ }
+ const rows = Math.floor((h - 2 * T.PAD + T.G) / (T.BAR + T.G));
+ shapes.push(bar(ix, y + T.PAD, Math.min(8, iw - 2), 'accent'));
+ for (let i = 1; i < rows; i++)
+ shapes.push(bar(ix, y + T.PAD + i * (T.BAR + T.G), i === rows - 1 && rows > 2 ? iw - 4 : iw));
+ return { gap: 0, shapes };
+}
+
+export const ICONS = {
+ general: TOGGLES,
+ appearance: {
+ inkMask: false,
+ shapes: [shape('M11 2.05A10 10 0 0 0 11 21.95z'), shape('M13 2.05A10 10 0 0 1 13 21.95z', 'accent')],
+ },
+ dashboard: {
+ inkMask: false,
+ shapes: [rect(3, 3, 18, 8, T.R_MD, 'accent'), rect(3, 13, 8, 8, T.R_MD), rect(13, 13, 8, 8, T.R_MD)],
+ },
+
+ app: { shapes: [rect(3, 5, 16, 16, T.R_LG), dot(19, 5, T.NODE, 'accent')] },
+ folder: {
+ gap: 0,
+ shapes: [
+ rect(3, 3, 18, 18, T.R_LG),
+ detail(6, 6, 5, 5, T.R_SM, 'accent'),
+ detail(13, 6, 5, 5, T.R_SM, 'cut'),
+ detail(6, 13, 5, 5, T.R_SM, 'cut'),
+ detail(13, 13, 5, 5, T.R_SM, 'cut'),
+ ],
+ },
+ widget: { gap: 0, shapes: [rect(2, 4, 20, 16, T.R_LG), bar(5, 7, 8, 'accent'), bar(5, 11, 14), bar(5, 15, 10)] },
+
+ small: sizeIcon('small'),
+ medium: sizeIcon('medium'),
+ large: sizeIcon('large'),
+ xlarge: sizeIcon('xlarge'),
+
+ backup: { shapes: [line('M5 12a7 7 0 1 0 2-5L5 9'), line('M12 8v4l3 2'), line('M5 4v5h5', 'accent')] },
+ books: {
+ inkMask: false,
+ shapes: [
+ rect(3, 6, 4, 15, T.R_SM),
+ rect(9, 4, 4, 17, T.R_SM, 'accent'),
+ rect(15, 6, 4, 15, T.R_SM, 'ink', { transform: 'rotate(-15 15 21)' }),
+ ],
+ },
+ clock: {
+ inkMask: false,
+ shapes: [ring(12, 12, 9), line('M12 12l4 2'), line('M12 12V7', 'accent'), dot(12, 12, T.DOT, 'accent')],
+ },
+ connections: {
+ shapes: [
+ line('M12 5 5 19M12 5l7 14M5 19h14'),
+ dot(5, 19, T.NODE),
+ dot(19, 19, T.NODE),
+ dot(12, 5, T.NODE, 'accent'),
+ ],
+ },
+ switcher: {
+ inkMask: false,
+ shapes: [detail(2, 7, 3, 10, 1, 'faint'), detail(19, 7, 3, 10, 1, 'faint'), rect(7, 4, 10, 16, T.R_MD, 'accent')],
+ },
+ disk: { inkMask: false, shapes: [shape('M8 4h8l4 8H4z'), rect(2, 14, 20, 6, T.R_SM), dot(17, 17, T.DOT, 'accent')] },
+ dns: {
+ inkMask: false,
+ shapes: [detail(2, 5, 4, 14, T.R_SM), line('M9 12c5 0 6-6 12-6M9 12h12'), line('M9 12c5 0 6 6 12 6', 'accent')],
+ },
+ github: {
+ inkMask: false,
+ shapes: [
+ line('M7 5v14'),
+ dot(7, 5, T.NODE),
+ dot(7, 19, T.NODE),
+ line('M7 10c0 4 4 4 10 4', 'accent'),
+ dot(17, 14, T.NODE, 'accent'),
+ ],
+ },
+ nowplaying: { shapes: [rect(3, 3, 18, 18, T.R_LG), shape('M10 8v8l6-4z', 'accent', { rj: true })] },
+ system: { shapes: [line('M3 16 7 12 11 15 16 8'), dot(19, 6, T.NODE, 'accent')] },
+ weather: {
+ inkMask: false,
+ shapes: [shape(CLOUD), circle(17, 8, 4, 'accent')],
+ accentCut: [{ ...shape(CLOUD), grow: T.G }],
+ },
+
+ edit: { shapes: [shape('M14 5l5 5L9 20H4v-5z'), line('M12 7l5 5', 'cut')] },
+ chevrons: { shapes: [line('M8 9l4-4 4 4'), line('M8 15l4 4 4-4')] },
+ back: { shapes: [line('M15 5l-7 7 7 7')] },
+ up: { shapes: [line('M6 15l6-6 6 6')] },
+ 'hue-lo': { shapes: [line('M12 3c3 4 6 7.5 6 11a6 6 0 0 1-12 0c0-3.5 3-7 6-11z')] },
+ 'hue-hi': { shapes: [shape('M12 3c3 4 6 7.5 6 11a6 6 0 0 1-12 0c0-3.5 3-7 6-11z')] },
+ 'sat-lo': { shapes: [ring(12, 12, 8)] },
+ 'sat-hi': { shapes: [ring(12, 12, 8), shape('M12 4a8 8 0 0 1 0 16z')] },
+ 'bright-lo': { shapes: [dot(12, 12, 3), line('M12 5v1M12 18v1M5 12h1M18 12h1')] },
+ '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')] },
+ 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: {
+ shapes: [
+ dot(9, 6, T.DOT),
+ dot(15, 6, T.DOT),
+ dot(9, 12, T.DOT),
+ dot(15, 12, T.DOT),
+ dot(9, 18, T.DOT),
+ dot(15, 18, T.DOT),
+ ],
+ },
+ eye: { shapes: [line('M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z'), dot(12, 12, 3)] },
+ 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')] },
+};
+
+export function iconSvg(id, px = 24, variant = 'solid', cls = '') {
+ return ` `;
+}
+
+export function ensureSprite(doc = document, variant = 'solid') {
+ if (doc.getElementById('sy-sprite')) return;
+ let defs = '';
+ let syms = '';
+ for (const [id, ic] of Object.entries(ICONS)) {
+ const s = symbol(id, ic, variant);
+ defs += s.defs;
+ syms += s.sym;
+ }
+ const src = `${defs} ${syms} `;
+ const svg = new DOMParser().parseFromString(src, 'image/svg+xml').documentElement;
+ doc.body.prepend(doc.importNode(svg, true));
+}
diff --git a/public/js/listbox.js b/public/js/listbox.js
index 3dfe163..aeaa4a8 100644
--- a/public/js/listbox.js
+++ b/public/js/listbox.js
@@ -9,10 +9,10 @@
import { nextActiveIndex } from '/js/admin-logic.js?v=e3673bd7';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
-import { qa } from '/js/utils.js?v=5d2b6f16';
+import { qa } from '/js/utils.js?v=045df327';
+import { iconSvg } from '/js/icon-set.js?v=606a68c6';
-const CHEV =
- ' ';
+const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev');
const TYPEAHEAD_MS = 800;
diff --git a/public/js/settings-icon.js b/public/js/settings-icon.js
index f49f776..f5c5d11 100644
--- a/public/js/settings-icon.js
+++ b/public/js/settings-icon.js
@@ -3,4 +3,4 @@
the same name left there would shadow the bundled artwork. */
export const SETTINGS_ICON =
- 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB0IiBoZWlnaHQ9IjEwMjRwdCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsPSIjZjJmMmY3IiBzdHJva2U9IiNmMmYyZjciIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gMzU1LjU1IDM5OC43NyBDIDM2NC45OSAzOTcuNjIgMzc0LjUxIDM5OC4xMCAzODQuMDAgMzk4LjAwIEMgNDc0LjY3IDM5OC4wMCA1NjUuMzQgMzk4LjAxIDY1Ni4wMSAzOTcuOTkgQyA2NzcuMTEgMzk3Ljk1IDY5OC4yMiA0MDMuODMgNzE2LjE1IDQxNC45OSBDIDc0Ni42MiA0MzMuNjAgNzY3LjMwIDQ2Ny4zMiA3NjkuNjYgNTAzLjAwIEMgNzcyLjYwIDUzOC4xNiA3NTcuNzIgNTc0LjA5IDczMS4wMCA1OTcuMDcgQyA3MTQuMjIgNjExLjcyIDY5My4wNCA2MjEuMjcgNjcwLjk0IDYyNC4wNSBDIDY2MS42NyA2MjUuMzQgNjUyLjMwIDYyNC45NCA2NDIuOTggNjI1LjAwIEMgNTU3LjMxIDYyNS4wMCA0NzEuNjQgNjI1LjAwIDM4NS45NyA2MjUuMDAgQyAzNzMuMzAgNjI0LjkyIDM2MC41MCA2MjUuNjAgMzQ3Ljk5IDYyMy4xMCBDIDMxNy4yMiA2MTcuNjAgMjg5LjIzIDU5OC42MiAyNzIuNjEgNTcyLjE2IEMgMjU5Ljc3IDU1Mi4wMyAyNTMuNjAgNTI3Ljc1IDI1NS4yNiA1MDMuOTMgQyAyNTYuNzAgNDgxLjI4IDI2NS4yNCA0NTkuMTcgMjc5LjMyIDQ0MS4zNyBDIDI5Ny43MyA0MTcuNzkgMzI1LjgwIDQwMi4wNiAzNTUuNTUgMzk4Ljc3IE0gNDY5LjMxIDQxMC40NyBDIDQ1Mi43OSA0MTIuMjIgNDM2LjYwIDQxNy41OCA0MjIuNzEgNDI2Ljc4IEMgNDAwLjE2IDQ0MS40MCAzODQuMDUgNDY1LjUyIDM3OS4xMCA0OTEuOTMgQyAzNzQuNjQgNTExLjQwIDM3Ny42NCA1MzEuODcgMzg0Ljc1IDU1MC4zMiBDIDM4OC4wNiA1NTcuNDAgMzkxLjMzIDU2NC41OSAzOTYuMjYgNTcwLjczIEMgNDE0Ljg1IDU5Ny42MyA0NDcuNTcgNjEzLjMwIDQ4MC4wNSA2MTMuMDEgQyA1MzguNzIgNjEzLjAwIDU5Ny4zOCA2MTMuMDQgNjU2LjA1IDYxMi45NyBDIDY4NC44OSA2MTIuMjggNzEzLjU2IDU5OS4zNyA3MzIuMDAgNTc2Ljk3IEMgNzM2LjY3IDU3MC44MyA3NDEuNTkgNTY0Ljc4IDc0NC43NCA1NTcuNjggQyA3NTAuMTEgNTQ3LjkxIDc1Mi44NSA1MzYuOTYgNzU0Ljg0IDUyNi4wNyBDIDc1Ny45MiA1MDguMzMgNzU0Ljc2IDQ5MC4wNiA3NDguNTMgNDczLjM1IEMgNzQ1LjE5IDQ2NS43NSA3NDEuNDcgNDU4LjI2IDczNi4zMiA0NTEuNzEgQyA3MjQuOTYgNDM1LjQxIDcwOC4xMyA0MjMuMzYgNjg5LjY5IDQxNi4zMSBDIDY3OC4xNiA0MTIuNTkgNjY2LjEzIDQwOS44NyA2NTMuOTYgNDEwLjAzIEMgNTk3LjI5IDQwOS45NSA1NDAuNjEgNDEwLjAzIDQ4My45NCA0MTAuMDAgQyA0NzkuMDYgNDA5Ljk4IDQ3NC4xNyA0MDkuOTQgNDY5LjMxIDQxMC40NyBaIiAvPgo8cGF0aCBmaWxsPSIjZGZkZmU0IiBzdHJva2U9IiNkZmRmZTQiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY5LjMxIDQxMC40NyBDIDQ3NC4xNyA0MDkuOTQgNDc5LjA2IDQwOS45OCA0ODMuOTQgNDEwLjAwIEMgNTQwLjYxIDQxMC4wMyA1OTcuMjkgNDA5Ljk1IDY1My45NiA0MTAuMDMgQyA2NjYuMTMgNDA5Ljg3IDY3OC4xNiA0MTIuNTkgNjg5LjY5IDQxNi4zMSBDIDcwOC4xMyA0MjMuMzYgNzI0Ljk2IDQzNS40MSA3MzYuMzIgNDUxLjcxIEMgNzQxLjQ3IDQ1OC4yNiA3NDUuMTkgNDY1Ljc1IDc0OC41MyA0NzMuMzUgQyA3NTQuNzYgNDkwLjA2IDc1Ny45MiA1MDguMzMgNzU0Ljg0IDUyNi4wNyBDIDc1Mi44NSA1MzYuOTYgNzUwLjExIDU0Ny45MSA3NDQuNzQgNTU3LjY4IEMgNzQxLjU5IDU2NC43OCA3MzYuNjcgNTcwLjgzIDczMi4wMCA1NzYuOTcgQyA3MTMuNTYgNTk5LjM3IDY4NC44OSA2MTIuMjggNjU2LjA1IDYxMi45NyBDIDU5Ny4zOCA2MTMuMDQgNTM4LjcyIDYxMy4wMCA0ODAuMDUgNjEzLjAxIEMgNDQ3LjU3IDYxMy4zMCA0MTQuODUgNTk3LjYzIDM5Ni4yNiA1NzAuNzMgQyAzOTEuMzMgNTY0LjU5IDM4OC4wNiA1NTcuNDAgMzg0Ljc1IDU1MC4zMiBDIDM3Ny42NCA1MzEuODcgMzc0LjY0IDUxMS40MCAzNzkuMTAgNDkxLjkzIEMgMzg0LjA1IDQ2NS41MiA0MDAuMTYgNDQxLjQwIDQyMi43MSA0MjYuNzggQyA0MzYuNjAgNDE3LjU4IDQ1Mi43OSA0MTIuMjIgNDY5LjMxIDQxMC40NyBNIDQ2Ny40OCA0MjIuNjYgQyA0MzYuNjggNDI2LjEzIDQwOC43NCA0NDcuMDggMzk2LjUwIDQ3NS41MSBDIDM4Ny43NyA0OTUuMjcgMzg2LjU4IDUxOC4yMSAzOTMuMjEgNTM4Ljc2IEMgMzk4LjcyIDU1Ni4xNiA0MDkuNzUgNTcxLjc1IDQyNC4zMCA1ODIuNzYgQyA0MzkuMDQgNTk0LjA3IDQ1Ny4zOSA2MDAuNTEgNDc1Ljk1IDYwMS4wMCBDIDUzMi4zMiA2MDEuMDAgNTg4LjY5IDYwMS4wMCA2NDUuMDYgNjAxLjAwIEMgNjUyLjE5IDYwMC45NCA2NTkuMzcgNjAxLjMzIDY2Ni40NiA2MDAuMjIgQyA2ODMuMTYgNTk4LjA4IDY5OS4xNCA1OTAuOTUgNzEyLjAyIDU4MC4xMSBDIDcyOC41OCA1NjYuMzIgNzM5Ljg5IDU0Ni4zMiA3NDMuMDAgNTI0Ljk4IEMgNzQ1Ljg2IDUwNi42NSA3NDIuNzYgNDg3LjQ2IDczNC4zNCA0NzAuOTQgQyA3MjYuNzQgNDU1Ljk3IDcxNC44NCA0NDMuMjMgNzAwLjQxIDQzNC42MyBDIDY4NS45NCA0MjUuODggNjY4LjkyIDQyMS41NCA2NTIuMDMgNDIxLjk5IEMgNTk1LjY2IDQyMi4wMSA1MzkuMjkgNDIyLjAwIDQ4Mi45MSA0MjIuMDAgQyA0NzcuNzYgNDIxLjk0IDQ3Mi41OSA0MjEuOTQgNDY3LjQ4IDQyMi42NiBaIiAvPgo8cGF0aCBmaWxsPSIjMzg3Zjk1IiBzdHJva2U9IiMzODdmOTUiIHN0cm9rZS13aWR0aD0iMC4wOTM3NSIgb3BhY2l0eT0iMS4wMCIgZD0iIE0gNDY3LjQ4IDQyMi42NiBDIDQ3Mi41OSA0MjEuOTQgNDc3Ljc2IDQyMS45NCA0ODIuOTEgNDIyLjAwIEMgNTM5LjI5IDQyMi4wMCA1OTUuNjYgNDIyLjAxIDY1Mi4wMyA0MjEuOTkgQyA2NjguOTIgNDIxLjU0IDY4NS45NCA0MjUuODggNzAwLjQxIDQzNC42MyBDIDcxNC44NCA0NDMuMjMgNzI2Ljc0IDQ1NS45NyA3MzQuMzQgNDcwLjk0IEMgNzQyLjc2IDQ4Ny40NiA3NDUuODYgNTA2LjY1IDc0My4wMCA1MjQuOTggQyA3MzkuODkgNTQ2LjMyIDcyOC41OCA1NjYuMzIgNzEyLjAyIDU4MC4xMSBDIDY5OS4xNCA1OTAuOTUgNjgzLjE2IDU5OC4wOCA2NjYuNDYgNjAwLjIyIEMgNjU5LjM3IDYwMS4zMyA2NTIuMTkgNjAwLjk0IDY0NS4wNiA2MDEuMDAgQyA1ODguNjkgNjAxLjAwIDUzMi4zMiA2MDEuMDAgNDc1Ljk1IDYwMS4wMCBDIDQ1Ny4zOSA2MDAuNTEgNDM5LjA0IDU5NC4wNyA0MjQuMzAgNTgyLjc2IEMgNDA5Ljc1IDU3MS43NSAzOTguNzIgNTU2LjE2IDM5My4yMSA1MzguNzYgQyAzODYuNTggNTE4LjIxIDM4Ny43NyA0OTUuMjcgMzk2LjUwIDQ3NS41MSBDIDQwOC43NCA0NDcuMDggNDM2LjY4IDQyNi4xMyA0NjcuNDggNDIyLjY2IFoiIC8+Cjwvc3ZnPg==';
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMiIgeT0iMyIgd2lkdGg9IjIwIiBoZWlnaHQ9IjgiIHJ4PSI0IiBmaWxsPSIjMDBEMkUwIi8+PHJlY3QgeD0iMTAiIHk9IjUiIHdpZHRoPSIxMCIgaGVpZ2h0PSI0IiByeD0iMiIgZmlsbD0iI0YyRjJGNyIvPjxyZWN0IHg9IjIiIHk9IjEzIiB3aWR0aD0iMjAiIGhlaWdodD0iOCIgcng9IjQiIGZpbGw9IiNGMkYyRjciIGZpbGwtb3BhY2l0eT0iLjQiLz48cmVjdCB4PSI0IiB5PSIxNSIgd2lkdGg9IjEwIiBoZWlnaHQ9IjQiIHJ4PSIyIiBmaWxsPSIjRjJGMkY3Ii8+PC9zdmc+';
diff --git a/public/js/spotlight.js b/public/js/spotlight.js
index 3a843bb..1e8fdd5 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=5d2b6f16';
+import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=045df327';
import { t } from '/js/i18n.js?v=1f1ea9c1';
/* Attached to the window so a re-open can undo the previous one. */
diff --git a/public/js/ui.js b/public/js/ui.js
index e388cb3..6993bd7 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -13,7 +13,7 @@ import {
q,
qa,
setUserText,
-} from '/js/utils.js?v=5d2b6f16';
+} from '/js/utils.js?v=045df327';
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';
diff --git a/public/js/utils.js b/public/js/utils.js
index 848f370..16569a8 100644
--- a/public/js/utils.js
+++ b/public/js/utils.js
@@ -1,6 +1,6 @@
import { iconChain } from '/js/icons.js?v=9c8c550c';
import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8';
-import { SETTINGS_ICON } from '/js/settings-icon.js?v=b96e5b13';
+import { SETTINGS_ICON } from '/js/settings-icon.js?v=fe53a1db';
import { BRAND_MARK } from '/js/brand-mark.js?v=1dcbf1ac';
import { t } from '/js/i18n.js?v=1f1ea9c1';
@@ -112,7 +112,7 @@ export const tgt = e => /** @type {HTMLInputElement} */ (e.target);
export function mkWrap(item, sz, r, isz, cls, breg) {
const w = mk('div');
w.className = cls ? `plate ${cls}` : 'plate';
- const wrapBg = item.system === 'settings' ? '#027eae' : clr(item.color);
+ const wrapBg = item.system === 'settings' ? '#0D1117' : clr(item.color);
w.style.cssText = `--pw:${sz}px;--tc:${wrapBg};width:${sz}px;height:${sz}px;border-radius:${r}px;background-color:${wrapBg};position:relative;flex-shrink:0;overflow:visible;display:flex;align-items:center;justify-content:center;`;
const rawIcon = item.iconUrl || '';
if (item.system === 'settings') {
diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js
index ffe2573..75c0f15 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=69f5c7f9';
-import { createListbox } from '/js/listbox.js?v=2b633751';
-import { renderColorControl } from '/js/admin-color-control.js?v=d0c89032';
+import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=dc0e02b6';
+import { createListbox } from '/js/listbox.js?v=96066369';
+import { renderColorControl } from '/js/admin-color-control.js?v=a48fbc58';
import {
seedCarried,
applyOptionSet,
@@ -15,7 +15,7 @@ import {
visibleFieldFlags,
} from '/js/admin-logic.js?v=e3673bd7';
import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';
-import { qi } from '/js/utils.js?v=5d2b6f16';
+import { qi } from '/js/utils.js?v=045df327';
const PE =
' ';
diff --git a/public/js/widget-glyphs.js b/public/js/widget-glyphs.js
index 2f7a86b..9025b7f 100644
--- a/public/js/widget-glyphs.js
+++ b/public/js/widget-glyphs.js
@@ -1,41 +1,28 @@
// @ts-check
-/* Drawn on the same 24-unit grid as the other item-list icons, and stroked
- with currentColor so they follow the theme. Do not replace with an . */
+import { iconSvg } from '/js/icon-set.js?v=606a68c6';
+/* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */
/** @type {Record} */
-const PATHS = {
- clock: ' ',
- weather:
- ' ',
- gauge:
- ' ',
- shield:
- ' ',
- drive:
- ' ',
- archive:
- ' ',
- shelf:
- ' ',
- play: ' ',
- network:
- ' ',
- merge:
- ' ',
- panels:
- ' ',
+const GLYPH_ICON = {
+ clock: 'clock',
+ weather: 'weather',
+ gauge: 'system',
+ shield: 'dns',
+ drive: 'disk',
+ archive: 'backup',
+ shelf: 'books',
+ play: 'nowplaying',
+ network: 'connections',
+ merge: 'github',
+ panels: 'switcher',
};
/** The glyph names a manifest may declare. */
-export const GLYPH_NAMES = Object.freeze(Object.keys(PATHS));
+export const GLYPH_NAMES = Object.freeze(Object.keys(GLYPH_ICON));
/** One glyph as SVG markup, or null when the name is not one of ours.
@param {unknown} name @returns {string|null} */
export function widgetGlyph(name) {
- const d = typeof name === 'string' ? PATHS[name] : undefined;
- if (!d) return null;
- return (
- '${d} `
- );
+ const id = typeof name === 'string' ? GLYPH_ICON[name] : undefined;
+ return id ? iconSvg(id, 26) : null;
}
diff --git a/public/widgets/system-summary/index.html b/public/widgets/system-summary/index.html
index 04068e2..5c10abe 100644
--- a/public/widgets/system-summary/index.html
+++ b/public/widgets/system-summary/index.html
@@ -76,6 +76,7 @@
/* Network slot — text only, always one line */
.net-row { display: flex; gap: 6px; flex-wrap: nowrap; align-items: center; white-space: nowrap; }
+ .net-ico { vertical-align: -0.125em; }
.net-item { font-size: clamp(10px, 2.6cqw, 13px); font-weight: 500;
color: rgba(255,255,255,0.65); white-space: nowrap; }
@media (prefers-reduced-motion: reduce){
@@ -265,8 +266,8 @@
return html``;
}
return html``;
}
diff --git a/src/components/IconSet.astro b/src/components/IconSet.astro
new file mode 100644
index 0000000..05048eb
--- /dev/null
+++ b/src/components/IconSet.astro
@@ -0,0 +1,74 @@
+---
+import { readRepoFile } from '../lib/repo';
+
+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} `;
+}
+---
+
+{
+ source ? (
+
+
+ {ids.map(id => (
+
+
+
+
+ {id}
+
+ ))}
+
+ ) : (
+
+ See ui/js/icon-set.js .
+
+ )
+}
+
+
diff --git a/src/content/docs/docs/contributing/design-system.mdx b/src/content/docs/docs/contributing/design-system.mdx
index c1dcaa1..33f20ab 100644
--- a/src/content/docs/docs/contributing/design-system.mdx
+++ b/src/content/docs/docs/contributing/design-system.mdx
@@ -1,8 +1,10 @@
---
title: Stackyard design system
-description: The Stackyard colours, logo and the few design rules that go beyond Apple's Human Interface Guidelines.
+description: The Stackyard colours, logo, icons and the few design rules that go beyond Apple's Human Interface Guidelines.
---
+import IconSet from '../../../../components/IconSet.astro';
+
Stackyard follows Apple's [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines) for type, spacing, controls and motion. This page covers only what is specific to Stackyard. Tokens live in `ui/css/tokens.css`.
## Colour
@@ -58,3 +60,38 @@ Use the files as they are. Do not recolour, stretch or redraw them.
Mark, light
+
+## Icons
+
+The interface draws one icon set, defined in [`ui/js/icon-set.js`](https://github.com/SandObserver/stackyard/blob/main/ui/js/icon-set.js). This list is built from that file, so it always matches the app.
+
+
+
+Each icon is a short list of shapes on a 24-unit grid. The file turns the list into an SVG sprite. Icons follow the text colour, and one part per icon takes the accent.
+
+| Rule | Value |
+| --- | --- |
+| Grid | 24 by 24. Draw inside 2 to 22. |
+| Coordinates | Whole numbers on every straight edge. |
+| Stroke and gap | 2 and 2. |
+| Corners | 4 for shapes 14 or larger, 3 medium, 2 small. |
+| Bars and dots | Bars 2 high, rows every 4. Dots radius 2, nodes radius 3. |
+| Accent | At most one part: the thing the icon is about. |
+
+### Adding an icon for a widget
+
+1. Add an entry to `ICONS` in `ui/js/icon-set.js`. Draw what the widget shows, in as few shapes as read at 16 pixels.
+
+ ```js
+ system: {
+ shapes: [
+ line('M3 16 7 12 11 15 16 8'), // the graph
+ dot(19, 6, 3, 'accent'), // the latest reading
+ ],
+ },
+ ```
+
+2. Map a glyph name to it in `ui/js/widget-glyphs.js`, and add the same name to `VALID_GLYPHS` in `api/src/widgets.js`.
+3. Set `"glyph"` in the widget's `widget.json`. See [List icon](/docs/create-a-widget/manifest/#list-icon).
+
+`rect`, `circle` and `shape` draw bodies. `detail`, `dot` and `bar` draw small parts. `line` and `ring` draw lines. A test rejects a glyph that points at no icon.
diff --git a/src/content/docs/docs/create-a-widget/manifest.md b/src/content/docs/docs/create-a-widget/manifest.md
index 0d42697..31ecd19 100644
--- a/src/content/docs/docs/create-a-widget/manifest.md
+++ b/src/content/docs/docs/create-a-widget/manifest.md
@@ -60,17 +60,19 @@ Settings lists every item with an icon. A widget that names a `glyph` shows it.
| `glyph` | What it depicts |
| --- | --- |
-| `clock` | A dial and hands |
+| `clock` | A dial with hands |
| `weather` | A sun behind a cloud |
-| `gauge` | A dial with a needle, for a measured figure |
-| `shield` | A shield with record lines, for a name server |
-| `drive` | A drive with a trace across it |
-| `archive` | A store with an arrow into it |
-| `shelf` | Book spines |
+| `gauge` | A rising line, for live system figures |
+| `shield` | Queries flowing out, one blocked |
+| `drive` | A drive with a status light |
+| `archive` | A circular arrow around a clock |
+| `shelf` | Book spines, one leaning |
| `play` | A play mark in a frame |
| `network` | Three linked nodes |
-| `merge` | Two branches joining one |
-| `panels` | Two panels, one handing over to the other |
+| `merge` | A branch leaving a line |
+| `panels` | One card between its neighbours |
+
+The icons come from the shared [icon set](/docs/contributing/design-system/#icons).
Two widgets may not name the same glyph. A test refuses it. A name not on this list is rejected at startup.