Charge the hold, and drop the two buttons that have nothing to do

Three things, plus the README moves up to the repo root where a README
belongs — it was being served as part of the site.

The clock now runs until the finger lifts. The previous commit registered
a tap on the lift but backdated the turn to the press, which showed up as
the display counting down during a hold and then jumping back. Worse than
cosmetic: the hold was refunded, so a thumb resting on your own half
bought unlimited thinking time for nothing, and holding past zero
couldn't flag you either, because render()'s flag check watches the live
display while tap() charged from an earlier instant. One
performance.now() at the lift now serves both the outgoing charge and the
incoming turnAt, so nothing falls between them and goes to neither
player. With the delay at 0 and a turn 1000ms old, an instant lift costs
1001ms and a 600ms hold costs 1602ms — the inverse of what the last
commit was verified against. Swipe rejection is untouched; it lives in
the pointer handler, not here.

Reset and pause step out when they have nothing to do: pause before each
game's first tap, reset while the match is still pristine. matchPristine()
already meant exactly that, so both toggles sit beside the settings one in
render(), where they can't go stale. Reset and settings turn out to be
exact opposites. btn--vacant like the gear, so the space is kept — a
fresh-match bar differs from the old build only inside those two slots,
every other pixel in the frame identical, gear and speaker unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:47:50 +02:00
co-authored by Claude Opus 5
parent 632f1ef47d
commit 261cebd10b
3 changed files with 32 additions and 19 deletions
+16 -8
View File
@@ -12,8 +12,9 @@ Chrome will only offer a real install over HTTPS, so the file needs a host.
GitHub Pages is free and takes about two minutes: GitHub Pages is free and takes about two minutes:
1. Create a new public repository, e.g. `bgclock`. 1. Create a new public repository, e.g. `bgclock`.
2. Upload all six files to the root: `index.html`, `manifest.webmanifest`, 2. Upload all six files from `public_html/` to the root: `index.html`,
`sw.js`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`. `manifest.webmanifest`, `sw.js`, `icon-192.png`, `icon-512.png`,
`icon-maskable-512.png`.
3. **Settings → Pages → Source: Deploy from a branch → `main` / `(root)` → Save.** 3. **Settings → Pages → Source: Deploy from a branch → `main` / `(root)` → Save.**
4. Wait a minute, then open `https://<you>.github.io/bgclock/` on your phone. 4. Wait a minute, then open `https://<you>.github.io/bgclock/` on your phone.
5. **Android, Chrome:** menu (⋮) → **Add to Home screen****Install**. 5. **Android, Chrome:** menu (⋮) → **Add to Home screen****Install**.
@@ -41,8 +42,8 @@ On iOS a few things behave differently, none of them fatal:
If you're using a different path than `/bgclock/`, edit the `"id"` field in If you're using a different path than `/bgclock/`, edit the `"id"` field in
`manifest.webmanifest` to match, or just delete that line. `manifest.webmanifest` to match, or just delete that line.
To try it before hosting, open `index.html` in any browser — everything works To try it before hosting, open `public_html/index.html` in any browser —
except installation and the service worker. everything works except installation and the service worker.
## Using it ## Using it
@@ -52,19 +53,26 @@ except installation and the service worker.
- A tap counts when you **lift** your finger, and only if it didn't move. A swipe - A tap counts when you **lift** your finger, and only if it didn't move. A swipe
does nothing, so Android's swipe-up-from-the-bottom and the status-bar pull-down does nothing, so Android's swipe-up-from-the-bottom and the status-bar pull-down
work over either half without costing anyone their turn — and a hand dragged work over either half without costing anyone their turn — and a hand dragged
across the panel won't change it either. The clock still switches at the moment across the panel won't change it either.
you pressed, not when you let go. - Your clock runs until you lift. Holding a finger on the panel costs you the
time you can see counting off, and holding it past zero flags you, exactly as
if you hadn't touched it at all.
- The bar under the big time is the delay draining. When it empties you get a - The bar under the big time is the delay draining. When it empties you get a
soft tick and your reserve starts moving. That's the moment worth hearing. soft tick and your reserve starts moving. That's the moment worth hearing.
- **Reset** starts a whole new match: clocks back to full *and* the score back to - **Reset** starts a whole new match: clocks back to full *and* the score back to
00. It takes two taps: the first arms the button — it lights up and a ring 00. It takes two taps: the first arms the button — it lights up and a ring
unwinds around it — and a second tap within those two seconds does it. One unwinds around it — and a second tap within those two seconds does it. One
stray thumb can't wipe a live match. stray thumb can't wipe a live match. It's absent on a fresh match, where there
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. 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. doesn't cost you a tap on play. It's absent before each game's first tap, with
no clock running to freeze.
Both buttons keep their space in the bar while they're away, so nothing shifts
under your thumb — the same as the gear.
- **Settings are only reachable before a match starts.** Every control in that - **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 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 starts the clock and comes back after a reset. Its space in the bar is kept
+15 -10
View File
@@ -761,8 +761,13 @@
} }
// here rather than in the mutators, so it can never go stale; the // 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 // two-argument toggle leaves the DOM alone when the state already matches.
btnSettings.classList.toggle("btn--vacant", !matchPristine()); // Settings and Reset are exact opposites: before the match you can change
// the setup and have nothing to reset, and once it's running, the reverse.
var pristine = matchPristine();
btnSettings.classList.toggle("btn--vacant", !pristine);
btnReset.classList.toggle("btn--vacant", pristine); // nothing played to reset
btnPlay.classList.toggle("btn--vacant", st.phase === IDLE); // nothing running to pause
} }
function loop(){ render(); requestAnimationFrame(loop); } function loop(){ render(); requestAnimationFrame(loop); }
@@ -778,13 +783,12 @@
saveGame(); saveGame();
} }
// `at` is when the finger landed. A tap is only confirmed once it lifts, but function tap(side){
// the turn has to change at the press or every switch drifts by however long
// the finger rested on the panel.
function tap(side, at){
if(st.phase === FLAG) return; if(st.phase === FLAG) return;
if(st.phase === PAUSE) return; if(st.phase === PAUSE) return;
var now = at || performance.now(); // the lift, not the press: your clock runs for as long as your finger is
// down, so a held thumb costs you exactly the time it was seen counting off
var now = performance.now();
if(st.phase === IDLE){ if(st.phase === IDLE){
st.active = 1 - side; // you tap your own side to start your opponent st.active = 1 - side; // you tap your own side to start your opponent
@@ -798,7 +802,8 @@
} }
if(side !== st.active) return; // the waiting player can't stop the clock if(side !== st.active) return; // the waiting player can't stop the clock
// not elapsed(): that measures to *now*, and this has to measure to the press // the same instant charges the outgoing turn and starts the incoming one,
// so nothing falls between the two and goes to neither player
var e = now - st.turnAt; var e = now - st.turnAt;
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay)); st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
st.moves[side]++; st.moves[side]++;
@@ -1177,7 +1182,7 @@
p.addEventListener("pointerdown", function(ev){ p.addEventListener("pointerdown", function(ev){
ev.preventDefault(); ev.preventDefault();
audio(); // unlocking on the press is a valid gesture and earlier is better audio(); // unlocking on the press is a valid gesture and earlier is better
down = { id: ev.pointerId, x: ev.clientX, y: ev.clientY, t: ev.timeStamp }; down = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
// so the lift still lands here if the finger drifts onto the other panel // so the lift still lands here if the finger drifts onto the other panel
try{ p.setPointerCapture(ev.pointerId); }catch(e){} try{ p.setPointerCapture(ev.pointerId); }catch(e){}
}); });
@@ -1197,7 +1202,7 @@
document.documentElement.requestFullscreen().catch(function(){}); document.documentElement.requestFullscreen().catch(function(){});
} }
} }
tap(side, d.t); tap(side);
}); });
// the branch Android's home gesture takes // the branch Android's home gesture takes
+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-v11"; const CACHE = "bgclock-v12";
const FILES = [ const FILES = [
"./", "./",
"./index.html", "./index.html",