From 1ec09dff4349ba70a8e9abe07a1d89fd8139c828 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:45:21 -0400 Subject: [PATCH 1/2] Show placeholder shapes while widgets load, not empty cards or wrong words --- ui/js/widget-toolbox.js | 26 ++++++++++++ ui/test/widget-placeholder.test.mjs | 44 +++++++++++++++++++++ ui/widgets/books/index.html | 29 ++++++++++++-- ui/widgets/connections/connections-vpn.html | 24 ++++++++++- ui/widgets/dns/index.html | 19 ++++++--- ui/widgets/github/pullrequests.html | 11 +++++- ui/widgets/nowplaying/index.html | 16 +++++--- ui/widgets/weather/index.html | 17 ++++++-- 8 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 ui/test/widget-placeholder.test.mjs diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index da8c7d42..994a00fe 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -488,6 +488,32 @@ export function errorState(opts = {}) { return _errorState(Object.assign({ t: _t }, opts)); } +const PH_STYLE_ID = 'wt-ph-css'; +const PH_CSS = + '.wt-ph{display:inline-block;vertical-align:middle;flex:none;border-radius:4px;' + + 'background:var(--wt-ph,rgba(235,235,245,0.16))}' + + 'html[data-theme="light"] .wt-ph{background:var(--wt-ph,rgba(60,60,67,0.12))}'; + +/** A soft rounded shape that stands in for content still loading. Set --wt-ph + on an ancestor to recolour it for a light surface. + @param {number|string} width px, or any CSS length @param {number} [height] px + @param {number} [radius] px @returns {HTMLElement} */ +export function placeholder(width, height = 10, radius = 4) { + if (!document.getElementById(PH_STYLE_ID)) { + const s = document.createElement('style'); + s.id = PH_STYLE_ID; + s.textContent = PH_CSS; + document.head.appendChild(s); + } + const el = document.createElement('span'); + el.className = 'wt-ph'; + el.setAttribute('aria-hidden', 'true'); + el.style.width = typeof width === 'number' ? width + 'px' : width; + el.style.height = height + 'px'; + el.style.borderRadius = radius + 'px'; + return el; +} + /** The reader's wording for a failure, for a widget that draws its own state. @param {unknown} err a caught error, or { kind } from a response @returns {string} */ diff --git a/ui/test/widget-placeholder.test.mjs b/ui/test/widget-placeholder.test.mjs new file mode 100644 index 00000000..0af6cd42 --- /dev/null +++ b/ui/test/widget-placeholder.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { register } from 'node:module'; +import { test } from 'node:test'; + +register('./js-root-hooks.mjs', import.meta.url); +globalThis.location = /** @type {any} */ ({ search: '?id=test' }); + +const nodes = new Map(); +const mk = tag => ({ + tagName: tag, + style: {}, + attrs: {}, + setAttribute(k, v) { + this.attrs[k] = v; + }, + appendChild(c) { + if (c.id) nodes.set(c.id, c); + }, +}); +globalThis.document = /** @type {any} */ ({ + documentElement: { getAttribute: () => 'dark' }, + head: mk('head'), + getElementById: id => nodes.get(id) || null, + createElement: mk, +}); +const { placeholder } = await import('../js/widget-toolbox.js'); + +test('a placeholder is a hidden, sized shape, and its style is added once', () => { + const a = placeholder(72, 9); + const b = placeholder('40%', 12, 6); + assert.equal(a.className, 'wt-ph'); + assert.equal(a.attrs['aria-hidden'], 'true'); + assert.deepEqual([a.style.width, a.style.height, a.style.borderRadius], ['72px', '9px', '4px']); + assert.deepEqual([b.style.width, b.style.height, b.style.borderRadius], ['40%', '12px', '6px']); + assert.equal([...nodes.keys()].filter(k => k === 'wt-ph-css').length, 1); +}); + +test('no widget claims a state before its data arrives', () => { + const page = p => fs.readFileSync(new URL(`../widgets/${p}`, import.meta.url), 'utf8'); + assert.doesNotMatch(page('nowplaying/index.html'), /id="title">Nothing playingREADING--/); +}); diff --git a/ui/widgets/books/index.html b/ui/widgets/books/index.html index 6337f328..be5fab59 100644 --- a/ui/widgets/books/index.html +++ b/ui/widgets/books/index.html @@ -85,7 +85,7 @@
-
READING
+
@@ -101,9 +101,8 @@