From 3f14c08cfc0fb911c5bb5d373f3d59e64a3a79a9 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:13:26 -0400 Subject: [PATCH] Fix: keep badges from losing their rounded shape in Safari Badge updates rewrote className, textContent, background and colour on every poll cycle for every registered badge, and once more per child update for a folder badge, whether or not the values had changed. Over a long session that repaint churn left WebKit rasterising the badge's clipped corners torn. Skip the DOM writes when the computed appearance is identical to what the element already shows, and pin the badge to a stable compositing layer so WebKit stops promoting and demoting it as its classes and inline colours change. --- CHANGELOG.md | 2 ++ ui/css/dashboard.css | 5 +++++ ui/js/badge-logic.js | 9 +++++++++ ui/js/dashboard.js | 10 +++++++++- ui/test/badge-logic.test.mjs | 24 +++++++++++++++++++++++- 5 files changed, 48 insertions(+), 2 deletions(-) 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)); +});