From 559b6cd335b45fd5d548a3ce0ef9e3978fa41b10 Mon Sep 17 00:00:00 2001 From: Hans de Zwart Date: Tue, 11 Aug 2026 11:18:02 +0200 Subject: [PATCH] Fix icon toggles, add timing presets and a two-tap reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public_html/README.md | 17 +++++- public_html/index.html | 125 +++++++++++++++++++++++++++++++++++++---- public_html/sw.js | 2 +- 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/public_html/README.md b/public_html/README.md index 1c075e6..b3d0dae 100644 --- a/public_html/README.md +++ b/public_html/README.md @@ -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 diff --git a/public_html/index.html b/public_html/index.html index bd20375..374ad86 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -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 @@ + + +

Time

@@ -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 pauseNow(){ + if(st.phase !== RUN) return; + st.held = elapsed(); + st.phase = PAUSE; + paintPlay(); + } + function togglePause(){ if(st.phase === RUN){ - st.held = elapsed(); - st.phase = PAUSE; + 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 ============ */ diff --git a/public_html/sw.js b/public_html/sw.js index d427f59..07b0ebe 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -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",