Files
bgclock/public_html/index.html
T
hansdezwartandClaude Opus 5 f68a88f3e8 Centre the reset glyph in its viewBox
The armed ring looked a couple of pixels low. The ring was already exactly
centred on the button — measured 0.00px off — but the glyph wasn't: its arc
is a circle centred at (12,11) in a 24-unit viewBox, so the ring and the
glyph's circle were drawn as two almost-concentric circles about 1.8px
apart, which reads as misalignment much louder than a single shape would.

Shifting the glyph down one unit puts its circle at (12,12), concentric with
the ring to 0.00px. It also fixes the icon sitting high next to its
neighbours — reset was the only one in the bar whose ink was off centre
(dy=-1.32 against 0.00 for the gear and speaker).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:46:45 +02:00

797 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>Backgammon Clock</title>
<meta name="theme-color" content="#332e2b">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon-192.png">
<link rel="apple-touch-icon" href="icon-192.png">
<style>
:root{
--accent:#7E9A79;
--accent-ink:#FFFFFF;
--accent-mute:rgba(0,0,0,.34);
--idle:#9A9A9A;
--idle-ink:#2E2E2E;
--idle-mute:rgba(0,0,0,.36);
--bar:#332E2B;
--icon:#8E8880;
--icon-hot:#DFD9D1;
--flag:#A6564E;
--sheet:#232120;
--sheet-line:rgba(255,255,255,.09);
--sheet-ink:#EDE9E4;
--sheet-dim:#A29A92;
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:var(--bar)}
body{
font-family:var(--ui);
font-variant-numeric:tabular-nums;
font-feature-settings:"tnum" 1;
-webkit-tap-highlight-color:transparent;
-webkit-user-select:none;user-select:none;
overscroll-behavior:none;
touch-action:manipulation;
}
/* ---------- frame ---------- */
.app{
height:100dvh;height:100vh;
display:grid;
grid-template-rows:1fr auto 1fr;
}
/* ---------- player panels ---------- */
.panel{
position:relative;
background:var(--idle);
color:var(--idle-ink);
overflow:hidden;
transition:background-color .18s ease;
}
.panel[data-state="active"]{background:var(--accent);color:var(--accent-ink)}
.panel[data-state="flagged"]{background:var(--flag);color:#fff}
.inner{
position:absolute;inset:0;
display:flex;flex-direction:column;
padding:max(14px,env(safe-area-inset-top)) 20px 14px;
}
.panel--top .inner{transform:rotate(180deg)}
.panel--top .inner{padding:14px 20px max(14px,env(safe-area-inset-top))}
.panel--bottom .inner{padding:14px 20px max(14px,env(safe-area-inset-bottom))}
.moves{
align-self:flex-end;
font-size:clamp(13px,3.7vw,20px);
font-weight:700;
letter-spacing:.01em;
color:var(--idle-mute);
order:-1;
}
.panel[data-state="active"] .moves,
.panel[data-state="flagged"] .moves{color:var(--accent-mute)}
.panel[data-state="flagged"] .moves{color:rgba(255,255,255,.55)}
.stack{
flex:1;
display:flex;flex-direction:column;
align-items:center;justify-content:center;
padding-bottom:16%;
gap:clamp(10px,2.4vw,18px);
}
.time{
font-size:clamp(58px,24vw,190px);
line-height:.9;
font-weight:700;
letter-spacing:-.02em;
}
/* ---------- the delay readout (the bit chess.com doesn't have) ---------- */
.delay{
display:flex;align-items:center;
gap:clamp(8px,2.2vw,14px);
opacity:0;
transition:opacity .16s ease;
}
.delay.on{opacity:1}
.track{
width:clamp(72px,20vw,140px);
height:clamp(4px,1.1vw,7px);
border-radius:99px;
background:var(--accent-mute);
opacity:.45;
overflow:hidden;
}
.fill{
display:block;height:100%;width:100%;
border-radius:99px;
background:var(--accent-ink);
transform-origin:left center;
}
.dnum{
font-size:clamp(15px,4.4vw,26px);
font-weight:700;
min-width:1.6em;
}
/* ---------- control bar ---------- */
.bar{
background:var(--bar);
height:clamp(58px,8.2vh,86px);
display:flex;align-items:center;
padding:0 clamp(12px,4vw,28px);
}
.bar .gap{flex:1.5}
.btn{
flex:1;
height:100%;
display:grid;place-items:center;
background:none;border:0;
color:var(--icon);
cursor:pointer;
}
.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 unwinds over the same 2s as the timeout */
#reset{position:relative}
#reset.armed{color:var(--icon-hot)}
#reset .ring{
position:absolute;
top:50%;left:50%;
width:clamp(36px,9.6vw,48px);height:clamp(36px,9.6vw,48px);
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
display:none;
pointer-events:none;
}
#reset.armed .ring{display:block}
#reset .ring circle{
fill:none;
stroke:currentColor; /* the same flash the buttons use */
stroke-width:2.6;
stroke-linecap:round;
stroke-dasharray:113.1; /* 2πr, r=18 */
animation:unwind 2s linear forwards;
}
@keyframes unwind{
from{stroke-dashoffset:0}
to{stroke-dashoffset:113.1}
}
/* ---------- settings sheet ---------- */
.scrim{
position:fixed;inset:0;
background:rgba(0,0,0,.55);
opacity:0;pointer-events:none;
transition:opacity .2s ease;
z-index:10;
}
.scrim.on{opacity:1;pointer-events:auto}
.sheet{
position:fixed;left:0;right:0;bottom:0;
z-index:11;
background:var(--sheet);
color:var(--sheet-ink);
border-radius:20px 20px 0 0;
padding:8px 22px max(22px,env(safe-area-inset-bottom));
transform:translateY(101%);
transition:transform .26s cubic-bezier(.32,.72,0,1);
max-height:88dvh;overflow-y:auto;
box-shadow:0 -18px 50px rgba(0,0,0,.45);
}
.sheet.on{transform:translateY(0)}
.grab{width:38px;height:4px;border-radius:99px;background:#4A4644;margin:6px auto 18px}
.row{
display:flex;align-items:center;justify-content:space-between;
gap:16px;
padding:16px 0;
border-top:1px solid var(--sheet-line);
}
.row:first-of-type{border-top:0}
.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;
border-radius:11px;border:1px solid var(--sheet-line);
background:#2C2A28;color:var(--sheet-ink);
font-size:19px;font-weight:600;line-height:1;
cursor:pointer;
}
.step:active{background:#3A3735}
.val{
min-width:66px;text-align:center;
font-size:19px;font-weight:700;
}
.unit{font-size:11px;color:var(--sheet-dim);display:block;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-top:2px}
.swatches{display:flex;gap:11px}
.sw{
width:36px;height:36px;border-radius:50%;
border:2px solid transparent;
box-shadow:inset 0 0 0 2px var(--sheet);
cursor:pointer;padding:0;
}
.sw[aria-checked="true"]{border-color:var(--sheet-ink)}
.toggle{
width:52px;height:31px;flex:none;
border-radius:99px;border:0;
background:#4A4644;
position:relative;cursor:pointer;
transition:background-color .18s ease;
}
.toggle[aria-checked="true"]{background:var(--accent)}
.toggle::after{
content:"";position:absolute;top:3px;left:3px;
width:25px;height:25px;border-radius:50%;
background:#fff;
transition:transform .18s cubic-bezier(.32,.72,0,1);
}
.toggle[aria-checked="true"]::after{transform:translateX(21px)}
.done{
width:100%;margin-top:20px;
padding:15px;border:0;border-radius:13px;
background:var(--accent);color:var(--accent-ink);
font-family:var(--ui);font-size:15.5px;font-weight:700;
cursor:pointer;
}
.hint{
font-size:12px;color:var(--sheet-dim);
text-align:center;margin-top:13px;line-height:1.45;
}
@media (prefers-reduced-motion:reduce){
*{transition-duration:.01ms!important}
}
</style>
</head>
<body>
<div class="app">
<section class="panel panel--top" data-side="0" data-state="idle">
<div class="inner">
<div class="moves">Moves: <span data-moves>0</span></div>
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
</div>
</div>
</section>
<div class="bar">
<button class="btn" id="reset" aria-label="Reset clock">
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
</svg>
</button>
<button class="btn" id="playpause" aria-label="Pause clock">
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="pause">
<rect x="6" y="4.5" width="4.4" height="15" rx="1.4"/><rect x="13.6" y="4.5" width="4.4" height="15" rx="1.4"/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="play" hidden>
<path d="M7.5 4.7a1 1 0 0 1 1.53-.85l9 6.3a1 1 0 0 1 0 1.7l-9 6.3A1 1 0 0 1 7.5 17.3z"/>
</svg>
</button>
<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="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">
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="on">
<path d="M4 9.2h3.3L12 5.1a.9.9 0 0 1 1.5.7v12.4a.9.9 0 0 1-1.5.7L7.3 14.8H4a.9.9 0 0 1-.9-.9v-3.8a.9.9 0 0 1 .9-.9z"/>
<path d="M16.4 8.6a.95.95 0 0 1 1.34.06 5.1 5.1 0 0 1 0 6.68.95.95 0 1 1-1.4-1.28 3.2 3.2 0 0 0 0-4.12.95.95 0 0 1 .06-1.34z"/>
<path d="M19.1 5.6a.95.95 0 0 1 1.34.05 9.2 9.2 0 0 1 0 12.7.95.95 0 1 1-1.39-1.3 7.3 7.3 0 0 0 0-10.1.95.95 0 0 1 .05-1.35z"/>
</svg>
<svg viewBox="0 0 24 24" fill="currentColor" data-icon="off" hidden>
<path d="M4 9.2h3.3L12 5.1a.9.9 0 0 1 1.5.7v12.4a.9.9 0 0 1-1.5.7L7.3 14.8H4a.9.9 0 0 1-.9-.9v-3.8a.9.9 0 0 1 .9-.9z"/>
<path d="M16.5 9.3a1 1 0 0 1 1.42 0L19.6 11l1.68-1.7a1 1 0 1 1 1.42 1.42L21 12.4l1.7 1.7a1 1 0 0 1-1.42 1.42L19.6 13.8l-1.68 1.7a1 1 0 0 1-1.42-1.42l1.7-1.68-1.7-1.68a1 1 0 0 1 0-1.42z"/>
</svg>
</button>
</div>
<section class="panel panel--bottom" data-side="1" data-state="idle">
<div class="inner">
<div class="moves">Moves: <span data-moves>0</span></div>
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
</div>
</div>
</section>
</div>
<div class="scrim" id="scrim"></div>
<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>
<p>Reserve time per player.</p>
</div>
<div class="stepper">
<button class="step" data-time-adj="-60"></button>
<span class="val"><span id="v-min">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60">+</button>
<button class="step" data-time-adj="-15" style="margin-left:8px"></button>
<span class="val"><span id="v-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15">+</button>
</div>
</div>
<div class="row">
<div>
<h2>Delay</h2>
<p>Free seconds each turn. Never carries over.</p>
</div>
<div class="stepper">
<button class="step" data-delay-adj="-1"></button>
<span class="val"><span id="v-delay">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1">+</button>
</div>
</div>
<div class="row">
<div>
<h2>Colour</h2>
<p>Marks the player on turn.</p>
</div>
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
</div>
<div class="row">
<div>
<h2>Sound</h2>
<p>Turn change, delay expiry, time out.</p>
</div>
<button class="toggle" id="t-sound" role="switch" aria-checked="true" aria-label="Sound"></button>
</div>
<button class="done" id="done">Done</button>
<p class="hint">Changing time or delay resets the clock.</p>
</div>
<script>
(function(){
"use strict";
/* ============ 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){} },
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" },
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF" },
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF" },
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF" }
];
var cfg = {
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"
};
/* ============ state ============ */
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
var st = {
phase: IDLE,
active: -1,
reserve: [cfg.base, cfg.base], // reserve banked at the start of the current turn
moves: [0, 0],
turnAt: 0, // performance.now() when the turn began
held: 0, // frozen elapsed while paused
ticked: false // subtle sound already played this turn
};
/* ============ dom ============ */
var panels = [].slice.call(document.querySelectorAll(".panel"));
var el = panels.map(function(p){
return {
root: p,
time: p.querySelector("[data-time]"),
moves: p.querySelector("[data-moves]"),
delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]")
};
});
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");
/* ============ audio (synthesised — no files, works offline) ============ */
var ac = null;
function audio(){
if(!ac){
var C = window.AudioContext || window.webkitAudioContext;
if(!C) return null;
ac = new C();
}
if(ac.state === "suspended") ac.resume();
return ac;
}
function tone(freq, at, dur, peak, type, partial){
var c = audio(); if(!c) return;
var t0 = c.currentTime + at;
var o = c.createOscillator(), g = c.createGain();
o.type = type || "sine";
o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(c.destination);
o.start(t0); o.stop(t0 + dur + 0.05);
if(partial){
var o2 = c.createOscillator(), g2 = c.createGain();
o2.type = "sine";
o2.frequency.setValueAtTime(freq * 2, t0);
g2.gain.setValueAtTime(0.0001, t0);
g2.gain.exponentialRampToValueAtTime(peak * 0.22, t0 + 0.01);
g2.gain.exponentialRampToValueAtTime(0.0001, t0 + dur * 0.5);
o2.connect(g2); g2.connect(c.destination);
o2.start(t0); o2.stop(t0 + dur);
}
}
// warm rising fifth, marimba-ish — the handover
function sndSwap(){ if(!cfg.sound) return; tone(440, 0, 0.34, 0.20, "sine", true); tone(659.25, 0.075, 0.42, 0.17, "sine", true); }
// barely-there wooden tick — delay just ran out, reserve is now moving
function sndTick(){ if(!cfg.sound) return; tone(300, 0, 0.075, 0.035, "sine", false); }
// unmistakable falling triad — flag
function sndFlag(){
if(!cfg.sound) return;
tone(880, 0.00, 0.20, 0.24, "triangle", false);
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
tone(440, 0.32, 0.55, 0.26, "triangle", true);
}
function buzz(ms){ try{ if(navigator.vibrate) navigator.vibrate(ms); }catch(e){} }
/* ============ helpers ============ */
function elapsed(){
if(st.phase === PAUSE) return st.held;
return performance.now() - st.turnAt;
}
function fmt(ms){
if(ms < 0) ms = 0;
var t = Math.ceil(ms / 100) / 10;
if(ms < 10000) return t.toFixed(1);
var s = Math.ceil(ms / 1000);
var m = Math.floor(s / 60);
s = s % 60;
return m + ":" + (s < 10 ? "0" : "") + s;
}
/* ============ render ============ */
function render(){
var e = elapsed();
for(var i = 0; i < 2; i++){
var live = (i === st.active && (st.phase === RUN || st.phase === PAUSE));
var main = st.reserve[i], dLeft = 0;
if(live){
if(e < cfg.delay){
dLeft = cfg.delay - e;
}else{
main = st.reserve[i] - (e - cfg.delay);
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
}
}
if(live && main <= 0 && st.phase === RUN){ flag(i); return; }
el[i].time.textContent = fmt(main);
el[i].moves.textContent = st.moves[i];
var showDelay = live && cfg.delay > 0 && dLeft > 0;
el[i].delay.classList.toggle("on", showDelay);
if(showDelay){
el[i].fill.style.transform = "scaleX(" + (dLeft / cfg.delay) + ")";
el[i].dnum.textContent = Math.ceil(dLeft / 1000);
}
if(st.phase === FLAG && i === st.active) el[i].root.dataset.state = "flagged";
else el[i].root.dataset.state = (i === st.active && st.phase !== IDLE) ? "active" : "idle";
}
}
function loop(){ render(); requestAnimationFrame(loop); }
/* ============ actions ============ */
function flag(side){
st.reserve[side] = 0;
st.phase = FLAG;
paintPlay();
render();
sndFlag();
buzz([90, 70, 90]);
}
function tap(side){
if(st.phase === FLAG) return;
if(st.phase === PAUSE) return;
if(st.phase === IDLE){
st.active = 1 - side; // you tap your own side to start your opponent
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;
}
if(side !== st.active) return; // the waiting player can't stop the clock
var e = elapsed();
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
st.moves[side]++;
st.active = 1 - side;
st.turnAt = performance.now();
st.ticked = false;
sndSwap(); buzz(14);
}
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;
}else return;
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;
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];
st.held = 0; st.ticked = false;
paintPlay();
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];
document.documentElement.style.setProperty("--accent", t.accent);
document.documentElement.style.setProperty("--accent-ink", t.ink);
[].forEach.call(document.querySelectorAll(".sw"), function(s){
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
});
}
/* ============ settings ui ============ */
var swWrap = document.getElementById("swatches");
THEMES.forEach(function(t){
var b = document.createElement("button");
b.className = "sw";
b.dataset.id = t.id;
b.style.background = t.accent;
b.setAttribute("role", "radio");
b.setAttribute("aria-label", t.id);
b.setAttribute("aria-checked", "false");
b.addEventListener("click", function(){
cfg.theme = t.id; store.set("bg.theme", t.id); applyTheme();
});
swWrap.appendChild(b);
});
function paintSettings(){
var s = Math.round(cfg.base / 1000);
document.getElementById("v-min").textContent = Math.floor(s / 60);
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
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");
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;
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
store.set("bg.base", String(cfg.base));
paintSettings(); reset();
});
});
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
b.addEventListener("click", function(){
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
cfg.delay = Math.min(60000, Math.max(0, next));
store.set("bg.delay", String(cfg.delay));
paintSettings(); reset();
});
});
function setSound(on){
cfg.sound = on;
store.set("bg.sound", on ? "1" : "0");
paintSettings();
if(on){ audio(); sndTick(); }
}
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
function openSheet(on){
sheet.classList.toggle("on", on);
scrim.classList.toggle("on", on);
}
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); });
btnReset.addEventListener("click", armReset);
btnPlay.addEventListener("click", togglePause);
/* ============ panel taps ============ */
var wantFullscreen = true;
panels.forEach(function(p){
p.addEventListener("pointerdown", function(ev){
ev.preventDefault();
audio();
if(wantFullscreen){
wantFullscreen = false;
if(!window.matchMedia("(display-mode: fullscreen)").matches && document.documentElement.requestFullscreen){
document.documentElement.requestFullscreen().catch(function(){});
}
}
tap(parseInt(p.dataset.side, 10));
});
});
/* ============ keep the screen awake ============ */
var lock = null;
function keepAwake(){
if(!navigator.wakeLock) return;
navigator.wakeLock.request("screen").then(function(l){ lock = l; }).catch(function(){});
}
document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "visible" && !lock) keepAwake();
});
keepAwake();
/* ============ go ============ */
applyTheme();
paintSettings();
paintPlay();
reset();
loop();
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
navigator.serviceWorker.register("sw.js").catch(function(){});
}
})();
</script>
</body>
</html>