2026-08-11 10:52:35 +02:00
|
|
|
|
<!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">
|
2026-08-11 11:53:56 +02:00
|
|
|
|
<meta name="apple-mobile-web-app-title" content="BG Clock">
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<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;
|
2026-08-11 11:53:56 +02:00
|
|
|
|
-webkit-touch-callout:none; /* no iOS long-press callout on a panel */
|
2026-08-11 10:52:35 +02:00
|
|
|
|
overscroll-behavior:none;
|
|
|
|
|
|
touch-action:manipulation;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- frame ---------- */
|
|
|
|
|
|
.app{
|
2026-08-11 11:53:56 +02:00
|
|
|
|
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 */
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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;
|
2026-08-11 17:26:56 +02:00
|
|
|
|
/* there's nothing to pan in a 100dvh frame, and this keeps a slow drag from
|
|
|
|
|
|
being cancelled by the browser — it ends in a lift we can measure instead */
|
|
|
|
|
|
touch-action:none;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
.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))}
|
|
|
|
|
|
|
2026-08-11 15:11:31 +02:00
|
|
|
|
/* the label column sizes itself to the widest label, so the three values
|
|
|
|
|
|
line up whatever the system font turns out to be */
|
2026-08-11 14:39:32 +02:00
|
|
|
|
.head{
|
2026-08-11 15:11:31 +02:00
|
|
|
|
display:grid;
|
|
|
|
|
|
grid-template-columns:max-content max-content;
|
|
|
|
|
|
justify-content:start;
|
2026-08-11 16:17:05 +02:00
|
|
|
|
align-items:baseline; /* a label and its number share a baseline whatever
|
|
|
|
|
|
the system font does to the two cells' metrics —
|
|
|
|
|
|
the default stretches them instead, which only
|
|
|
|
|
|
lines up while both measure exactly the same */
|
2026-08-11 15:11:31 +02:00
|
|
|
|
gap:3px 8px;
|
2026-08-11 14:39:32 +02:00
|
|
|
|
order:-1;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
font-size:clamp(13px,3.7vw,20px);
|
|
|
|
|
|
font-weight:700;
|
|
|
|
|
|
letter-spacing:.01em;
|
|
|
|
|
|
color:var(--idle-mute);
|
2026-08-11 14:39:32 +02:00
|
|
|
|
white-space:nowrap;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
2026-08-11 15:11:31 +02:00
|
|
|
|
.line{display:contents}
|
|
|
|
|
|
.line[hidden]{display:none}
|
|
|
|
|
|
/* set the moves line apart from the score — a display:contents row can't take
|
|
|
|
|
|
a margin itself, so both its cells carry it and the row shifts down evenly.
|
|
|
|
|
|
The guard drops the space when there's no score line above to separate from. */
|
|
|
|
|
|
.line:not([hidden]) + .line--moves > *{margin-top:7px}
|
|
|
|
|
|
|
|
|
|
|
|
.panel[data-state="active"] .head,
|
|
|
|
|
|
.panel[data-state="flagged"] .head{color:var(--accent-mute)}
|
|
|
|
|
|
.panel[data-state="flagged"] .head{color:rgba(255,255,255,.55)}
|
2026-08-11 10:52:35 +02:00
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 20:05:24 +02:00
|
|
|
|
/* the flagged player's clock is replaced by the words, on two lines.
|
|
|
|
|
|
Sized off the space that's actually left rather than the viewport, because
|
|
|
|
|
|
the score lines above eat into it: two lines at line-height 1.04 need 2.08x
|
|
|
|
|
|
the font size, so 42cqh of the stack's content box leaves a tenth of it as
|
|
|
|
|
|
breathing room. On two lines the width binds first — "Out of" is the long
|
|
|
|
|
|
line — which is what the vw term is holding. */
|
|
|
|
|
|
.stack{container-type:size}
|
|
|
|
|
|
.out{
|
|
|
|
|
|
font-size:clamp(24px,min(23vw,42cqh),130px);
|
|
|
|
|
|
line-height:1.04;
|
|
|
|
|
|
font-weight:700;
|
|
|
|
|
|
letter-spacing:-.01em;
|
|
|
|
|
|
text-align:center;
|
|
|
|
|
|
color:#fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
/* ---------- 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);
|
|
|
|
|
|
}
|
2026-08-11 14:39:32 +02:00
|
|
|
|
.bar .gap{flex:.75}
|
2026-08-11 10:52:35 +02:00
|
|
|
|
.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}
|
2026-08-11 11:18:02 +02:00
|
|
|
|
.btn svg[hidden]{display:none}
|
2026-08-11 14:48:44 +02:00
|
|
|
|
.btn[hidden]{display:none} /* .btn's own display would otherwise win */
|
2026-08-11 15:57:37 +02:00
|
|
|
|
/* not [hidden]: that would collapse the slot and shift the whole bar. This
|
|
|
|
|
|
keeps the space, and still drops the button out of the tab order, the
|
|
|
|
|
|
accessibility tree and the path of a stray tap. */
|
|
|
|
|
|
.btn--vacant{visibility:hidden}
|
2026-08-11 10:52:35 +02:00
|
|
|
|
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
|
|
|
|
|
|
2026-08-11 11:31:06 +02:00
|
|
|
|
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
|
2026-08-11 11:18:02 +02:00
|
|
|
|
#reset{position:relative}
|
2026-08-11 11:31:06 +02:00
|
|
|
|
#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;
|
2026-08-11 11:18:02 +02:00
|
|
|
|
}
|
2026-08-11 11:31:06 +02:00
|
|
|
|
#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}
|
2026-08-11 11:18:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
/* ---------- 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);
|
2026-08-11 18:25:27 +02:00
|
|
|
|
max-height:92vh;max-height:92dvh;overflow-y:auto; /* the scroll is a safety net for the smallest screens, not the normal case */
|
2026-08-11 11:53:56 +02:00
|
|
|
|
overscroll-behavior:contain; /* scrolling the sheet can't rubber-band the app */
|
2026-08-11 20:05:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
/* only while it's up: the shadow is thrown 18px upward with a 50px blur, and a
|
|
|
|
|
|
closed sheet sits just 1% below the viewport, so on .sheet it bled back onto
|
|
|
|
|
|
the bottom of the clock and sat there — twice over, once per sheet */
|
|
|
|
|
|
.sheet.on{
|
|
|
|
|
|
transform:translateY(0);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
box-shadow:0 -18px 50px rgba(0,0,0,.45);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 18:25:27 +02:00
|
|
|
|
.grab{width:38px;height:4px;border-radius:99px;background:#4A4644;margin:5px auto 11px}
|
2026-08-11 10:52:35 +02:00
|
|
|
|
|
2026-08-11 18:25:27 +02:00
|
|
|
|
/* the sheet is meant to be read without scrolling, and it grew a row; the
|
|
|
|
|
|
spacing here, on .grab, .done and .hint is set so the whole thing lands
|
|
|
|
|
|
inside a phone screen rather than running off the bottom */
|
2026-08-11 10:52:35 +02:00
|
|
|
|
.row{
|
|
|
|
|
|
display:flex;align-items:center;justify-content:space-between;
|
|
|
|
|
|
gap:16px;
|
2026-08-11 18:25:27 +02:00
|
|
|
|
padding:14px 0;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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}
|
|
|
|
|
|
|
2026-08-11 14:39:14 +02:00
|
|
|
|
/* a minutes+seconds stepper is 302px wide, so it can't share a line with a
|
|
|
|
|
|
label on any phone — stack it under one instead of overflowing the sheet */
|
|
|
|
|
|
@media (max-width:420px){
|
|
|
|
|
|
.row--wide{flex-direction:column;align-items:stretch;gap:10px}
|
|
|
|
|
|
.row--wide .stepper{align-self:flex-end}
|
|
|
|
|
|
.row--wide .val{min-width:46px}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 18:11:04 +02:00
|
|
|
|
/* three buttons wide enough to read can't share a line with a label, so the
|
|
|
|
|
|
row stacks at every width rather than only under the .row--wide breakpoint */
|
2026-08-11 18:25:27 +02:00
|
|
|
|
.row--presets{flex-direction:column;align-items:stretch;gap:10px}
|
2026-08-11 18:11:04 +02:00
|
|
|
|
.presets{display:flex;gap:11px}
|
2026-08-11 11:18:02 +02:00
|
|
|
|
.preset{
|
|
|
|
|
|
flex:1;
|
2026-08-11 11:46:53 +02:00
|
|
|
|
padding:13px 6px;
|
2026-08-11 11:18:02 +02:00
|
|
|
|
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;
|
2026-08-11 11:46:53 +02:00
|
|
|
|
letter-spacing:.01em;white-space:nowrap;
|
2026-08-11 11:18:02 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
.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{
|
2026-08-11 18:25:27 +02:00
|
|
|
|
width:100%;margin-top:14px;
|
|
|
|
|
|
padding:13px;border:0;border-radius:13px;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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);
|
2026-08-11 18:25:27 +02:00
|
|
|
|
text-align:center;margin-top:9px;line-height:1.45;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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">
|
2026-08-11 14:39:32 +02:00
|
|
|
|
<div class="head">
|
2026-08-11 15:11:31 +02:00
|
|
|
|
<span class="line" hidden><span>You:</span><span><span data-you>0</span> out of <span data-target>1</span></span></span>
|
|
|
|
|
|
<span class="line" hidden><span>Them:</span><span data-them>0</span></span>
|
|
|
|
|
|
<span class="line line--moves"><span>Moves:</span><span data-moves>0</span></span>
|
2026-08-11 14:39:32 +02:00
|
|
|
|
</div>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<div class="stack">
|
|
|
|
|
|
<div class="time" data-time>3:00</div>
|
2026-08-11 20:05:24 +02:00
|
|
|
|
<div class="out" data-out hidden>Out of<br>time</div>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<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">
|
2026-08-11 11:31:06 +02:00
|
|
|
|
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
2026-08-11 11:46:45 +02:00
|
|
|
|
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
</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>
|
2026-08-11 14:48:44 +02:00
|
|
|
|
<button class="btn" id="score" aria-label="Score" hidden>
|
2026-08-11 14:39:32 +02:00
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
|
<rect x="4" y="3" width="16" height="18" rx="2.6"/>
|
|
|
|
|
|
<path d="M12 7.5v10"/>
|
|
|
|
|
|
<path d="M7.6 10.6h2.6"/><path d="M13.8 10.6h2.6"/>
|
|
|
|
|
|
<path d="M7.6 14.6h2.6"/><path d="M13.8 14.6h2.6"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span class="gap"></span>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<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">
|
2026-08-11 11:18:02 +02:00
|
|
|
|
<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"/>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
</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">
|
2026-08-11 14:39:32 +02:00
|
|
|
|
<div class="head">
|
2026-08-11 15:11:31 +02:00
|
|
|
|
<span class="line" hidden><span>You:</span><span><span data-you>0</span> out of <span data-target>1</span></span></span>
|
|
|
|
|
|
<span class="line" hidden><span>Them:</span><span data-them>0</span></span>
|
|
|
|
|
|
<span class="line line--moves"><span>Moves:</span><span data-moves>0</span></span>
|
2026-08-11 14:39:32 +02:00
|
|
|
|
</div>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<div class="stack">
|
|
|
|
|
|
<div class="time" data-time>3:00</div>
|
2026-08-11 20:05:24 +02:00
|
|
|
|
<div class="out" data-out hidden>Out of<br>time</div>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-11 14:39:32 +02:00
|
|
|
|
<div class="row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Match</h2>
|
|
|
|
|
|
<p>Points to play to.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stepper">
|
|
|
|
|
|
<button class="step" data-points-adj="-2">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-points">1</span><span class="unit">pts</span></span>
|
|
|
|
|
|
<button class="step" data-points-adj="2">+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-11 14:39:14 +02:00
|
|
|
|
<div class="row row--wide">
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Time</h2>
|
2026-08-11 14:39:32 +02:00
|
|
|
|
<p id="p-time">Reserve time per point.</p>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-08-11 18:11:04 +02:00
|
|
|
|
<div class="row row--presets">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Preset time limits</h2>
|
|
|
|
|
|
<p>Quickly pick a setting.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="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>
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
<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>
|
2026-08-11 14:39:32 +02:00
|
|
|
|
<p class="hint">Changing match, time or delay resets the clocks. The score is left alone.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
|
|
|
|
|
|
<div class="grab"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>You</h2>
|
|
|
|
|
|
<p>Points won, this end of the phone.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stepper">
|
|
|
|
|
|
<button class="step" data-score-adj="-1" data-who="1">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-you">0</span><span class="unit">pts</span></span>
|
|
|
|
|
|
<button class="step" data-score-adj="1" data-who="1">+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Them</h2>
|
|
|
|
|
|
<p>Points won, far end.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stepper">
|
|
|
|
|
|
<button class="step" data-score-adj="-1" data-who="0">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-them">0</span><span class="unit">pts</span></span>
|
|
|
|
|
|
<button class="step" data-score-adj="1" data-who="0">+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="row row--wide">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Your clock</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stepper">
|
|
|
|
|
|
<button class="step" data-clock-adj="-60" data-who="1">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-you-min">3</span><span class="unit">min</span></span>
|
|
|
|
|
|
<button class="step" data-clock-adj="60" data-who="1">+</button>
|
|
|
|
|
|
<button class="step" data-clock-adj="-1" data-who="1" style="margin-left:8px">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-you-sec">00</span><span class="unit">sec</span></span>
|
|
|
|
|
|
<button class="step" data-clock-adj="1" data-who="1">+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="row row--wide">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Their clock</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stepper">
|
|
|
|
|
|
<button class="step" data-clock-adj="-60" data-who="0">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-them-min">3</span><span class="unit">min</span></span>
|
|
|
|
|
|
<button class="step" data-clock-adj="60" data-who="0">+</button>
|
|
|
|
|
|
<button class="step" data-clock-adj="-1" data-who="0" style="margin-left:8px">–</button>
|
|
|
|
|
|
<span class="val"><span id="v-them-sec">00</span><span class="unit">sec</span></span>
|
|
|
|
|
|
<button class="step" data-clock-adj="1" data-who="0">+</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button class="done" id="score-done">Done</button>
|
|
|
|
|
|
<p class="hint">Changing the score starts a new game: moves back to zero, turn back to
|
|
|
|
|
|
the first tap. The clocks keep their times.</p>
|
2026-08-11 10:52:35 +02:00
|
|
|
|
</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; } },
|
2026-08-11 11:18:02 +02:00
|
|
|
|
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; }
|
2026-08-11 10:52:35 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 11:18:02 +02:00
|
|
|
|
// 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){}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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 = {
|
2026-08-11 14:39:32 +02:00
|
|
|
|
base: store.num("bg.base", 180000), // reserve per point
|
2026-08-11 17:00:50 +02:00
|
|
|
|
delay: store.num("bg.delay", 12000),
|
2026-08-11 14:39:32 +02:00
|
|
|
|
points: store.num("bg.points", 1), // match length, odd, 1..29
|
|
|
|
|
|
theme: store.get("bg.theme", "sage"),
|
|
|
|
|
|
sound: store.get("bg.sound", "1") === "1"
|
2026-08-11 10:52:35 +02:00
|
|
|
|
};
|
2026-08-11 14:39:32 +02:00
|
|
|
|
if(!(cfg.points >= 1 && cfg.points <= 29 && cfg.points % 2 === 1)) cfg.points = 1;
|
|
|
|
|
|
|
|
|
|
|
|
// a match to N points is worth N times the per-point reserve
|
|
|
|
|
|
function matchTime(){ return cfg.base * cfg.points; }
|
2026-08-11 10:52:35 +02:00
|
|
|
|
|
|
|
|
|
|
/* ============ state ============ */
|
|
|
|
|
|
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
|
|
|
|
|
|
var st = {
|
|
|
|
|
|
phase: IDLE,
|
|
|
|
|
|
active: -1,
|
2026-08-11 14:39:32 +02:00
|
|
|
|
reserve: [matchTime(), matchTime()], // reserve banked at the start of the current turn
|
|
|
|
|
|
score: [0, 0], // match points, by side
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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]"),
|
2026-08-11 20:05:24 +02:00
|
|
|
|
out: p.querySelector("[data-out]"),
|
2026-08-11 10:52:35 +02:00
|
|
|
|
moves: p.querySelector("[data-moves]"),
|
2026-08-11 15:11:31 +02:00
|
|
|
|
you: p.querySelector("[data-you]"),
|
|
|
|
|
|
them: p.querySelector("[data-them]"),
|
2026-08-11 14:39:32 +02:00
|
|
|
|
target: p.querySelector("[data-target]"),
|
2026-08-11 15:11:31 +02:00
|
|
|
|
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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");
|
2026-08-11 11:18:02 +02:00
|
|
|
|
var btnReset = document.getElementById("reset");
|
2026-08-11 14:48:44 +02:00
|
|
|
|
var btnScore = document.getElementById("score");
|
2026-08-11 15:57:37 +02:00
|
|
|
|
var btnSettings = document.getElementById("settings");
|
2026-08-11 10:52:35 +02:00
|
|
|
|
var sheet = document.getElementById("sheet");
|
2026-08-11 14:39:32 +02:00
|
|
|
|
var scoreSheet = document.getElementById("scoresheet");
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-11 11:53:56 +02:00
|
|
|
|
// 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){}
|
|
|
|
|
|
}
|
2026-08-11 10:52:35 +02:00
|
|
|
|
function tone(freq, at, dur, peak, type, partial){
|
|
|
|
|
|
var c = audio(); if(!c) return;
|
2026-08-11 11:53:56 +02:00
|
|
|
|
claimAudioSession();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ============ 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;
|
2026-08-11 15:40:11 +02:00
|
|
|
|
// <= so 10000ms renders as "10.0" rather than "0:10": both branches round up,
|
|
|
|
|
|
// so the coarse one reads a second high and 0:10 would flash for 1ms
|
|
|
|
|
|
if(ms <= 10000) return t.toFixed(1);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
var s = Math.ceil(ms / 1000);
|
|
|
|
|
|
var m = Math.floor(s / 60);
|
|
|
|
|
|
s = s % 60;
|
|
|
|
|
|
return m + ":" + (s < 10 ? "0" : "") + s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 14:39:32 +02:00
|
|
|
|
// is this side's clock the one the turn is on?
|
|
|
|
|
|
function isLive(i){
|
|
|
|
|
|
return i === st.active && (st.phase === RUN || st.phase === PAUSE);
|
|
|
|
|
|
}
|
|
|
|
|
|
// how much of this turn has already come off the reserve (0 while the delay runs)
|
|
|
|
|
|
function consumed(i){
|
|
|
|
|
|
return isLive(i) ? Math.max(0, elapsed() - cfg.delay) : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
// the number the panel is showing, which is what the score sheet edits
|
|
|
|
|
|
function shownTime(i){
|
|
|
|
|
|
return Math.max(0, st.reserve[i] - consumed(i));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 15:57:37 +02:00
|
|
|
|
/* Every timer control in the settings resets both clocks, so the sheet is only
|
|
|
|
|
|
safe to reach in exactly the state resetAll() leaves behind. Checking the
|
|
|
|
|
|
phase alone would not do: a score change runs newGame(), which returns to
|
|
|
|
|
|
IDLE waiting for the next game's first tap, with the match still very much
|
|
|
|
|
|
on. The score and reserve checks are what cover that. */
|
|
|
|
|
|
function matchPristine(){
|
|
|
|
|
|
return st.phase === IDLE && st.active === -1 &&
|
|
|
|
|
|
st.moves[0] === 0 && st.moves[1] === 0 &&
|
|
|
|
|
|
st.score[0] === 0 && st.score[1] === 0 &&
|
|
|
|
|
|
st.reserve[0] === matchTime() && st.reserve[1] === matchTime();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 15:40:11 +02:00
|
|
|
|
/* ---- write only on change ----
|
|
|
|
|
|
render() runs 60 times a second but the digits turn over once a second, and
|
|
|
|
|
|
.time is a ~190px glyph run: rewriting its text node every frame forces a
|
|
|
|
|
|
style recalc and relayout for nothing, which is what makes the readout
|
|
|
|
|
|
stutter. The cached marker avoids reading the DOM back, which would flush
|
|
|
|
|
|
style by itself. */
|
|
|
|
|
|
function setText(node, value){
|
|
|
|
|
|
var s = String(value);
|
|
|
|
|
|
if(node.__v !== s){ node.__v = s; node.textContent = s; }
|
|
|
|
|
|
}
|
|
|
|
|
|
function setAttr(node, name, value){
|
|
|
|
|
|
if(node.getAttribute(name) !== value) node.setAttribute(name, value);
|
|
|
|
|
|
}
|
2026-08-11 20:05:24 +02:00
|
|
|
|
function setHidden(node, on){
|
|
|
|
|
|
if(node.__h !== on){ node.__h = on; node.hidden = on; }
|
|
|
|
|
|
}
|
2026-08-11 15:40:11 +02:00
|
|
|
|
function setStyle(node, prop, value){
|
|
|
|
|
|
var k = "__s_" + prop; // per property, so a second one can't alias
|
|
|
|
|
|
if(node[k] !== value){ node[k] = value; node.style[prop] = value; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
/* ============ render ============ */
|
|
|
|
|
|
function render(){
|
|
|
|
|
|
var e = elapsed();
|
|
|
|
|
|
for(var i = 0; i < 2; i++){
|
2026-08-11 14:39:32 +02:00
|
|
|
|
var live = isLive(i);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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; }
|
|
|
|
|
|
|
2026-08-11 20:05:24 +02:00
|
|
|
|
// the flagged player's clock is replaced by the words; the other panel
|
|
|
|
|
|
// keeps showing its own remaining time
|
|
|
|
|
|
var out = (st.phase === FLAG && i === st.active);
|
|
|
|
|
|
setHidden(el[i].time, out);
|
|
|
|
|
|
setHidden(el[i].out, !out);
|
|
|
|
|
|
|
2026-08-11 15:40:11 +02:00
|
|
|
|
setText(el[i].time, fmt(main));
|
|
|
|
|
|
setText(el[i].moves, st.moves[i]);
|
|
|
|
|
|
setText(el[i].you, st.score[i]); // each panel is "You" to its own player
|
|
|
|
|
|
setText(el[i].them, st.score[1 - i]);
|
|
|
|
|
|
setText(el[i].target, cfg.points);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
|
|
|
|
|
|
var showDelay = live && cfg.delay > 0 && dLeft > 0;
|
2026-08-11 15:40:11 +02:00
|
|
|
|
el[i].delay.classList.toggle("on", showDelay); // no-op when already right
|
2026-08-11 10:52:35 +02:00
|
|
|
|
if(showDelay){
|
2026-08-11 15:40:11 +02:00
|
|
|
|
// the bar really does move every frame, but transform is compositor-only
|
|
|
|
|
|
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay).toFixed(4) + ")");
|
|
|
|
|
|
setText(el[i].dnum, Math.ceil(dLeft / 1000));
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 15:40:11 +02:00
|
|
|
|
setAttr(el[i].root, "data-state",
|
|
|
|
|
|
(st.phase === FLAG && i === st.active) ? "flagged"
|
|
|
|
|
|
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
2026-08-11 15:57:37 +02:00
|
|
|
|
|
|
|
|
|
|
// here rather than in the mutators, so it can never go stale; the
|
2026-08-11 17:47:50 +02:00
|
|
|
|
// two-argument toggle leaves the DOM alone when the state already matches.
|
|
|
|
|
|
// Settings and Reset are exact opposites: before the match you can change
|
|
|
|
|
|
// the setup and have nothing to reset, and once it's running, the reverse.
|
|
|
|
|
|
var pristine = matchPristine();
|
|
|
|
|
|
btnSettings.classList.toggle("btn--vacant", !pristine);
|
|
|
|
|
|
btnReset.classList.toggle("btn--vacant", pristine); // nothing played to reset
|
2026-08-11 20:20:30 +02:00
|
|
|
|
// nothing running to pause: before a game's first tap, and after a flag,
|
|
|
|
|
|
// where togglePause is inert anyway — the game is over, Reset is the way out
|
|
|
|
|
|
btnPlay.classList.toggle("btn--vacant", st.phase === IDLE || st.phase === FLAG);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loop(){ render(); requestAnimationFrame(loop); }
|
|
|
|
|
|
|
|
|
|
|
|
/* ============ actions ============ */
|
|
|
|
|
|
function flag(side){
|
|
|
|
|
|
st.reserve[side] = 0;
|
|
|
|
|
|
st.phase = FLAG;
|
2026-08-11 11:18:02 +02:00
|
|
|
|
paintPlay();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
render();
|
|
|
|
|
|
sndFlag();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
saveGame();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 17:47:50 +02:00
|
|
|
|
function tap(side){
|
2026-08-11 10:52:35 +02:00
|
|
|
|
if(st.phase === FLAG) return;
|
|
|
|
|
|
if(st.phase === PAUSE) return;
|
2026-08-11 17:47:50 +02:00
|
|
|
|
// the lift, not the press: your clock runs for as long as your finger is
|
|
|
|
|
|
// down, so a held thumb costs you exactly the time it was seen counting off
|
|
|
|
|
|
var now = performance.now();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
|
|
|
|
|
|
if(st.phase === IDLE){
|
|
|
|
|
|
st.active = 1 - side; // you tap your own side to start your opponent
|
2026-08-11 17:26:56 +02:00
|
|
|
|
st.turnAt = now;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
st.ticked = false;
|
|
|
|
|
|
st.phase = RUN;
|
2026-08-11 11:18:02 +02:00
|
|
|
|
paintPlay(); // the bar has to show what the clock is doing
|
2026-08-11 19:10:23 +02:00
|
|
|
|
sndSwap();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
saveGame();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if(side !== st.active) return; // the waiting player can't stop the clock
|
|
|
|
|
|
|
2026-08-11 17:47:50 +02:00
|
|
|
|
// the same instant charges the outgoing turn and starts the incoming one,
|
|
|
|
|
|
// so nothing falls between the two and goes to neither player
|
2026-08-11 17:26:56 +02:00
|
|
|
|
var e = now - st.turnAt;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
|
|
|
|
|
|
st.moves[side]++;
|
|
|
|
|
|
st.active = 1 - side;
|
2026-08-11 17:26:56 +02:00
|
|
|
|
st.turnAt = now;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
st.ticked = false;
|
2026-08-11 19:10:23 +02:00
|
|
|
|
sndSwap();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
saveGame();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 11:18:02 +02:00
|
|
|
|
function pauseNow(){
|
|
|
|
|
|
if(st.phase !== RUN) return;
|
|
|
|
|
|
st.held = elapsed();
|
|
|
|
|
|
st.phase = PAUSE;
|
|
|
|
|
|
paintPlay();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
saveGame();
|
2026-08-11 11:18:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 15:11:31 +02:00
|
|
|
|
function resumeNow(){
|
|
|
|
|
|
if(st.phase !== PAUSE) return;
|
|
|
|
|
|
st.turnAt = performance.now() - st.held; // pick the turn up where it stopped
|
|
|
|
|
|
st.phase = RUN;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
paintPlay();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
saveGame();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 15:11:31 +02:00
|
|
|
|
function togglePause(){
|
|
|
|
|
|
if(st.phase === RUN) pauseNow();
|
|
|
|
|
|
else resumeNow();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 11:18:02 +02:00
|
|
|
|
/* 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", "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
function paintPlay(){
|
|
|
|
|
|
var showPlay = st.phase !== RUN;
|
2026-08-11 11:18:02 +02:00
|
|
|
|
showIcon(btnPlay, "pause", !showPlay);
|
|
|
|
|
|
showIcon(btnPlay, "play", showPlay);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 14:39:32 +02:00
|
|
|
|
// back to the first tap of a game, clocks untouched
|
|
|
|
|
|
function newGame(){
|
2026-08-11 10:52:35 +02:00
|
|
|
|
st.phase = IDLE; st.active = -1;
|
|
|
|
|
|
st.moves = [0, 0];
|
|
|
|
|
|
st.held = 0; st.ticked = false;
|
|
|
|
|
|
paintPlay();
|
|
|
|
|
|
render();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 14:39:32 +02:00
|
|
|
|
// clocks back to full for the match length; the score is not this function's business
|
|
|
|
|
|
function resetClocks(){
|
|
|
|
|
|
disarm();
|
|
|
|
|
|
st.reserve = [matchTime(), matchTime()];
|
|
|
|
|
|
newGame();
|
|
|
|
|
|
saveGame();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// the Reset button — a whole new match
|
|
|
|
|
|
function resetAll(){
|
|
|
|
|
|
st.score = [0, 0];
|
|
|
|
|
|
resetClocks();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clampScore(){
|
|
|
|
|
|
st.score = [Math.min(cfg.points, st.score[0]), Math.min(cfg.points, st.score[1])];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ============ the game survives a force-quit ============ */
|
|
|
|
|
|
// Time that passes while the app is dead can't be charged to anyone, so a
|
|
|
|
|
|
// running clock is stored as paused and comes back on the play button.
|
|
|
|
|
|
function saveGame(){
|
|
|
|
|
|
var mid = (st.phase === RUN || st.phase === PAUSE);
|
|
|
|
|
|
try{
|
|
|
|
|
|
store.set("bg.game", JSON.stringify({
|
|
|
|
|
|
v: 1,
|
|
|
|
|
|
score: st.score, reserve: st.reserve, moves: st.moves,
|
|
|
|
|
|
active: st.active,
|
|
|
|
|
|
held: mid ? elapsed() : 0,
|
|
|
|
|
|
phase: st.phase === RUN ? PAUSE : st.phase
|
|
|
|
|
|
}));
|
|
|
|
|
|
}catch(e){}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadGame(){
|
|
|
|
|
|
var raw = store.get("bg.game", null), g;
|
|
|
|
|
|
if(!raw) return;
|
|
|
|
|
|
try{ g = JSON.parse(raw); }catch(e){ return; }
|
|
|
|
|
|
// anything malformed is ignored rather than half-applied
|
|
|
|
|
|
function pair(a){
|
|
|
|
|
|
return a && a.length === 2 && isFinite(a[0]) && isFinite(a[1]) && a[0] >= 0 && a[1] >= 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
if(!g || g.v !== 1 || !pair(g.score) || !pair(g.reserve) || !pair(g.moves)) return;
|
|
|
|
|
|
if(!isFinite(g.held) || g.held < 0) return;
|
|
|
|
|
|
if([IDLE, RUN, PAUSE, FLAG].indexOf(g.phase) < 0) return;
|
|
|
|
|
|
if([-1, 0, 1].indexOf(g.active) < 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
st.score = [g.score[0], g.score[1]];
|
|
|
|
|
|
clampScore(); // the match may have been shortened since
|
|
|
|
|
|
st.reserve = [g.reserve[0], g.reserve[1]];
|
|
|
|
|
|
st.moves = [g.moves[0], g.moves[1]];
|
|
|
|
|
|
st.active = g.active;
|
|
|
|
|
|
st.phase = g.phase === RUN ? PAUSE : g.phase;
|
|
|
|
|
|
st.held = st.phase === PAUSE ? g.held : 0;
|
|
|
|
|
|
st.ticked = st.held > cfg.delay; // don't re-tick a delay that already expired
|
|
|
|
|
|
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// the only hooks iOS reliably fires before killing an app
|
|
|
|
|
|
document.addEventListener("visibilitychange", function(){
|
|
|
|
|
|
if(document.visibilityState === "hidden") saveGame();
|
|
|
|
|
|
});
|
|
|
|
|
|
window.addEventListener("pagehide", saveGame);
|
|
|
|
|
|
|
2026-08-11 11:18:02 +02:00
|
|
|
|
/* 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(){
|
2026-08-11 19:10:23 +02:00
|
|
|
|
if(armT){ disarm(); resetAll(); return; } // second tap inside the window
|
2026-08-11 11:18:02 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
/* ============ 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-11 14:48:44 +02:00
|
|
|
|
// a single game has no match score, so the score line and the sheet that
|
|
|
|
|
|
// edits it are both out of the way until the match is longer than a point
|
|
|
|
|
|
function paintMatchChrome(){
|
|
|
|
|
|
var many = cfg.points > 1;
|
|
|
|
|
|
function show(node){
|
|
|
|
|
|
if(many) node.removeAttribute("hidden"); else node.setAttribute("hidden", "");
|
|
|
|
|
|
}
|
|
|
|
|
|
show(btnScore);
|
2026-08-11 15:11:31 +02:00
|
|
|
|
el.forEach(function(e){ e.scoreLines.forEach(show); });
|
2026-08-11 14:48:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 14:39:32 +02:00
|
|
|
|
function clock(ms){ // m:ss, for copy rather than the big readout
|
|
|
|
|
|
var s = Math.round(ms / 1000);
|
|
|
|
|
|
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
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);
|
2026-08-11 14:39:32 +02:00
|
|
|
|
document.getElementById("v-points").textContent = cfg.points;
|
|
|
|
|
|
document.getElementById("p-time").textContent = cfg.points === 1
|
|
|
|
|
|
? "Reserve time per point."
|
|
|
|
|
|
: "Reserve time per point. " + cfg.points + " × " + clock(cfg.base) +
|
|
|
|
|
|
" = " + clock(matchTime()) + " each.";
|
2026-08-11 14:48:44 +02:00
|
|
|
|
paintMatchChrome();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
|
|
|
|
|
|
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
|
2026-08-11 11:18:02 +02:00
|
|
|
|
showIcon(btnSound, "on", cfg.sound);
|
|
|
|
|
|
showIcon(btnSound, "off", !cfg.sound);
|
|
|
|
|
|
paintPresets();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 11:18:02 +02:00
|
|
|
|
/* 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));
|
2026-08-11 14:39:32 +02:00
|
|
|
|
paintSettings(); resetClocks();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
[].forEach.call(document.querySelectorAll("[data-points-adj]"), function(b){
|
|
|
|
|
|
b.addEventListener("click", function(){
|
|
|
|
|
|
// stepping by 2 from 1 keeps it odd without any parity juggling
|
|
|
|
|
|
var next = cfg.points + parseInt(b.dataset.pointsAdj, 10);
|
|
|
|
|
|
cfg.points = Math.min(29, Math.max(1, next));
|
|
|
|
|
|
store.set("bg.points", String(cfg.points));
|
|
|
|
|
|
clampScore(); // a shorter match can't hold a bigger score
|
|
|
|
|
|
paintSettings(); resetClocks();
|
2026-08-11 11:18:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
[].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));
|
2026-08-11 14:39:32 +02:00
|
|
|
|
paintSettings(); resetClocks();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
[].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));
|
2026-08-11 14:39:32 +02:00
|
|
|
|
paintSettings(); resetClocks();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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); });
|
|
|
|
|
|
|
2026-08-11 14:39:32 +02:00
|
|
|
|
/* ============ sheets ============ */
|
|
|
|
|
|
var openNode = null;
|
|
|
|
|
|
function openSheet(node){
|
|
|
|
|
|
openNode = node;
|
|
|
|
|
|
sheet.classList.toggle("on", node === sheet);
|
|
|
|
|
|
scoreSheet.classList.toggle("on", node === scoreSheet);
|
|
|
|
|
|
scrim.classList.toggle("on", !!node);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
2026-08-11 14:39:32 +02:00
|
|
|
|
function closeSheet(){ openSheet(null); }
|
|
|
|
|
|
|
2026-08-11 15:57:37 +02:00
|
|
|
|
btnSettings.addEventListener("click", function(){
|
|
|
|
|
|
// the CSS only hides the button; the rule itself lives here, so no route in
|
|
|
|
|
|
// can reach the timer controls once the match is under way
|
|
|
|
|
|
if(!matchPristine()) return;
|
2026-08-11 14:39:32 +02:00
|
|
|
|
pauseNow(); // nobody's clock runs while a sheet is open
|
2026-08-11 11:18:02 +02:00
|
|
|
|
disarm();
|
|
|
|
|
|
paintSettings();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
openSheet(sheet);
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("done").addEventListener("click", closeSheet);
|
|
|
|
|
|
// tapping outside the score sheet discards the staged edits rather than
|
|
|
|
|
|
// committing them — a stray tap must never restart a game
|
2026-08-11 15:11:31 +02:00
|
|
|
|
scrim.addEventListener("click", function(){
|
|
|
|
|
|
if(openNode === scoreSheet) closeScore(); else closeSheet();
|
|
|
|
|
|
});
|
2026-08-11 14:39:32 +02:00
|
|
|
|
|
|
|
|
|
|
/* ============ score sheet ============ */
|
|
|
|
|
|
// Edits are staged here and only reach `st` on Done, so opening the sheet,
|
|
|
|
|
|
// fiddling and tapping away changes nothing.
|
2026-08-11 15:11:31 +02:00
|
|
|
|
var edit = null, before = null, resumeOnClose = false;
|
|
|
|
|
|
|
|
|
|
|
|
// leaving the sheet with the turn still live carries straight on. After a score
|
|
|
|
|
|
// change there is no turn to resume — newGame() has already moved us to IDLE,
|
|
|
|
|
|
// which the PAUSE check below quietly skips.
|
|
|
|
|
|
function closeScore(){
|
|
|
|
|
|
if(resumeOnClose) resumeNow();
|
|
|
|
|
|
resumeOnClose = false;
|
|
|
|
|
|
closeSheet();
|
|
|
|
|
|
}
|
2026-08-11 14:39:32 +02:00
|
|
|
|
|
|
|
|
|
|
function paintScore(){
|
|
|
|
|
|
document.getElementById("v-you").textContent = edit.score[1];
|
|
|
|
|
|
document.getElementById("v-them").textContent = edit.score[0];
|
|
|
|
|
|
[[1, "you"], [0, "them"]].forEach(function(p){
|
|
|
|
|
|
var s = Math.round(edit.time[p[0]] / 1000);
|
|
|
|
|
|
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
|
|
|
|
|
|
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openScore(){
|
2026-08-11 15:11:31 +02:00
|
|
|
|
// only the sheet's own pause gets undone on the way out; a pause the players
|
|
|
|
|
|
// set themselves survives
|
|
|
|
|
|
resumeOnClose = (st.phase === RUN);
|
2026-08-11 14:39:32 +02:00
|
|
|
|
pauseNow();
|
|
|
|
|
|
disarm();
|
|
|
|
|
|
// seed from what the panels are showing, not from the banked reserve — for
|
|
|
|
|
|
// the interrupted turn those differ by the delay already spent
|
|
|
|
|
|
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
|
|
|
|
|
before = { score: edit.score.slice(), time: edit.time.slice() };
|
|
|
|
|
|
paintScore();
|
|
|
|
|
|
openSheet(scoreSheet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyScore(){
|
|
|
|
|
|
if(!edit) return;
|
|
|
|
|
|
var scoreChanged = edit.score[0] !== before.score[0] || edit.score[1] !== before.score[1];
|
|
|
|
|
|
var timeChanged = edit.time[0] !== before.time[0] || edit.time[1] !== before.time[1];
|
|
|
|
|
|
if(!scoreChanged && !timeChanged) return; // Done with no edits changes nothing
|
|
|
|
|
|
|
|
|
|
|
|
if(scoreChanged){
|
|
|
|
|
|
// the displayed times become the reserves, which banks the interrupted
|
|
|
|
|
|
// turn's spend — "the clocks keep their value"
|
|
|
|
|
|
st.score = edit.score.slice();
|
|
|
|
|
|
st.reserve = [edit.time[0], edit.time[1]];
|
|
|
|
|
|
newGame();
|
|
|
|
|
|
}else{
|
|
|
|
|
|
// a correction mid-game: put the spent delay back on so the panel shows
|
|
|
|
|
|
// exactly what was typed, and leave the turn and its delay alone
|
|
|
|
|
|
st.reserve = [edit.time[0] + consumed(0), edit.time[1] + consumed(1)];
|
|
|
|
|
|
render(); // every other mutator repaints; don't wait a frame
|
|
|
|
|
|
}
|
|
|
|
|
|
saveGame();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[].forEach.call(document.querySelectorAll("[data-score-adj]"), function(b){
|
|
|
|
|
|
b.addEventListener("click", function(){
|
|
|
|
|
|
if(!edit) return;
|
|
|
|
|
|
var who = parseInt(b.dataset.who, 10);
|
|
|
|
|
|
var next = edit.score[who] + parseInt(b.dataset.scoreAdj, 10);
|
|
|
|
|
|
edit.score[who] = Math.min(cfg.points, Math.max(0, next));
|
|
|
|
|
|
paintScore();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
[].forEach.call(document.querySelectorAll("[data-clock-adj]"), function(b){
|
|
|
|
|
|
b.addEventListener("click", function(){
|
|
|
|
|
|
if(!edit) return;
|
|
|
|
|
|
var who = parseInt(b.dataset.who, 10);
|
|
|
|
|
|
var next = edit.time[who] + parseInt(b.dataset.clockAdj, 10) * 1000;
|
|
|
|
|
|
edit.time[who] = Math.min(99 * 60000, Math.max(0, next));
|
|
|
|
|
|
paintScore();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("score").addEventListener("click", openScore);
|
|
|
|
|
|
document.getElementById("score-done").addEventListener("click", function(){
|
|
|
|
|
|
applyScore();
|
2026-08-11 15:11:31 +02:00
|
|
|
|
closeScore();
|
2026-08-11 11:18:02 +02:00
|
|
|
|
});
|
2026-08-11 10:52:35 +02:00
|
|
|
|
|
2026-08-11 11:18:02 +02:00
|
|
|
|
btnReset.addEventListener("click", armReset);
|
2026-08-11 10:52:35 +02:00
|
|
|
|
btnPlay.addEventListener("click", togglePause);
|
|
|
|
|
|
|
|
|
|
|
|
/* ============ panel taps ============ */
|
2026-08-11 17:26:56 +02:00
|
|
|
|
// A tap is a press and a lift in the same place. Acting on the press alone
|
|
|
|
|
|
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
|
|
|
|
|
// bottom panel owns that edge, and starting a swipe home is a finger landing.
|
|
|
|
|
|
// A swipe travels past SLOP, or the system claims it and we get pointercancel
|
|
|
|
|
|
// instead of a lift. Either way there's no turn change.
|
|
|
|
|
|
var SLOP = 12; // px of travel a press may drift and still count
|
2026-08-11 10:52:35 +02:00
|
|
|
|
var wantFullscreen = true;
|
|
|
|
|
|
panels.forEach(function(p){
|
2026-08-11 17:26:56 +02:00
|
|
|
|
var down = null;
|
|
|
|
|
|
var side = parseInt(p.dataset.side, 10);
|
|
|
|
|
|
|
2026-08-11 10:52:35 +02:00
|
|
|
|
p.addEventListener("pointerdown", function(ev){
|
|
|
|
|
|
ev.preventDefault();
|
2026-08-11 17:26:56 +02:00
|
|
|
|
audio(); // unlocking on the press is a valid gesture and earlier is better
|
2026-08-11 17:47:50 +02:00
|
|
|
|
down = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
2026-08-11 17:26:56 +02:00
|
|
|
|
// so the lift still lands here if the finger drifts onto the other panel
|
|
|
|
|
|
try{ p.setPointerCapture(ev.pointerId); }catch(e){}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
p.addEventListener("pointerup", function(ev){
|
|
|
|
|
|
var d = down;
|
|
|
|
|
|
// only the pointer we're tracking clears it: with two fingers on one panel
|
|
|
|
|
|
// the second overwrites the first, and the first's lift mustn't wipe it
|
|
|
|
|
|
if(!d || ev.pointerId !== d.id) return;
|
|
|
|
|
|
down = null;
|
|
|
|
|
|
if(Math.abs(ev.clientX - d.x) > SLOP || Math.abs(ev.clientY - d.y) > SLOP) return;
|
|
|
|
|
|
|
|
|
|
|
|
// on the lift, not the press, so a swipe can't force fullscreen either
|
2026-08-11 10:52:35 +02:00
|
|
|
|
if(wantFullscreen){
|
|
|
|
|
|
wantFullscreen = false;
|
|
|
|
|
|
if(!window.matchMedia("(display-mode: fullscreen)").matches && document.documentElement.requestFullscreen){
|
|
|
|
|
|
document.documentElement.requestFullscreen().catch(function(){});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-11 17:47:50 +02:00
|
|
|
|
tap(side);
|
2026-08-11 17:26:56 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// the branch Android's home gesture takes
|
|
|
|
|
|
p.addEventListener("pointercancel", function(ev){
|
|
|
|
|
|
if(down && ev.pointerId === down.id) down = null;
|
2026-08-11 10:52:35 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/* ============ keep the screen awake ============ */
|
|
|
|
|
|
var lock = null;
|
|
|
|
|
|
function keepAwake(){
|
2026-08-11 11:53:56 +02:00
|
|
|
|
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(){});
|
2026-08-11 10:52:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener("visibilitychange", function(){
|
2026-08-11 11:53:56 +02:00
|
|
|
|
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){} }
|
2026-08-11 10:52:35 +02:00
|
|
|
|
});
|
|
|
|
|
|
keepAwake();
|
|
|
|
|
|
|
|
|
|
|
|
/* ============ go ============ */
|
|
|
|
|
|
applyTheme();
|
|
|
|
|
|
paintSettings();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
loadGame(); // restores a match in progress; leaves the fresh state if there's none
|
2026-08-11 10:52:35 +02:00
|
|
|
|
paintPlay();
|
2026-08-11 14:39:32 +02:00
|
|
|
|
render();
|
2026-08-11 10:52:35 +02:00
|
|
|
|
loop();
|
|
|
|
|
|
|
|
|
|
|
|
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
|
|
|
|
|
|
navigator.serviceWorker.register("sw.js").catch(function(){});
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|