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
20 changes: 20 additions & 0 deletions ui/js/dashboard-switch-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,23 @@ export function buildModel(config = {}, opts = {}) {
}
return { size, capacity, openIn, count: slots.length, slots };
}

const RTL_LETTER = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;

/** @param {string} text @returns {'rtl' | 'ltr'} */
export function textDirection(text) {
const first = String(text).match(/\p{L}/u);
return first && RTL_LETTER.test(first[0]) ? 'rtl' : 'ltr';
}

/** @param {string} name @param {(text: string) => boolean} fits @returns {string} */
export function fitName(name, fits) {
if (fits(name)) return name;
const parts = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(name)].map(p => p.segment);
let text = name;
for (let n = parts.length - 1; n >= 1; n--) {
text = `${parts.slice(0, n).join('').trimEnd()}…`;
if (fits(text)) break;
}
return text;
}
23 changes: 20 additions & 3 deletions ui/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,20 @@ function fit(el, min) {
}
}

const BAR_STYLE_ID = 'wt-bar-css';
const BAR_CSS =
'.tb-bar-fill{transform:translateX(calc(var(--tb-pct) - 100%))}' +
'.tb-bar-fill:dir(rtl){transform:translateX(calc(100% - var(--tb-pct)))}';

/* opts: { color='#0a84ff', track='rgba(255,255,255,0.10)', height=6, radius=3 }
track: null paints no track, so the page styles .tb-bar per theme. */
export function barFill(percent, opts = {}) {
const pct = Math.max(0, Math.min(100, Number(percent) || 0));
if (!document.getElementById(BAR_STYLE_ID)) {
const s = document.createElement('style');
s.id = BAR_STYLE_ID;
s.textContent = BAR_CSS;
document.head.appendChild(s);
}
const h = opts.height != null ? opts.height : 6;
const radius = opts.radius != null ? opts.radius : 3;
const track = document.createElement('div');
Expand All @@ -338,13 +348,20 @@ export function barFill(percent, opts = {}) {
const fill = document.createElement('div');
fill.className = 'tb-bar-fill';
fill.style.cssText =
`position:absolute;left:0;top:0;bottom:0;width:${pct}%;border-radius:${radius}px` +
(reducedMotion() ? '' : ';transition:width .4s ease');
`position:absolute;inset:0;border-radius:${radius}px` +
(reducedMotion() ? '' : ';transition:transform .4s cubic-bezier(.23,1,.32,1)');
fill.style.backgroundColor = colorOrFallback(opts.color, '#0a84ff');
track.appendChild(fill);
setBar(track, percent);
return track;
}

/** @param {HTMLElement} track @param {unknown} percent */
export function setBar(track, percent) {
const pct = Math.max(0, Math.min(100, Number(percent) || 0));
/** @type {HTMLElement} */ (track.firstElementChild).style.setProperty('--tb-pct', `${pct}%`);
}

/** Rounded columns, newest on the right, for a short history at a glance.
Built once; update() moves heights only, so a poll never rebuilds the DOM.
track: null paints no track, so the page styles .tb-col per theme.
Expand Down
38 changes: 34 additions & 4 deletions ui/test/clock-timezone.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function element() {
const el = {
style: {},
attrs: {},
writes: {},
children: [],
className: '',
innerHTML: '',
Expand All @@ -33,7 +34,9 @@ function element() {
classList: { add() {}, remove() {}, toggle() {} },
setAttribute(k, v) {
el.attrs[k] = v;
el.writes[k] = (el.writes[k] || 0) + 1;
},
getComputedTextLength: () => 0,
appendChild(c) {
el.children.push(c);
return c;
Expand All @@ -46,14 +49,20 @@ function element() {
return el;
}

async function boot(file, clockTimezone, { resizeDuringLoad = false, search = '?id=c1', config = {} } = {}) {
async function boot(
file,
clockTimezone,
{ resizeDuringLoad = false, search = '?id=c1', config = {}, reduceMotion = true, frames = 0 } = {},
) {
const els = new Map();
const byId = id => els.get(id) || els.set(id, element()).get(id);
const timers = [];
const painted = [];
const resizers = [];
const rafs = [];
const globals = {
location: { search },
window: {},
document: {
documentElement: { getAttribute: () => null },
getElementById: byId,
Expand All @@ -62,14 +71,14 @@ async function boot(file, clockTimezone, { resizeDuringLoad = false, search = '?
hidden: false,
},
addEventListener: (type, fn) => type === 'resize' && resizers.push(fn),
matchMedia: () => ({ matches: true }),
matchMedia: () => ({ matches: reduceMotion }),
fetch: async () => {
if (resizeDuringLoad) for (const fn of resizers) fn();
return { ok: true, json: async () => ({ widgetConfig: { clockTimezone, ...config } }) };
},
setTimeout: fn => timers.push(fn),
clearTimeout() {},
requestAnimationFrame: () => 0,
requestAnimationFrame: fn => rafs.push(fn),
cancelAnimationFrame() {},
__clockDate: () => import('../js/clock-date.js'),
__toolbox: async () => ({
Expand All @@ -86,13 +95,25 @@ async function boot(file, clockTimezone, { resizeDuringLoad = false, search = '?
try {
await new Function(pageScript(file))();
for (const fn of timers.splice(0)) fn();
for (let i = 0; i < frames; i++) {
for (const fn of rafs.splice(0)) fn();
if (rafs.length) break;
for (const fn of timers.splice(0)) fn();
}
} finally {
for (const k of Object.keys(globals)) {
if (saved[k]) Object.defineProperty(globalThis, k, saved[k]);
else delete globalThis[k];
}
}
return { painted, timers, byId, label: byId('widget').attrs['aria-label'], face: byId('face').children.length };
return {
painted,
timers,
rafs,
byId,
label: byId('widget').attrs['aria-label'],
face: byId('face').children.length,
};
}

for (const file of ['digital.html', 'analog.html']) {
Expand Down Expand Up @@ -133,3 +154,12 @@ test('digital.html: the figures move down when the date is off', async () => {
assert.deepEqual([off('hh-over').attrs.y, off('mm-clip').attrs.y], [88, 124]);
assert.equal(off('date-line').style.display, 'none');
});

test('analog.html: the sweep waits on a timer between frames and writes the label only when it changes', async () => {
const r = await boot('analog.html', 'Europe/Berlin', { reduceMotion: false, frames: 6 });
assert.deepEqual(r.painted, []);
assert.equal(r.rafs.length, 1, 'each timer asks for one frame');
assert.equal(r.timers.length, 0, 'each frame schedules one timer');
assert.match(r.label, /^\d\d:\d\d, /);
assert.ok(r.byId('widget').writes['aria-label'] <= 2, 'the label is written once a minute');
});
19 changes: 19 additions & 0 deletions ui/test/connections-map-frames.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,22 @@ test('the pulse resumes when the map comes back on screen', async () => {
map.restore();
}
});

test('the pulse repaints only the marker, not the map dots', async () => {
const map = await mount();
try {
map.render(PULSING);
map.runFrames();
const once = map.dotsDrawn();
assert.ok(once > 1000, 'the first frame draws the map');
for (let i = 0; i < 5; i++) map.runFrames();
assert.equal(map.dotsDrawn(), once);
const fills = map.painted();
assert.ok(fills.length > 1000, 'each frame still shows the map');
map.resize();
map.runFrames();
assert.ok(map.dotsDrawn() > once, 'a resize redraws the map');
} finally {
map.restore();
}
});
60 changes: 38 additions & 22 deletions ui/test/connections-map-mount.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,35 @@ export function placement(math = Math) {
return { D: window.__DOTS, M: window.__MAP };
}

/* Records what the last frame painted: each fill with its colour and shape. */
const ctx = {
fills: [],
fillStyle: '',
_shape: null,
clearRect() {
ctx.fills = [];
},
beginPath() {},
arc(x, y, r) {
ctx._shape = { kind: 'arc', x, y, r };
},
fill() {
ctx.fills.push({ ...ctx._shape, color: ctx.fillStyle });
},
fillRect(x, y, w, h) {
ctx.fills.push({ kind: 'rect', x, y, w, h, color: ctx.fillStyle });
},
};
/* Each canvas records what it last painted: each fill with its colour and
shape. Copying one canvas onto another copies its fills. */
function canvasContext() {
const ctx = {
fills: [],
drawn: 0,
fillStyle: '',
_shape: null,
clearRect() {
ctx.fills = [];
},
beginPath() {},
arc(x, y, r) {
ctx._shape = { kind: 'arc', x, y, r };
},
fill() {
ctx.fills.push({ ...ctx._shape, color: ctx.fillStyle });
ctx.drawn++;
},
fillRect(x, y, w, h) {
ctx.fills.push({ kind: 'rect', x, y, w, h, color: ctx.fillStyle });
ctx.drawn++;
},
drawImage(src) {
ctx.fills.push(...src.getContext().fills);
},
};
return ctx;
}

function element(tag = 'div') {
const el = {
Expand Down Expand Up @@ -100,7 +110,7 @@ function element(tag = 'div') {
return out;
},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 360, height: 170 }),
getContext: () => ctx,
getContext: () => (el._ctx ||= canvasContext()),
};
return el;
}
Expand All @@ -112,13 +122,18 @@ export async function mount({ theme = 'dark', onScreen = true } = {}) {
let render = null;
let screenChanged = null;
const byId = { c: element('canvas'), key: element(), sr: element('p') };
const created = [];

const saved = {};
const globals = {
window: {},
document: {
getElementById: id => byId[id] || element(),
createElement: element,
createElement: tag => {
const el = element(tag);
created.push(el);
return el;
},
createTextNode: t => t,
body: element('body'),
documentElement: { lang: 'en' },
Expand Down Expand Up @@ -174,7 +189,8 @@ export async function mount({ theme = 'dark', onScreen = true } = {}) {
return {
frames,
key: byId.key,
painted: () => ctx.fills,
painted: () => byId.c.getContext().fills,
dotsDrawn: () => created.filter(c => c !== byId.c).reduce((n, c) => n + (c._ctx?.drawn || 0), 0),
sr: byId.sr,
render: data => render(data),
resize: () => resizeCallbacks.forEach(fn => fn()),
Expand Down
38 changes: 38 additions & 0 deletions ui/test/dashboard-switch-model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
deriveComposition,
deriveColor,
buildModel,
fitName,
textDirection,
} from '../js/dashboard-switch-model.js';

/* ── normalizeUrl ── */
Expand Down Expand Up @@ -218,3 +220,39 @@ test('hashString is a stable uint32', () => {
assert.equal(h, hashString('http://dash.lan'));
assert.ok(Number.isInteger(h) && h >= 0 && h <= 0xffffffff);
});

/* ── fitName, textDirection ── */

const fitsIn = max => text => [...new Intl.Segmenter().segment(text)].length <= max;

test('fitName keeps a name that fits', () => {
assert.equal(
fitName('Home', () => true),
'Home',
);
});

test('fitName drops the space before the ellipsis', () => {
assert.equal(fitName('Homelab main', fitsIn(8)), 'Homelab\u2026');
});

test('fitName never splits a grapheme', () => {
const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}';
const out = fitName(`ab${family}cd`, fitsIn(4));
assert.equal(out, `ab${family}\u2026`);
assert.equal(fitName('\u{1F3E0}\u{1F3E1}\u{1F3E2}', fitsIn(2)), '\u{1F3E0}\u2026');
});

test('fitName stops at one grapheme', () => {
assert.equal(
fitName('Dashboard', () => false),
'D\u2026',
);
});

test('textDirection follows the first letter of the name', () => {
assert.equal(textDirection('داشبورد خانه'), 'rtl');
assert.equal(textDirection('1 داشبورد'), 'rtl');
assert.equal(textDirection('Home داشبورد'), 'ltr');
assert.equal(textDirection('123'), 'ltr');
});
52 changes: 52 additions & 0 deletions ui/test/system-summary-faint-bar.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { test } from 'node:test';

const html = fs.readFileSync(new URL('../widgets/system-summary/index.html', import.meta.url), 'utf8');
const toolbox = fs.readFileSync(new URL('../js/widget-toolbox.js', import.meta.url), 'utf8');
const style = html.match(/<style>([\s\S]*?)<\/style>/)?.[1] ?? '';

/** @param {string} selector */
function declarations(selector) {
const out = {};
for (const m of style.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectors = m[1]
.replace(/\/\*[\s\S]*?\*\//g, '')
.split(',')
.map(s => s.trim());
if (!selectors.includes(selector)) continue;
for (const d of m[2].split(';')) {
const i = d.indexOf(':');
if (i > 0) out[d.slice(0, i).trim()] = d.slice(i + 1).trim();
}
}
return out;
}

test('a faint bar is sized by width and does not slide', () => {
const d = declarations('.faint .tb-bar-fill');
assert.equal(d.width, 'var(--tb-pct)');
assert.equal(d.transform, 'none');
});

test('a faint bar animates its width with the toolbox duration and curve', () => {
assert.equal(
declarations('.faint .tb-bar-fill')['transition-property'],
'width !important',
'the inline transition from barFill names transform, so only the property is replaced',
);
});

test('a faint bar keeps its edge ring', () => {
assert.match(declarations('.faint .tb-bar-fill')['box-shadow'] ?? '', /^inset 0 0 0 1px /);
});

test('other bars still slide by transform', () => {
assert.match(toolbox, /\.tb-bar-fill\{transform:translateX\(calc\(var\(--tb-pct\) - 100%\)\)\}/);
assert.match(toolbox, /transition:transform \.4s/);
assert.deepEqual(
Object.keys(declarations('.tb-bar-fill')).filter(k => /width|transform|transition/.test(k)),
[],
'no page rule outside .faint changes how a bar moves',
);
});
Loading
Loading