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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The Connections map spreads a country's highlighted dots across it, clustered
around the capital, instead of bunching them at the country's centre.

- Badges no longer lose their rounded shape in Safari after a long session.

### Security

- A Connections tile that cannot reach a service now says so in plain words
Expand Down
5 changes: 5 additions & 0 deletions ui/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@
justify-content:center;
font-size:calc(13px * var(--sc,1)); font-weight:800; color:#fff; line-height:1;
z-index:20; pointer-events:none;
/* Pins the badge to its own compositing layer for the life of the page.
WebKit otherwise promotes and demotes this rounded, absolutely
positioned element as its classes and inline colours change next to the
transformed icon, and its clipped corners can rasterise torn. */
transform:translateZ(0);
}
.badge.on { display:flex }
.badge.red { background:var(--badge-red) }
Expand Down
9 changes: 9 additions & 0 deletions ui/js/badge-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,12 @@ export function computeBadgeVisual({

return { cls, txt, bg, aria, color, title: reason };
}

/** Identity of a rendered badge, for skipping DOM writes that would change nothing.
Covers every field {@link computeBadgeVisual} returns that reaches the element.
@param {{ cls?: string, txt?: string, bg?: string, aria?: string, color?: string, title?: string }} visual */
export function badgeSignature({ cls = '', txt = '', bg = '', aria = '', color = '', title = '' } = {}) {
/* NUL separator: a plain space would let a class list and the label pack
into one string two different ways and compare equal. */
return [cls, txt, bg, aria, color, title].join('\u0000');
}
10 changes: 9 additions & 1 deletion ui/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { initI18n, t, currentLang } from '/js/i18n.js?v=133a7aac';
import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=dab9978e';
import { sanitizeItemLinks } from '/js/link-url.js?v=19038560';
import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=07fd9e2d';
import { computeBadgeVisual } from '/js/badge-logic.js?v=d278c683';
import { badgeSignature, computeBadgeVisual } from '/js/badge-logic.js?v=d278c683';
import { configChanged, landingAfterSetup } from '/js/dashboard-logic.js?v=640430ba';
import { trapFocus } from '/js/dialog.js?v=4ff94595';

Expand Down Expand Up @@ -74,6 +74,10 @@ let _badgeFails = 0,
badgesStale = false,
healthStale = false;
const BEL = new Map();
/* Last painted appearance per badge element. Polling calls bupd() for every
registered badge each cycle, and folder badges once more per child update, so
without this the same values are rewritten thousands of times a session. */
const BSIG = new WeakMap();
function breg(id, el) {
if (!BEL.has(id)) BEL.set(id, new Set());
BEL.get(id).add(el);
Expand Down Expand Up @@ -104,7 +108,11 @@ function bupd(id) {
translate: t,
});

const sig = badgeSignature({ cls, txt, bg, aria, color, title });

els.forEach(el => {
if (BSIG.get(el) === sig) return;
BSIG.set(el, sig);
el.className = cls;
el.textContent = txt;
if (aria) {
Expand Down
24 changes: 23 additions & 1 deletion ui/test/badge-logic.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { computeBadgeVisual, needsDark, resolveColor, NAMED, healthReason } from '../js/badge-logic.js';
import { computeBadgeVisual, needsDark, resolveColor, NAMED, healthReason, badgeSignature } from '../js/badge-logic.js';

test('unhealthy takes priority over everything else', () => {
const v = computeBadgeVisual({
Expand Down Expand Up @@ -189,3 +189,25 @@ test('an unhealthy badge with no detail still works', () => {
assert.equal(v.title, '');
assert.equal(v.aria, 'Status: needs attention');
});

test('the same badge visual signs identically, so an unchanged badge is not repainted', () => {
const opts = { health: 0, activity: 4, hasHC: true, custom: { unit: 'GB' } };
assert.equal(badgeSignature(computeBadgeVisual(opts)), badgeSignature(computeBadgeVisual(opts)));
});

test('every field that reaches the element changes the signature', () => {
const base = { cls: 'badge on blue', txt: '4', bg: '#ffcc00', aria: '4 pending', color: '#1c1c1e', title: 'x' };
for (const key of Object.keys(base)) {
assert.notEqual(badgeSignature(base), badgeSignature({ ...base, [key]: 'changed' }), key);
}
});

test('signature fields cannot pack into each other', () => {
assert.notEqual(badgeSignature({ cls: 'badge on', txt: '' }), badgeSignature({ cls: 'badge', txt: 'on' }));
});

test('a stale badge signs differently from the same badge fresh', () => {
const fresh = computeBadgeVisual({ activity: 2, badgesStale: false });
const stale = computeBadgeVisual({ activity: 2, badgesStale: true });
assert.notEqual(badgeSignature(fresh), badgeSignature(stale));
});
Loading