Fix icon toggles, add timing presets and a two-tap reset

The play/pause and sound icons never toggled: SVGElement doesn't inherit
from HTMLElement, so it has no .hidden property and `svg.hidden = true`
only set a JS expando. Both icons then rendered at once because
`.btn svg{display:block}` out-specifies the UA's `[hidden]{display:none}`.
Fixed on both sides — a showIcon() helper sets the attribute, and the CSS
now honours [hidden]. Starting the clock by tapping a panel didn't repaint
the button either, which the working toggle made visible.

Also:
- conventional gear for the settings icon, replacing the clock glyph
- opening settings pauses the clock, and leaves it paused on close
- reset needs two taps within 2s, with a ring that drains over the window
- 2m/12s and 3m/15s presets atop the settings sheet; the highlight is
  derived from cfg, so it clears and returns as the steppers move
- ask for persistent storage so settings aren't evicted, and fall back to
  the defaults rather than NaN if a stored value is ever corrupt

sw.js cache bumped to v2 so installed devices pick up the new index.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:18:02 +02:00
co-authored by Claude Opus 5
parent a1d46f5e32
commit 559b6cd335
3 changed files with 128 additions and 16 deletions
+14 -3
View File
@@ -34,14 +34,22 @@ except installation and the service worker.
- The waiting player's taps are ignored, so a stray hand won't stop the clock.
- 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.
- **Reset** returns to the starting position. **Pause** freezes mid-turn and
resumes exactly where it stopped, delay included.
- **Reset** returns to the starting position. It takes two taps: the first arms
the button — it turns your accent colour and a ring drains — and a second tap
within those two seconds does it. One stray thumb can't wipe a live game.
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay
included. Opening settings pauses automatically, and leaves it paused so the
clock only restarts when both players are ready.
Backgammon clocks pause between games, and after a cocked die the delay is
normally restarted — use pause and reset for those.
## Settings
At the top of the sheet are two presets — **2m / 12s** and **3m / 15s**. Tapping
one jumps both timers there and lights it up. Change the time or delay by hand
and the highlight goes out; land back on a preset's numbers and it comes back on.
| Setting | Range | Default |
| --- | --- | --- |
| Time | 0:15 99:00 | 3:00 |
@@ -50,7 +58,10 @@ normally restarted — use pause and reset for those.
| Sound | on / off | on |
Brass is the colour in the chess.com app, if you want the exact original look.
Changing time or delay resets the clock. Settings are remembered on the device.
Changing time or delay resets the clock. Settings are kept in `localStorage`, so
they survive closing the tab, force-quitting the installed app and rebooting the
phone — the app also asks for persistent storage so they aren't evicted when the
device runs low on space. Only the settings are stored; a game in progress isn't.
## Sounds
+113 -12
View File
@@ -146,8 +146,23 @@
.btn:active{color:var(--icon-hot)}
.btn[aria-pressed="false"]{color:#5E5954}
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
.btn svg[hidden]{display:none}
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
/* reset armed for a second tap — the ring drains over the same 2s as the timeout */
#reset{position:relative}
#reset.armed{color:var(--accent)}
#reset.armed::before{
content:"";position:absolute;
width:clamp(36px,9vw,48px);aspect-ratio:1;
border-radius:50%;border:2px solid var(--accent);
animation:drain 2s linear forwards;
}
@keyframes drain{
from{opacity:1;transform:scale(1)}
to{opacity:0;transform:scale(.72)}
}
/* ---------- settings sheet ---------- */
.scrim{
position:fixed;inset:0;
@@ -184,6 +199,23 @@
.row h2{font-size:15px;font-weight:600;letter-spacing:.01em}
.row p{font-size:12.5px;color:var(--sheet-dim);margin-top:3px;line-height:1.35}
.row--presets{gap:11px}
.preset{
flex:1;
padding:13px 10px;
border-radius:11px;border:1px solid var(--sheet-line);
background:#2C2A28;color:var(--sheet-ink);
font-family:var(--ui);font-size:15px;font-weight:700;
letter-spacing:.01em;
cursor:pointer;
transition:background-color .16s ease,color .16s ease;
}
.preset:active{background:#3A3735}
.preset[aria-pressed="true"]{
background:var(--accent);color:var(--accent-ink);
border-color:transparent;
}
.stepper{display:flex;align-items:center;gap:2px}
.step{
width:38px;height:38px;flex:none;
@@ -275,7 +307,8 @@
<span class="gap"></span>
<button class="btn" id="settings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="13.6" r="7.4"/><path d="M12 13.6V9.4"/><path d="M9.4 2.4h5.2"/><path d="M12 2.4v3.6"/><path d="m18.6 6.4 1.9-1.9"/>
<circle cx="12" cy="12" r="3.3"/>
<path d="M19.6 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 9.1 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.7 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.7 9.1a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.7h.08A1.7 1.7 0 0 0 10.1 3.14V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.3 9v.08a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.56 1.03z"/>
</svg>
</button>
<button class="btn" id="sound" aria-label="Sound" aria-pressed="true">
@@ -310,6 +343,11 @@
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Settings">
<div class="grab"></div>
<div class="row row--presets">
<button class="preset" data-base="120000" data-preset-delay="12000" aria-pressed="false">2m / 12s</button>
<button class="preset" data-base="180000" data-preset-delay="15000" aria-pressed="false">3m / 15s</button>
</div>
<div class="row">
<div>
<h2>Time</h2>
@@ -364,9 +402,13 @@
/* ============ persistence (degrades to memory if blocked) ============ */
var store = {
get: function(k, d){ try{ var v = localStorage.getItem(k); return v === null ? d : v; }catch(e){ return d; } },
set: function(k, v){ try{ localStorage.setItem(k, v); }catch(e){} }
set: function(k, v){ try{ localStorage.setItem(k, v); }catch(e){} },
num: function(k, d){ var n = parseInt(store.get(k, ""), 10); return isFinite(n) ? n : d; }
};
// ask the browser not to evict us when the device is low on space
try{ if(navigator.storage && navigator.storage.persist) navigator.storage.persist(); }catch(e){}
var THEMES = [
{ id:"sage", accent:"#7E9A79", ink:"#FFFFFF" },
{ id:"brass", accent:"#C3A153", ink:"#FFFFFF" },
@@ -376,8 +418,8 @@
];
var cfg = {
base: parseInt(store.get("bg.base", "180000"), 10),
delay: parseInt(store.get("bg.delay", "15000"), 10),
base: store.num("bg.base", 180000),
delay: store.num("bg.delay", 15000),
theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1"
};
@@ -408,6 +450,7 @@
});
var btnPlay = document.getElementById("playpause");
var btnSound = document.getElementById("sound");
var btnReset = document.getElementById("reset");
var sheet = document.getElementById("sheet");
var scrim = document.getElementById("scrim");
@@ -511,6 +554,7 @@
function flag(side){
st.reserve[side] = 0;
st.phase = FLAG;
paintPlay();
render();
sndFlag();
buzz([90, 70, 90]);
@@ -525,6 +569,7 @@
st.turnAt = performance.now();
st.ticked = false;
st.phase = RUN;
paintPlay(); // the bar has to show what the clock is doing
sndSwap(); buzz(14);
return;
}
@@ -539,10 +584,17 @@
sndSwap(); buzz(14);
}
function togglePause(){
if(st.phase === RUN){
function pauseNow(){
if(st.phase !== RUN) return;
st.held = elapsed();
st.phase = PAUSE;
paintPlay();
}
function togglePause(){
if(st.phase === RUN){
pauseNow();
return;
}else if(st.phase === PAUSE){
st.turnAt = performance.now() - st.held;
st.phase = RUN;
@@ -550,14 +602,22 @@
paintPlay();
}
/* SVG elements aren't HTMLElements, so they have no .hidden property —
setting it only makes a JS expando. The attribute has to be set by hand. */
function showIcon(btn, name, on){
var svg = btn.querySelector('[data-icon="' + name + '"]');
if(on) svg.removeAttribute("hidden"); else svg.setAttribute("hidden", "");
}
function paintPlay(){
var showPlay = st.phase !== RUN;
btnPlay.querySelector('[data-icon="pause"]').hidden = showPlay;
btnPlay.querySelector('[data-icon="play"]').hidden = !showPlay;
showIcon(btnPlay, "pause", !showPlay);
showIcon(btnPlay, "play", showPlay);
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
}
function reset(){
disarm();
st.phase = IDLE; st.active = -1;
st.reserve = [cfg.base, cfg.base];
st.moves = [0, 0];
@@ -566,6 +626,22 @@
render();
}
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
var armT = null;
function disarm(){
if(armT){ clearTimeout(armT); armT = null; }
btnReset.classList.remove("armed");
btnReset.setAttribute("aria-label", "Reset clock");
}
function armReset(){
if(armT){ disarm(); reset(); buzz(20); return; } // second tap inside the window
btnReset.classList.remove("armed");
void btnReset.offsetWidth; // restart the drain animation
btnReset.classList.add("armed");
btnReset.setAttribute("aria-label", "Tap again to reset");
armT = setTimeout(disarm, 2000);
}
/* ============ theme ============ */
function applyTheme(){
var t = THEMES.filter(function(x){ return x.id === cfg.theme; })[0] || THEMES[0];
@@ -599,10 +675,30 @@
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
btnSound.querySelector('[data-icon="on"]').hidden = !cfg.sound;
btnSound.querySelector('[data-icon="off"]').hidden = cfg.sound;
showIcon(btnSound, "on", cfg.sound);
showIcon(btnSound, "off", !cfg.sound);
paintPresets();
}
/* highlight is derived from cfg, so it clears when you step away from a
preset and comes back by itself when you step onto one */
function paintPresets(){
[].forEach.call(document.querySelectorAll(".preset"), function(b){
var on = (+b.dataset.base === cfg.base && +b.dataset.presetDelay === cfg.delay);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
[].forEach.call(document.querySelectorAll(".preset"), function(b){
b.addEventListener("click", function(){
cfg.base = parseInt(b.dataset.base, 10);
cfg.delay = parseInt(b.dataset.presetDelay, 10);
store.set("bg.base", String(cfg.base));
store.set("bg.delay", String(cfg.delay));
paintSettings(); reset();
});
});
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
b.addEventListener("click", function(){
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
@@ -633,11 +729,16 @@
sheet.classList.toggle("on", on);
scrim.classList.toggle("on", on);
}
document.getElementById("settings").addEventListener("click", function(){ paintSettings(); openSheet(true); });
document.getElementById("settings").addEventListener("click", function(){
pauseNow(); // nobody's clock runs while the sheet is open
disarm();
paintSettings();
openSheet(true);
});
document.getElementById("done").addEventListener("click", function(){ openSheet(false); });
scrim.addEventListener("click", function(){ openSheet(false); });
document.getElementById("reset").addEventListener("click", reset);
btnReset.addEventListener("click", armReset);
btnPlay.addEventListener("click", togglePause);
/* ============ panel taps ============ */
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v1";
const CACHE = "bgclock-v2";
const FILES = [
"./",
"./index.html",