Two real bugs, both worst on iOS: The frame was taller than the screen in Safari. .app declared `height:100dvh;height:100vh`, so the fallback won and 100vh on iOS is the large viewport — the control bar and part of the bottom panel sat behind the toolbar. The declarations are now in the right order. The wake lock never came back. iOS drops it whenever the app backgrounds, but `lock` was never cleared, so the `!lock` guard blocked every later re-request and the screen started sleeping mid-game. It now listens for `release` and clears the handle; against a stubbed API the old code stays at one request after a release-and-return, the new code makes a second. Smaller iOS fixes: claim a playback audio session so the ringer switch doesn't silence the clock (this pauses other audio — the README says how to drop it), resume the audio context when returning from the background, suppress the long-press callout, keep sheet scrolling from rubber-banding the app, back 88dvh with 88vh, and name the home-screen icon. Untested on real hardware — there's no WebKit engine here, so this is a code audit plus a Chromium regression run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
824 lines
28 KiB
HTML
824 lines
28 KiB
HTML
<!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-title" content="BG Clock">
|
||
<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;
|
||
-webkit-touch-callout:none; /* no iOS long-press callout on a panel */
|
||
overscroll-behavior:none;
|
||
touch-action:manipulation;
|
||
}
|
||
|
||
/* ---------- frame ---------- */
|
||
.app{
|
||
height:100vh;height:100dvh; /* dvh must win — 100vh on iOS Safari is the
|
||
large viewport, so the bar and the bottom
|
||
panel end up under the toolbar */
|
||
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:88vh;max-height:88dvh;overflow-y:auto;
|
||
overscroll-behavior:contain; /* scrolling the sheet can't rubber-band the app */
|
||
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 6px;
|
||
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;white-space:nowrap;
|
||
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="12000" aria-pressed="false">3m / 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;
|
||
}
|
||
// iOS silences Web Audio through the ringer switch unless the page claims a
|
||
// playback session. Trade-off: it also interrupts whatever else is playing,
|
||
// so it's only claimed the first time the clock actually makes a sound.
|
||
var claimedSession = false;
|
||
function claimAudioSession(){
|
||
if(claimedSession) return;
|
||
claimedSession = true;
|
||
try{ if(navigator.audioSession) navigator.audioSession.type = "playback"; }catch(e){}
|
||
}
|
||
function tone(freq, at, dur, peak, type, partial){
|
||
var c = audio(); if(!c) return;
|
||
claimAudioSession();
|
||
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 || lock) return;
|
||
if(document.visibilityState !== "visible") return; // request() rejects when hidden
|
||
navigator.wakeLock.request("screen").then(function(l){
|
||
lock = l;
|
||
// the OS drops the lock whenever the app goes to the background; without
|
||
// clearing it here the stale handle blocks every later re-request
|
||
l.addEventListener("release", function(){ lock = null; });
|
||
}).catch(function(){});
|
||
}
|
||
document.addEventListener("visibilitychange", function(){
|
||
if(document.visibilityState !== "visible") return;
|
||
keepAwake();
|
||
// iOS interrupts the audio context on backgrounding; nudge it back so the
|
||
// flag still sounds even if nobody has tapped since returning
|
||
// (old webkitAudioContext.resume() returns undefined, so don't chain on it)
|
||
if(ac && ac.state === "suspended"){ try{ ac.resume(); }catch(e){} }
|
||
});
|
||
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>
|