Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions ui/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down
44 changes: 44 additions & 0 deletions ui/test/widget-placeholder.test.mjs
Original file line number Diff line number Diff line change
@@ -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 playing</);
assert.doesNotMatch(page('books/index.html'), /id="lbl">READING</);
assert.doesNotMatch(page('weather/index.html'), /id="temp">--/);
});
2 changes: 1 addition & 1 deletion ui/widgets/backup/backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
<body>
<div class="widget" id="widget" role="group" aria-label="Backup status"></div>
<script type="module">
import { poll, fetchData, openUrl, linkTo, html, setHtml, loadStrings, wt, errorState, errorLine, formatNumber, cssZoom } from '/js/widget-toolbox.js?v=7d38d7fb';
import { poll, fetchData, openUrl, linkTo, html, setHtml, loadStrings, wt, errorState, errorLine, formatNumber, cssZoom } from '/js/widget-toolbox.js?v=9f9c7421';
await loadStrings();
document.getElementById('widget').setAttribute('aria-label', wt('ui.backupStatus', 'Backup status'));
const id = new URLSearchParams(location.search).get('id') || '';
Expand Down
29 changes: 25 additions & 4 deletions ui/widgets/books/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
<body>
<div class="card" id="card" role="group" aria-label="Books">
<div class="bk-link" id="bk-link"></div>
<div class="head" id="lbl">READING</div>
<div class="head" id="lbl"></div>
<div class="shelves" id="shelves">
<div class="shelf-wrap">
<span class="shelf-lbl" hidden></span>
Expand All @@ -101,9 +101,8 @@
<span class="sr" id="sr" aria-live="polite"></span>
</div>
<script type="module">
import { poll, linkTo, loadStrings, wt, formatNumber, errorState } from '/js/widget-toolbox.js?v=7d38d7fb';
import { poll, linkTo, loadStrings, wt, formatNumber, errorState, placeholder } from '/js/widget-toolbox.js?v=9f9c7421';
await loadStrings();
document.getElementById('lbl').textContent = wt('ui.reading', 'Reading');
document.getElementById('card').setAttribute('aria-label', wt('widget.label', 'Books'));
const API='/api';
const params=new URLSearchParams(location.search);
Expand Down Expand Up @@ -162,6 +161,7 @@

function render(books,list,shelf){
list.innerHTML='';
list.removeAttribute('aria-hidden');
list.classList.remove('sparse','pointing');
shelf.querySelectorAll('.empty').forEach(n=>n.remove());
/* Removing the bullets drops the list semantics in one engine, so the role
Expand Down Expand Up @@ -210,7 +210,24 @@
});
}

function paintPlaceholders(){
lbl.replaceChildren(placeholder(72,9));
for(const bay of bays){
const n=Math.min(SHOW,Math.max(5,Math.floor((bay.list.clientWidth||140)/18)));
bay.list.setAttribute('aria-hidden','true');
bay.list.replaceChildren(...Array.from({length:n},(_,i)=>{
const ph=placeholder(14+(i*7)%5,0,1.5);
ph.style.height=(62+(i*37)%32)+'%';
ph.style.borderRadius='1.5px 1.5px 0 0';
return ph;
}));
}
fT.replaceChildren(placeholder(90,10));
fP.replaceChildren(placeholder(36,10));
}

async function boot(){
paintPlaceholders();
try{
const r=await fetch(`${API}/widget-config/${encodeURIComponent(id)}`);
if(r.ok){ const c=await r.json(); href=(c&&c.widgetConfig&&c.widgetConfig.href)||''; }
Expand All @@ -223,7 +240,11 @@

poll({ interval:POLL_MS,
isEmpty:d=>(Array.isArray(d&&d.shelves)?d.shelves:[]).every(sh=>!(sh&&Array.isArray(sh.books)&&sh.books.length)),
onEmpty:()=>{ paintFoot(null); state.empty(wt('ui.noBooks','No books')); },
onEmpty:d=>{
lbl.textContent=SHELVES>1?wt('widget.label','Books'):shelfName((d&&d.shelves||[])[0]);
for(const bay of bays){ bay.list.replaceChildren(); bay.list.removeAttribute('aria-hidden'); }
paintFoot(null); state.empty(wt('ui.noBooks','No books'));
},
onError:({error,everOk,stale,since})=>{ if(!everOk||stale) state.fail(error,{since,inert:everOk}); },
render:d=>{
state.ok();
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/clock/analog.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
async function showZoneError(){
const w=document.getElementById('widget');
w.className='widget mode-'+_mode;
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=7d38d7fb');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=9f9c7421');
await loadStrings();
const line=wt('ui.unknownZone','Unknown timezone');
errorState({ root: w, place: 'center' }).empty(line);
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/clock/digital.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
}
async function showZoneError() {
const w = document.getElementById('widget');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=7d38d7fb');
const { errorState, loadStrings, wt } = await import('/js/widget-toolbox.js?v=9f9c7421');
await loadStrings();
const line = wt('ui.unknownZone', 'Unknown timezone');
errorState({ root: w, place: 'center' }).empty(line);
Expand Down
2 changes: 1 addition & 1 deletion ui/widgets/connections/connections-map.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
})(window.__DOTS);
</script>
<script type="module">
import { poll, fetchData, openUrl, html, setHtml, colorOrFallback, loadStrings, wt, errorState, theme, formatNumber, cssZoom, linkTo } from '/js/widget-toolbox.js?v=7d38d7fb';
import { poll, fetchData, openUrl, html, setHtml, colorOrFallback, loadStrings, wt, errorState, theme, formatNumber, cssZoom, linkTo } from '/js/widget-toolbox.js?v=9f9c7421';
await loadStrings();
document.getElementById('c').setAttribute('aria-label', wt('ui.worldMap', 'World map of connections'));
(function(){
Expand Down
24 changes: 23 additions & 1 deletion ui/widgets/connections/connections-vpn.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
<div class="wrap" id="wrap" role="img" aria-label="VPN status"></div>
<script>window.__DOTS={"cols":100,"rows":39,"minLat":-58.0,"maxLat":84.0,"countries":["CA","GL","NO","RU","US","SE","FI","IS","GB","DK","LV","IE","NL","DE","PL","BY","KZ","CN","FR","CZ","UA","MN","IT","HR","RS","RO","ES","AL","BG","GE","TM","UZ","KG","PT","GR","TR","IR","AZ","TJ","KP","MA","DZ","TN","SY","IQ","AF","PK","IN","KR","JP","MX","LY","EG","SA","NP","BT","EH","MR","ML","AE","OM","MM","CN-TW","NE","TD","SD","LA","VN","ER","YE","TH","PH","NI","SN","BF","NG","ET","KH","CO","VE","SL","GN","CI","GH","BJ","CF","SS","-99","GY","SR","LR","CM","SO","MY","BR","GA","CG","CD","UG","KE","ID","EC","PE","RW","TZ","PG","AO","ZM","BO","MW","MZ","AU","ZW","MG","VU","PY","NA","BW","CL","AR","ZA","UY","NZ"],"dots":[26,0,0,27,0,0,28,0,0,29,0,0,30,0,0,31,0,0,32,0,0,34,0,1,37,0,1,38,0,1,39,0,1,40,0,1,21,1,0,24,1,0,26,1,0,27,1,0,28,1,0,30,1,1,31,1,1,32,1,1,33,1,1,34,1,1,35,1,1,36,1,1,37,1,1,38,1,1,39,1,1,40,1,1,41,1,1,42,1,1,43,1,1,44,1,1,53,1,2,54,1,2,78,1,3,19,2,0,23,2,0,24,2,0,25,2,0,26,2,0,27,2,0,34,2,1,35,2,1,36,2,1,37,2,1,38,2,1,39,2,1,40,2,1,41,2,1,42,2,1,43,2,1,65,2,3,66,2,3,74,2,3,75,2,3,76,2,3,77,2,3,78,2,3,79,2,3,80,2,3,88,2,3,89,2,3,91,2,3,0,3,3,6,3,4,18,3,0,19,3,0,20,3,0,23,3,0,25,3,0,26,3,0,27,3,0,28,3,0,29,3,0,35,3,1,36,3,1,37,3,1,38,3,1,39,3,1,40,3,1,41,3,1,42,3,1,43,3,1,65,3,3,69,3,3,70,3,3,71,3,3,72,3,3,73,3,3,74,3,3,75,3,3,76,3,3,77,3,3,78,3,3,79,3,3,80,3,3,81,3,3,82,3,3,83,3,3,84,3,3,85,3,3,87,3,3,88,3,3,89,3,3,90,3,3,91,3,3,0,4,3,4,4,4,5,4,4,6,4,4,7,4,4,8,4,4,9,4,4,10,4,4,11,4,0,12,4,0,13,4,0,14,4,0,15,4,0,16,4,0,17,4,0,18,4,0,19,4,0,20,4,0,21,4,0,22,4,0,23,4,0,24,4,0,25,4,0,26,4,0,30,4,0,31,4,0,35,4,1,36,4,1,37,4,1,38,4,1,39,4,1,40,4,1,54,4,2,55,4,5,56,4,5,57,4,6,58,4,3,59,4,3,60,4,3,62,4,3,63,4,3,64,4,3,65,4,3,66,4,3,67,4,3,68,4,3,69,4,3,71,4,3,72,4,3,73,4,3,74,4,3,75,4,3,76,4,3,77,4,3,78,4,3,79,4,3,80,4,3,81,4,3,82,4,3,83,4,3,84,4,3,85,4,3,86,4,3,87,4,3,88,4,3,89,4,3,90,4,3,91,4,3,92,4,3,93,4,3,94,4,3,95,4,3,96,4,3,97,4,3,98,4,3,99,4,3,5,5,4,6,5,4,7,5,4,8,5,4,9,5,4,10,5,4,11,5,0,12,5,0,13,5,0,14,5,0,15,5,0,16,5,0,17,5,0,18,5,0,19,5,0,20,5,0,21,5,0,22,5,0,23,5,0,24,5,0,26,5,0,27,5,0,30,5,0,31,5,0,36,5,1,37,5,1,38,5,1,44,5,7,53,5,5,54,5,5,55,5,5,56,5,6,57,5,6,58,5,3,59,5,3,60,5,3,61,5,3,62,5,3,63,5,3,64,5,3,65,5,3,66,5,3,67,5,3,68,5,3,69,5,3,70,5,3,71,5,3,72,5,3,73,5,3,74,5,3,75,5,3,76,5,3,77,5,3,78,5,3,79,5,3,80,5,3,81,5,3,82,5,3,83,5,3,84,5,3,85,5,3,86,5,3,87,5,3,88,5,3,89,5,3,90,5,3,91,5,3,92,5,3,93,5,3,94,5,3,95,5,3,96,5,3,97,5,3,98,5,3,99,5,3,4,6,4,5,6,4,6,6,4,7,6,4,8,6,4,10,6,4,11,6,0,12,6,0,13,6,0,14,6,0,15,6,0,16,6,0,17,6,0,18,6,0,19,6,0,20,6,0,21,6,0,22,6,0,23,6,0,28,6,0,29,6,0,30,6,0,37,6,1,51,6,2,52,6,2,53,6,5,54,6,5,56,6,6,58,6,3,59,6,3,60,6,3,61,6,3,62,6,3,63,6,3,64,6,3,65,6,3,66,6,3,67,6,3,68,6,3,69,6,3,70,6,3,71,6,3,72,6,3,73,6,3,74,6,3,75,6,3,76,6,3,77,6,3,78,6,3,79,6,3,80,6,3,81,6,3,82,6,3,83,6,3,84,6,3,85,6,3,86,6,3,87,6,3,88,6,3,89,6,3,90,6,3,91,6,3,92,6,3,95,6,3,96,6,3,13,7,0,14,7,0,15,7,0,16,7,0,17,7,0,18,7,0,19,7,0,20,7,0,21,7,0,22,7,0,23,7,0,24,7,0,29,7,0,30,7,0,31,7,0,32,7,0,48,7,8,52,7,9,53,7,5,54,7,5,56,7,10,57,7,10,58,7,3,59,7,3,60,7,3,61,7,3,62,7,3,63,7,3,64,7,3,65,7,3,66,7,3,67,7,3,68,7,3,69,7,3,70,7,3,71,7,3,72,7,3,73,7,3,74,7,3,75,7,3,76,7,3,77,7,3,78,7,3,79,7,3,80,7,3,81,7,3,82,7,3,83,7,3,84,7,3,85,7,3,86,7,3,87,7,3,93,7,3,94,7,3,14,8,0,15,8,0,16,8,0,17,8,0,18,8,0,19,8,0,20,8,0,21,8,0,22,8,0,23,8,0,24,8,0,25,8,0,26,8,0,28,8,0,29,8,0,30,8,0,31,8,0,32,8,0,33,8,0,34,8,0,47,8,11,49,8,8,51,8,12,52,8,13,53,8,13,54,8,14,55,8,14,56,8,14,57,8,15,58,8,15,59,8,3,60,8,3,61,8,3,62,8,3,63,8,3,64,8,3,65,8,3,66,8,3,67,8,16,68,8,16,69,8,16,70,8,16,71,8,16,72,8,3,73,8,3,74,8,3,75,8,3,76,8,3,77,8,3,78,8,3,79,8,3,80,8,3,81,8,3,82,8,3,83,8,3,84,8,17,85,8,3,86,8,3,87,8,3,88,8,3,89,8,3,93,8,3,16,9,0,17,9,0,18,9,0,19,9,0,20,9,0,21,9,0,22,9,0,23,9,0,24,9,0,25,9,0,26,9,0,27,9,0,28,9,0,29,9,0,30,9,0,32,9,0,34,9,0,49,9,18,50,9,18,51,9,18,52,9,13,53,9,13,54,9,19,55,9,14,56,9,20,57,9,20,58,9,20,59,9,20,60,9,20,61,9,3,62,9,3,63,9,16,64,9,16,65,9,16,66,9,16,67,9,16,68,9,16,69,9,16,70,9,16,71,9,16,72,9,16,73,9,16,74,9,3,75,9,21,76,9,21,77,9,21,78,9,21,79,9,21,80,9,3,81,9,21,82,9,17,83,9,17,84,9,17,85,9,17,86,9,3,87,9,3,88,9,3,89,9,3,16,10,4,17,10,4,18,10,4,19,10,4,20,10,4,21,10,4,22,10,4,23,10,4,24,10,4,25,10,4,26,10,4,27,10,0,28,10,0,29,10,0,30,10,4,31,10,0,50,10,18,51,10,18,52,10,22,53,10,22,54,10,23,55,10,24,56,10,25,57,10,25,59,10,3,60,10,3,61,10,3,62,10,3,65,10,16,66,10,16,67,10,16,68,10,16,69,10,16,70,10,16,71,10,16,72,10,16,73,10,17,74,10,17,75,10,21,76,10,21,77,10,21,78,10,21,79,10,21,80,10,21,81,10,21,82,10,17,83,10,17,84,10,17,85,10,17,86,10,17,87,10,3,15,11,4,16,11,4,17,11,4,18,11,4,19,11,4,20,11,4,21,11,4,22,11,4,23,11,4,24,11,4,25,11,4,26,11,4,27,11,4,28,11,4,29,11,4,47,11,26,48,11,26,49,11,26,50,11,26,52,11,18,53,11,22,55,11,27,56,11,28,57,11,28,62,11,29,65,11,16,66,11,30,67,11,31,68,11,16,69,11,16,70,11,32,71,11,32,72,11,17,73,11,17,74,11,17,75,11,17,76,11,17,77,11,17,78,11,17,79,11,17,80,11,17,81,11,17,82,11,17,83,11,17,84,11,17,85,11,17,16,12,4,17,12,4,18,12,4,19,12,4,20,12,4,21,12,4,22,12,4,23,12,4,24,12,4,25,12,4,26,12,4,27,12,4,28,12,4,47,12,33,48,12,26,49,12,26,54,12,22,56,12,34,57,12,35,58,12,35,59,12,35,60,12,35,61,12,35,62,12,36,63,12,37,65,12,30,66,12,30,67,12,30,68,12,31,69,12,38,70,12,38,71,12,17,72,12,17,73,12,17,74,12,17,75,12,17,76,12,17,77,12,17,78,12,17,79,12,17,80,12,17,81,12,17,82,12,17,85,12,39,16,13,4,17,13,4,18,13,4,19,13,4,20,13,4,21,13,4,22,13,4,23,13,4,24,13,4,25,13,4,26,13,4,27,13,4,28,13,4,48,13,40,49,13,41,50,13,41,51,13,41,52,13,42,60,13,43,61,13,44,62,13,44,63,13,36,64,13,36,65,13,36,66,13,36,67,13,45,68,13,45,69,13,45,70,13,46,71,13,47,72,13,17,73,13,17,74,13,17,75,13,17,76,13,17,77,13,17,78,13,17,79,13,17,80,13,17,81,13,17,82,13,17,85,13,48,87,13,49,88,13,49,19,14,50,20,14,50,21,14,4,22,14,4,23,14,4,24,14,4,25,14,4,26,14,4,47,14,40,48,14,40,49,14,41,50,14,41,51,14,41,52,14,41,53,14,51,54,14,51,56,14,51,57,14,52,58,14,52,60,14,53,61,14,53,62,14,44,63,14,36,64,14,36,65,14,36,66,14,36,67,14,45,68,14,45,69,14,46,70,14,46,71,14,47,72,14,17,73,14,17,74,14,17,75,14,17,76,14,17,77,14,17,78,14,17,79,14,17,80,14,17,81,14,17,82,14,17,83,14,17,18,15,50,19,15,50,20,15,50,21,15,50,22,15,4,27,15,4,46,15,40,47,15,40,48,15,41,49,15,41,50,15,41,51,15,41,52,15,41,53,15,51,54,15,51,55,15,51,56,15,51,57,15,52,58,15,52,60,15,53,61,15,53,62,15,53,63,15,53,65,15,36,66,15,36,67,15,46,68,15,46,69,15,47,70,15,47,71,15,47,72,15,47,73,15,54,74,15,47,75,15,55,76,15,47,77,15,17,78,15,17,79,15,17,80,15,17,81,15,17,82,15,17,83,15,17,20,16,50,21,16,50,22,16,50,46,16,56,47,16,57,48,16,58,49,16,41,50,16,41,51,16,41,52,16,41,53,16,51,54,16,51,55,16,51,56,16,51,57,16,52,58,16,52,59,16,52,61,16,53,62,16,53,63,16,53,64,16,59,65,16,60,69,16,47,70,16,47,71,16,47,72,16,47,73,16,47,74,16,47,75,16,47,76,16,61,77,16,17,78,16,17,79,16,17,80,16,17,81,16,17,82,16,17,83,16,62,21,17,50,22,17,50,25,17,50,45,17,57,46,17,57,47,17,57,48,17,58,49,17,58,50,17,58,51,17,41,52,17,63,53,17,63,54,17,64,55,17,64,56,17,51,57,17,65,58,17,65,59,17,65,61,17,53,62,17,53,63,17,53,64,17,53,65,17,60,70,17,47,71,17,47,72,17,47,73,17,47,76,17,61,77,17,61,78,17,66,79,17,67,22,18,50,23,18,50,24,18,50,45,18,57,46,18,57,47,18,57,48,18,58,49,18,58,50,18,58,51,18,63,52,18,63,53,18,63,54,18,64,55,18,64,56,18,64,57,18,65,58,18,65,59,18,65,60,18,68,62,18,69,63,18,69,64,18,69,70,18,47,71,18,47,72,18,47,76,18,61,77,18,70,78,18,70,79,18,66,83,18,71,26,19,72,45,19,73,46,19,73,47,19,58,48,19,58,49,19,74,50,19,63,51,19,75,52,19,63,53,19,75,54,19,64,55,19,64,56,19,65,57,19,65,58,19,65,59,19,65,60,19,76,61,19,76,62,19,69,71,19,47,77,19,61,78,19,77,79,19,77,29,20,78,30,20,79,31,20,79,32,20,79,46,20,80,47,20,81,48,20,82,49,20,83,50,20,84,51,20,75,52,20,75,53,20,75,54,20,64,55,20,64,56,20,85,57,20,86,58,20,86,59,20,76,60,20,76,61,20,76,62,20,87,63,20,87,71,20,47,77,20,70,28,21,78,29,21,78,30,21,78,31,21,79,32,21,79,33,21,88,34,21,89,47,21,90,48,21,82,49,21,83,51,21,75,52,21,91,53,21,91,54,21,85,55,21,85,56,21,85,57,21,86,58,21,86,59,21,86,60,21,76,61,21,76,62,21,76,63,21,92,78,21,93,82,21,93,28,22,78,29,22,78,30,22,78,31,22,79,32,22,94,33,22,88,34,22,94,35,22,94,53,22,95,54,22,96,55,22,97,56,22,97,57,22,97,58,22,97,59,22,98,60,22,99,61,22,92,62,22,92,77,22,100,78,22,93,81,22,93,82,22,100,28,23,101,29,23,102,30,23,78,31,23,94,32,23,94,33,23,94,34,23,94,35,23,94,36,23,94,37,23,94,53,23,95,54,23,96,55,23,97,56,23,97,57,23,97,58,23,103,59,23,104,60,23,99,61,23,99,78,23,100,81,23,100,83,23,100,27,24,102,28,24,102,29,24,102,30,24,94,31,24,94,32,24,94,33,24,94,34,24,94,35,24,94,36,24,94,37,24,94,38,24,94,39,24,94,53,24,97,54,24,97,55,24,97,56,24,97,57,24,97,58,24,104,59,24,104,60,24,104,88,24,100,89,24,105,90,24,105,28,25,102,29,25,102,30,25,94,31,25,94,32,25,94,33,25,94,34,25,94,35,25,94,36,25,94,37,25,94,38,25,94,39,25,94,54,25,106,55,25,106,56,25,97,57,25,97,58,25,107,59,25,104,60,25,104,82,25,100,83,25,100,89,25,105,29,26,102,30,26,102,31,26,108,32,26,94,33,26,94,34,26,94,35,26,94,36,26,94,37,26,94,38,26,94,54,26,106,55,26,106,56,26,106,57,26,107,58,26,107,59,26,109,60,26,110,86,26,111,87,26,111,89,26,111,29,27,102,30,27,102,31,27,108,32,27,108,33,27,94,34,27,94,35,27,94,36,27,94,37,27,94,38,27,94,53,27,106,54,27,106,55,27,106,56,27,107,57,27,107,58,27,112,59,27,110,60,27,110,63,27,113,85,27,111,86,27,111,87,27,111,89,27,111,96,27,114,31,28,108,32,28,108,33,28,115,34,28,94,35,28,94,36,28,94,37,28,94,38,28,94,54,28,116,55,28,116,56,28,117,57,28,112,58,28,112,59,28,110,62,28,113,63,28,113,83,28,111,84,28,111,85,28,111,86,28,111,87,28,111,88,28,111,89,28,111,90,28,111,30,29,118,31,29,119,32,29,119,33,29,115,34,29,115,35,29,94,36,29,94,37,29,94,54,29,116,55,29,116,56,29,117,57,29,117,58,29,120,59,29,110,62,29,113,82,29,111,83,29,111,84,29,111,85,29,111,86,29,111,87,29,111,88,29,111,89,29,111,90,29,111,91,29,111,30,30,118,31,30,119,32,30,119,33,30,119,34,30,115,35,30,94,36,30,94,54,30,116,55,30,116,56,30,120,57,30,120,58,30,120,82,30,111,83,30,111,84,30,111,85,30,111,86,30,111,87,30,111,88,30,111,89,30,111,90,30,111,91,30,111,92,30,111,30,31,118,31,31,119,32,31,119,33,31,119,34,31,94,35,31,94,55,31,120,56,31,120,57,31,120,82,31,111,83,31,111,84,31,111,85,31,111,86,31,111,87,31,111,88,31,111,89,31,111,90,31,111,91,31,111,92,31,111,30,32,118,31,32,119,32,32,119,33,32,119,34,32,121,55,32,120,82,32,111,88,32,111,89,32,111,90,32,111,91,32,111,30,33,119,31,33,119,32,33,119,33,33,119,89,33,111,90,33,111,99,33,122,29,34,118,30,34,119,31,34,119,90,34,111,29,35,118,30,35,119,96,35,122,29,36,118,30,36,119,29,37,118,30,37,118]};</script>
<script type="module">
import { readableInk, poll, fetchData, linkTo, esc, loadStrings, wt, errorLine } from '/js/widget-toolbox.js?v=7d38d7fb';
import { readableInk, poll, fetchData, linkTo, esc, loadStrings, wt, errorLine, placeholder } from '/js/widget-toolbox.js?v=9f9c7421';
await loadStrings();
document.getElementById('wrap').setAttribute('aria-label', wt('ui.vpnStatus', 'VPN status'));
(function(){
Expand Down Expand Up @@ -189,6 +189,28 @@
wrap.setAttribute('aria-label',aria);
}

function renderPlaceholder(){
const small=size==='small';
const pad=document.createElement('div'); pad.className='pad';
const info=document.createElement('div'); info.className='info';
const eyebrow=document.createElement('div'); eyebrow.className='eyebrow';
eyebrow.append(placeholder(30,9),placeholder(48,9));
const hero=document.createElement('div'); hero.className='hero';
hero.append(placeholder(small?96:130,small?12:18));
info.append(eyebrow,hero);
if(!small){
const metrics=document.createElement('div'); metrics.className='metrics';
metrics.append(placeholder(104,10),placeholder(78,10));
info.append(metrics);
}
const geo=document.createElement('div'); geo.className='geo';
geo.style.display='flex'; geo.style.alignItems='center'; geo.style.justifyContent='center';
geo.append(placeholder(small?64:96,small?44:64,12));
pad.append(info,geo);
wrap.replaceChildren(pad);
}
renderPlaceholder();

async function vfetch(){
if(!id) throw new Error('fetch failed');
return fetchData('vpn');
Expand Down
Loading
Loading