Only touch the DOM when a displayed value changes

The readout hung and then skipped numbers in a focused desktop window.
render() runs every animation frame and was writing unconditionally —
measured at 11 DOM mutations per frame — whether or not anything had
changed. Two of those are the .time text node, a glyph run up to 190px, so
every frame forced a style recalc and a full relayout to redraw digits that
turn over once a second. Routing the per-frame writes through change guards
takes a steady frame from 11 writes to 0.

The timing itself was never at fault and is untouched here. elapsed() is one
subtraction from a single monotonic performance.now() stamp and the reserve
is debited once, in tap(), so nothing accumulates per frame and a dropped
frame cannot cost or gift anyone a millisecond. Measured against wall clock
over a running turn: 0.0000ms drift across 5s, 12s and 17s segments.

Also fixes a real skip in fmt(). It switched from m:ss to tenths at
ms < 10000 with both branches rounding up, so the coarse branch read a
second high: counting down gave 0:11 for a full second, 0:10 for a single
millisecond, then 10.0. Handing 10000ms to the tenths branch gives a
monotone 0:11 -> 10.0 -> 9.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:40:11 +02:00
co-authored by Claude Opus 5
parent 0f69c78b74
commit b438630796
2 changed files with 34 additions and 12 deletions
+33 -11
View File
@@ -659,7 +659,9 @@
function fmt(ms){ function fmt(ms){
if(ms < 0) ms = 0; if(ms < 0) ms = 0;
var t = Math.ceil(ms / 100) / 10; var t = Math.ceil(ms / 100) / 10;
if(ms < 10000) return t.toFixed(1); // <= so 10000ms renders as "10.0" rather than "0:10": both branches round up,
// so the coarse one reads a second high and 0:10 would flash for 1ms
if(ms <= 10000) return t.toFixed(1);
var s = Math.ceil(ms / 1000); var s = Math.ceil(ms / 1000);
var m = Math.floor(s / 60); var m = Math.floor(s / 60);
s = s % 60; s = s % 60;
@@ -679,6 +681,24 @@
return Math.max(0, st.reserve[i] - consumed(i)); return Math.max(0, st.reserve[i] - consumed(i));
} }
/* ---- write only on change ----
render() runs 60 times a second but the digits turn over once a second, and
.time is a ~190px glyph run: rewriting its text node every frame forces a
style recalc and relayout for nothing, which is what makes the readout
stutter. The cached marker avoids reading the DOM back, which would flush
style by itself. */
function setText(node, value){
var s = String(value);
if(node.__v !== s){ node.__v = s; node.textContent = s; }
}
function setAttr(node, name, value){
if(node.getAttribute(name) !== value) node.setAttribute(name, value);
}
function setStyle(node, prop, value){
var k = "__s_" + prop; // per property, so a second one can't alias
if(node[k] !== value){ node[k] = value; node.style[prop] = value; }
}
/* ============ render ============ */ /* ============ render ============ */
function render(){ function render(){
var e = elapsed(); var e = elapsed();
@@ -697,21 +717,23 @@
if(live && main <= 0 && st.phase === RUN){ flag(i); return; } if(live && main <= 0 && st.phase === RUN){ flag(i); return; }
el[i].time.textContent = fmt(main); setText(el[i].time, fmt(main));
el[i].moves.textContent = st.moves[i]; setText(el[i].moves, st.moves[i]);
el[i].you.textContent = st.score[i]; // each panel is "You" to its own player setText(el[i].you, st.score[i]); // each panel is "You" to its own player
el[i].them.textContent = st.score[1 - i]; setText(el[i].them, st.score[1 - i]);
el[i].target.textContent = cfg.points; setText(el[i].target, cfg.points);
var showDelay = live && cfg.delay > 0 && dLeft > 0; var showDelay = live && cfg.delay > 0 && dLeft > 0;
el[i].delay.classList.toggle("on", showDelay); el[i].delay.classList.toggle("on", showDelay); // no-op when already right
if(showDelay){ if(showDelay){
el[i].fill.style.transform = "scaleX(" + (dLeft / cfg.delay) + ")"; // the bar really does move every frame, but transform is compositor-only
el[i].dnum.textContent = Math.ceil(dLeft / 1000); setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay).toFixed(4) + ")");
setText(el[i].dnum, Math.ceil(dLeft / 1000));
} }
if(st.phase === FLAG && i === st.active) el[i].root.dataset.state = "flagged"; setAttr(el[i].root, "data-state",
else el[i].root.dataset.state = (i === st.active && st.phase !== IDLE) ? "active" : "idle"; (st.phase === FLAG && i === st.active) ? "flagged"
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */ /* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v6"; const CACHE = "bgclock-v7";
const FILES = [ const FILES = [
"./", "./",
"./index.html", "./index.html",