diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e932b2e..2eba0110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index 5addc65b..b17e0df3 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -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) } diff --git a/ui/js/badge-logic.js b/ui/js/badge-logic.js index eb4bffa3..0793264b 100644 --- a/ui/js/badge-logic.js +++ b/ui/js/badge-logic.js @@ -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'); +} diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index dd214aec..76b7611c 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -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'; @@ -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); @@ -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) { diff --git a/ui/test/badge-logic.test.mjs b/ui/test/badge-logic.test.mjs index bdbfb987..72ef7e8a 100644 --- a/ui/test/badge-logic.test.mjs +++ b/ui/test/badge-logic.test.mjs @@ -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({ @@ -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)); +});