Put the settings out of reach once a match is under way

Every control in the settings sheet calls resetClocks(), so opening it
mid-match could throw away two live clocks. The gear now only appears on an
untouched match: before the first tap, or after a two-tap reset.

"Untouched" is derived rather than tracked — phase, turn, moves, score and
both reserves matching matchTime() — so it cannot fall out of sync the way a
stored flag could. The phase alone would be wrong: a score change runs
newGame(), which returns to IDLE waiting for the next game's first tap while
the match is still very much on. The reserve check also catches a clock
corrected through the score sheet.

The button uses visibility rather than [hidden], so its slot stays put and
the rest of the bar doesn't jump the instant the clock starts; verified
pixel-for-pixel that reset, play/pause, score and sound do not move.
visibility:hidden also drops it from the tab order and the accessibility
tree. The rule is enforced in the click handler as well, because a
programmatic click still reaches a visibility:hidden button — CSS is the
affordance, the guard is the rule.

The trade, deliberately: a mistake in the time or delay now costs a reset and
a restarted match. Correcting a clock mid-match is the score sheet's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:57:37 +02:00
co-authored by Claude Opus 5
parent b438630796
commit 81826589d8
3 changed files with 33 additions and 2 deletions
+7
View File
@@ -60,6 +60,13 @@ except installation and the service worker.
it paused — everything in there resets the clocks anyway — while the score
sheet picks the turn back up when you close it, so glancing at the score
doesn't cost you a tap on play.
- **Settings are only reachable before a match starts.** Every control in that
sheet resets both clocks, so the gear disappears the moment the first tap
starts the clock and comes back after a reset. Its space in the bar is kept
empty, so nothing else shifts. That means a mistake in the time or delay can
only be fixed by resetting and starting the match again — deliberately, since
the alternative is wiping two live clocks by accident. Correcting a clock
mid-match is what the score sheet is for.
Backgammon clocks pause between games, and after a cocked die the delay is
normally restarted — use pause and reset for those.
+25 -1
View File
@@ -165,6 +165,10 @@
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
.btn svg[hidden]{display:none}
.btn[hidden]{display:none} /* .btn's own display would otherwise win */
/* not [hidden]: that would collapse the slot and shift the whole bar. This
keeps the space, and still drops the button out of the tab order, the
accessibility tree and the path of a stray tap. */
.btn--vacant{visibility:hidden}
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
@@ -591,6 +595,7 @@
var btnSound = document.getElementById("sound");
var btnReset = document.getElementById("reset");
var btnScore = document.getElementById("score");
var btnSettings = document.getElementById("settings");
var sheet = document.getElementById("sheet");
var scoreSheet = document.getElementById("scoresheet");
var scrim = document.getElementById("scrim");
@@ -681,6 +686,18 @@
return Math.max(0, st.reserve[i] - consumed(i));
}
/* Every timer control in the settings resets both clocks, so the sheet is only
safe to reach in exactly the state resetAll() leaves behind. Checking the
phase alone would not do: a score change runs newGame(), which returns to
IDLE waiting for the next game's first tap, with the match still very much
on. The score and reserve checks are what cover that. */
function matchPristine(){
return st.phase === IDLE && st.active === -1 &&
st.moves[0] === 0 && st.moves[1] === 0 &&
st.score[0] === 0 && st.score[1] === 0 &&
st.reserve[0] === matchTime() && st.reserve[1] === matchTime();
}
/* ---- 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
@@ -735,6 +752,10 @@
(st.phase === FLAG && i === st.active) ? "flagged"
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
}
// here rather than in the mutators, so it can never go stale; the
// two-argument toggle leaves the DOM alone when the state already matches
btnSettings.classList.toggle("btn--vacant", !matchPristine());
}
function loop(){ render(); requestAnimationFrame(loop); }
@@ -1026,7 +1047,10 @@
}
function closeSheet(){ openSheet(null); }
document.getElementById("settings").addEventListener("click", function(){
btnSettings.addEventListener("click", function(){
// the CSS only hides the button; the rule itself lives here, so no route in
// can reach the timer controls once the match is under way
if(!matchPristine()) return;
pauseNow(); // nobody's clock runs while a sheet is open
disarm();
paintSettings();
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v7";
const CACHE = "bgclock-v8";
const FILES = [
"./",
"./index.html",