4 Commits
Author SHA1 Message Date
hansdezwartandClaude Opus 5 e207478e79 Release 1.5
versionCode 6. public_html/ changed, so the service worker cache goes to
bgclock-v21.

Not confirmed on device: the force-quit-and-reopen check from RELEASING.md
step 4 has not been run for this build. Nothing in it touches storage, timing
or the state machine — the change is a label, an inherited colour and a lit
button, all written from render() — but the check is still owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:35 +02:00
hansdezwartandClaude Opus 5 3f6fe5abf1 Say Paused on both halves while the clock is stopped
A stopped clock looked exactly like a running one. The panel keeps its accent
while paused, and isLive() counts a paused side as live, so the delay bar just
freezes mid-drain — nothing about either half says the clocks aren't moving.
The only tell was the play glyph in the bar, which is small, grey, and upside
down for one of the two players. It matters most in the state the app is
designed to land in: a match restored from localStorage comes back paused on
purpose, and nothing said so.

So both halves say it in words, under the delay bar, sized to sit between the
bar and the clock: wider than the bar at every screen size, narrower than the
clock at m:ss. The band is tighter than it sounds, since "3:00" is only about
2.2x the bar, so part of the width comes from the tracking rather than the
glyph size. Measured from 320 to 1024px wide it lands at 1.3-1.4x the delay
row and 74-82% of the clock. It is wider than the clock in the last ten
seconds of a reserve, where the readout drops to three narrow characters —
unavoidable inside these bounds, and the two are never confusable at four
times the height.

The word hangs off the delay row rather than joining .stack, so the big clock
does not move by a pixel when the clock stops. It cannot live inside .delay
either: that is opacity:0 whenever no delay is running, and a child can never
be more opaque than its parent, which would lose the word on the waiting half,
on a spent delay, and at a delay of 0. It takes no colour of its own, so it
wears whatever ink the clock above it is wearing, in every theme.

The play button lights up while paused, the same --icon-hot a held Reset uses.
Both toggles live in render() rather than in paintPlay(), for the reason the
comment there already gives: state written from the render loop cannot go
stale.

Screenshot 2 is redone — the score sheet pauses, so the word shows behind the
scrim. The other four are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:25 +02:00
hansdezwartandClaude Opus 5 7f1b246679 Release 1.4
versionCode 5. public_html/ changed, so the service worker cache goes to
bgclock-v20.

Confirmed on device: a turn left running well past the delay, force-quit and
reopened, now comes back with the time it had actually spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:07:26 +02:00
hansdezwartandClaude Opus 5 3a7eb8c612 Save the match every second instead of waiting to be told
A force-quit deep into a turn came back at the clock's last-turn-switch value,
losing the whole interrupted turn. The tell was the delay counter returning at
a full 12: the newest saved state was the one tap() writes, and tap() sets
turnAt and calls saveGame() in the same breath, so it carries held ~ 0.
Restoring it reproduces the moment of the turn switch exactly.

The two triggers meant to save at quit time — visibilitychange and pagehide —
do not fire in Android's WebView. MainActivity's onPause() forwards to
web.onPause() under a comment claiming that is what makes visibilitychange
fire. It isn't: document visibility follows the WebView's view and window
visibility, not that call. The forwarding is still right for stopping timers
and audio, but the saved match should never have depended on it.

This is not new. It has almost certainly been broken since 1.0, and the release
check could not catch it, because for the first 12 seconds of a turn the delay
absorbs everything and render() never touches st.reserve. A clock that has lost
its place is indistinguishable from one that is correctly sitting still, so
every force-quit test that quit soon after a turn change passed. RELEASING.md
now says to let the turn run 40 seconds first, which is the only version of
that check worth running.

So the page stops waiting to be told. A timer writes the match once a second
while a clock is running — a timer rather than a hook in loop(), because
requestAnimationFrame stops when the page is hidden, which is exactly when this
matters. blur joins the listeners too: a different signal, and one that does
fire when the task switcher opens. At most a second can now be lost, which is
well inside the delay nobody is charged for anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:07:18 +02:00
9 changed files with 111 additions and 14 deletions
+4 -1
View File
@@ -70,7 +70,10 @@ everything works except installation and the service worker.
stray thumb can't wipe a live match. It's absent on a fresh match, where there stray thumb can't wipe a live match. It's absent on a fresh match, where there
is nothing yet to wipe. is nothing yet to wipe.
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay - **Pause** freezes mid-turn and resumes exactly where it stopped, delay
included. Opening either sheet pauses automatically. The settings sheet leaves included. Both halves read **Paused** underneath the delay bar while it is, in
the same ink as the clock above it, and the play button lights up — so a
stopped clock can't be mistaken for a running one from either side of the
board. Opening either sheet pauses automatically. The settings sheet leaves
it paused — everything in there resets the clocks anyway — while the score 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 sheet picks the turn back up when you close it, so glancing at the score
doesn't cost you a tap on play. It's absent whenever there's no clock running doesn't cost you a tap on play. It's absent whenever there's no clock running
+8
View File
@@ -61,6 +61,14 @@ make some moves, force-quit from the task switcher and reopen — everything
should come back, paused. That is the check that proves the app's storage is should come back, paused. That is the check that proves the app's storage is
working, and it fails silently rather than loudly. working, and it fails silently rather than loudly.
**Let the turn run at least 40 seconds before you quit.** This is the whole
check, and doing it any faster proves nothing: for the first 12 seconds of a
turn the delay absorbs everything and the reserve clock is *supposed* to sit
still, so a clock that has lost its place looks exactly like one that hasn't.
Quit well past the delay and the time must come back lower than it was at the
turn switch — and the delay counter must not be sitting at a full 12. That is
how a broken save hid from this check from 1.0 to 1.3.
## 5. Commit, tag, push ## 5. Commit, tag, push
```bash ```bash
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "nl.hansdezwart.bgclock" applicationId = "nl.hansdezwart.bgclock"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 4 versionCode = 6
versionName = "1.3" versionName = "1.5"
} }
compileOptions { compileOptions {
@@ -121,8 +121,15 @@ public class MainActivity extends Activity {
} }
} }
// forwarded so the page's visibilitychange fires — that is what calls // Forwarded so the WebView stops its timers, animations and audio while the
// saveGame() and nudges the audio context back after backgrounding // app is in the background, and picks them up again on return.
//
// This does NOT drive the page's visibilitychange — an earlier comment here
// claimed it did, and the saved match quietly depended on that being true.
// Document visibility follows the WebView's view and window visibility, not
// this call, so the page can be killed without ever hearing about it. The
// page no longer relies on being told: it writes the match to localStorage
// once a second while a clock is running.
@Override @Override
protected void onPause() { protected void onPause() {
super.onPause(); super.onPause();
@@ -0,0 +1,4 @@
Fixes a match not coming back correctly after the app is force-quit. The clock
returned to where it stood at the last turn change, losing the time spent on
the turn that was interrupted. The match is now written down once a second
while a clock is running, so at most a second can be lost.
@@ -0,0 +1 @@
Added a 'Paused' indicator when the clock is paused.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 123 KiB

+76 -2
View File
@@ -182,6 +182,49 @@
text-align:right; text-align:right;
} }
/* ---------- paused ----------
A stopped clock used to look exactly like a running one: the panel keeps its
accent, and the delay bar simply freezes mid-drain. The only tell was the
play glyph in the bar — small, grey, and upside down for one of the two
players. So both halves say it in words.
Hung off the delay row rather than added to .stack, so the big clock does
not move a pixel when the clock stops — the same promise .btn--vacant makes
in the bar, and the reason .delay fades rather than hides. It also cannot
live *inside* .delay: that is opacity:0 whenever no delay is running, and a
child can never be more opaque than its parent, which would lose the word on
the waiting half, on a spent delay, and at a delay of 0. */
.foot{position:relative}
.paused{
position:absolute;
top:100%;
left:50%;
/* the bar and its number sit dead centre of the panel (see .dnum), so
centring on that row centres on the panel, whatever the count reads */
transform:translateX(-50%);
/* a clear step below the bar rather than .stack's gap: near enough to belong
to the delay row, far enough not to crowd it */
margin-top:clamp(21px,5.8vw,41px);
white-space:nowrap;
line-height:1;
/* Wider than the delay bar, narrower than the clock — the band is tighter
than it sounds, since "3:00" is only about 2.2x the bar. The crossovers
are .track's own (20vw between 360 and 700px wide), so the word holds its
proportion to the bar at every size instead of drifting at the ends.
Part of the width comes from the tracking rather than the glyph size:
spaced letters carry "held" without shouting over the clock. */
font-size:clamp(34px,9.4vw,66px);
font-weight:700;
letter-spacing:.08em;
/* no colour of its own: it inherits the panel's, which is what the clock and
the delay count are already wearing. Each theme picks its own --accent-ink
(white on most of them), so the word follows the palette without a rule
per theme, and the paused half never has two different inks on it. */
opacity:0;
transition:opacity .16s ease;
}
.paused.on{opacity:1}
/* ---------- control bar ---------- */ /* ---------- control bar ---------- */
.bar{ .bar{
background:var(--bar); background:var(--bar);
@@ -199,6 +242,9 @@
cursor:pointer; cursor:pointer;
} }
.btn:active{color:var(--icon-hot)} .btn:active{color:var(--icon-hot)}
/* the way back from a paused clock, lit like a held Reset: the bar is the only
thing either player can act on while the clocks are stopped */
#playpause.lit{color:var(--icon-hot)}
.btn[aria-pressed="false"]{color:#5E5954} .btn[aria-pressed="false"]{color:#5E5954}
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block} .btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
.btn svg[hidden]{display:none} .btn svg[hidden]{display:none}
@@ -432,10 +478,13 @@
<div class="stack"> <div class="stack">
<div class="time" data-time>3:00</div> <div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div> <div class="out" data-out hidden>Out of<br>time</div>
<div class="foot">
<div class="delay" data-delay> <div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span> <span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span> <span class="dnum" data-dnum>15</span>
</div> </div>
<div class="paused" data-paused>Paused</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -498,10 +547,13 @@
<div class="stack"> <div class="stack">
<div class="time" data-time>3:00</div> <div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div> <div class="out" data-out hidden>Out of<br>time</div>
<div class="foot">
<div class="delay" data-delay> <div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span> <span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span> <span class="dnum" data-dnum>15</span>
</div> </div>
<div class="paused" data-paused>Paused</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -709,7 +761,8 @@
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")), scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
delay: p.querySelector("[data-delay]"), delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"), fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]") dnum: p.querySelector("[data-dnum]"),
paused: p.querySelector("[data-paused]")
}; };
}); });
var btnPlay = document.getElementById("playpause"); var btnPlay = document.getElementById("playpause");
@@ -886,6 +939,11 @@
setText(el[i].dnum, Math.ceil(dLeft / 1000)); setText(el[i].dnum, Math.ceil(dLeft / 1000));
} }
// both halves, not just the one whose turn it is: the waiting player's
// clock is stopped too, and they are the one who can't read the bar's
// play glyph the right way up
el[i].paused.classList.toggle("on", st.phase === PAUSE);
setAttr(el[i].root, "data-state", setAttr(el[i].root, "data-state",
(st.phase === FLAG && i === st.active) ? "flagged" (st.phase === FLAG && i === st.active) ? "flagged"
: (i === st.active && st.phase !== IDLE) ? "active" : "idle"); : (i === st.active && st.phase !== IDLE) ? "active" : "idle");
@@ -901,6 +959,7 @@
// nothing running to pause: before a game's first tap, and after a flag, // nothing running to pause: before a game's first tap, and after a flag,
// where togglePause is inert anyway — the game is over, Reset is the way out // where togglePause is inert anyway — the game is over, Reset is the way out
btnPlay.classList.toggle("btn--vacant", st.phase === IDLE || st.phase === FLAG); btnPlay.classList.toggle("btn--vacant", st.phase === IDLE || st.phase === FLAG);
btnPlay.classList.toggle("lit", st.phase === PAUSE);
} }
function loop(){ render(); requestAnimationFrame(loop); } function loop(){ render(); requestAnimationFrame(loop); }
@@ -1051,11 +1110,26 @@
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
} }
// the only hooks iOS reliably fires before killing an app // The hooks iOS fires before killing an app. Android's WebView is the problem
// child: neither of these is guaranteed there, whatever MainActivity's
// onPause() forwarding suggests, and when they don't fire the newest save is
// the one tap() wrote — which carries held ≈ 0, because tap() sets turnAt and
// saves in the same breath. Reopening then hands back the entire turn, and
// the bug hides in plain sight: quit inside the delay window and a clock that
// correctly hasn't moved looks exactly like a clock that lost its place.
document.addEventListener("visibilitychange", function(){ document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "hidden") saveGame(); if(document.visibilityState === "hidden") saveGame();
}); });
window.addEventListener("pagehide", saveGame); window.addEventListener("pagehide", saveGame);
// a different signal from visibilitychange, and it does fire when Android's
// task switcher opens. saveGame() is idempotent, so an extra call is free
window.addEventListener("blur", saveGame);
// and the one that doesn't depend on being told anything: a second is the
// most a force-quit can now cost, which is well inside the delay nobody is
// charged for. A timer rather than a hook in loop(), because rAF stops when
// the page is hidden — exactly when this matters most.
setInterval(function(){ if(st.phase === RUN) saveGame(); }, 1000);
/* Reset is a hold, not a tap. It used to be two taps inside 2s, and a test /* Reset is a hold, not a tap. It used to be two taps inside 2s, and a test
user found the button, tapped it once and gave up — which is exactly what user found the button, tapped it once and gave up — which is exactly what
+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-v19"; const CACHE = "bgclock-v21";
const FILES = [ const FILES = [
"./", "./",
"./index.html", "./index.html",