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
+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 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 ============ */