Set the time and the delay per player

A handicap match had no way to happen: one time control, one delay, both
players. The settings sheet now carries a switch between Match and Time —
"Different time per player" — and turning it on gives Time and Delay a second
row each, labelled You for the bottom half and Them for the far end, the same
way round as the score sheet. Them starts as a copy of You. Turning it off puts
both players back on You's value, so the row that survives is the one still on
screen; turning it on again re-copies rather than remembering what Them used to
say. The presets go while it is on, because a preset is one pair of numbers and
there are two players to give it to.

Underneath, cfg.base and cfg.delay are arrays indexed like st.reserve and the
panels, and they are arrays whether or not the switch is on. Split is a fact
about the sheet, not about the clock: render() and tap() read their own side's
number and never ask which mode they are in, and shared is simply the state
where the two entries are equal, kept that way by one line in the setter. The
line that mattered most was matchPristine(), which compares both sides now —
left alone it would have made the gear unreachable the moment the two bases
differed, silently and with no way back but clearing storage.

bg.base and bg.delay still mean what they always did, which is side 1, so an
install from before this lands symmetric and an older build reading the same
storage still finds its own settings. bg.split, bg.base.them and bg.delay.them
are the new keys, and all four are clamped on the way in — points was the only
setting ever validated at load, which was survivable while nothing but our own
steppers could write these.

The sheet loses two rows that earned nothing. Sound duplicated the speaker
button that is in the bar at all times, including mid-match when the sheet
cannot be opened at all; the hint under Done repeated what the sheet's own
behaviour teaches. The score sheet's hint now names the match it would restart
within, and the Time copy ends in a colon and puts its two sums on their own
line, where they fit without breaking mid-sum.

Two bugs worth recording, both invisible in the diff and both caught by
rendering rather than by asserting. The Them rows are .side elements and
.side{display:flex} beat the hidden attribute, so both stepper rows showed in
shared mode — the probe asked for the hidden property, which was set correctly,
and passed. And data-them was already the panels' opponent-score span, so
hiding the sheet's Them rows blanked the score on both halves and collapsed the
head grid; it is data-them-row now.

Screenshots 2 and 3 are redone. The other three are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:55:20 +02:00
co-authored by Claude Opus 5
parent e207478e79
commit a34d6058a1
4 changed files with 227 additions and 71 deletions
+25 -8
View File
@@ -96,7 +96,8 @@ normally restarted — use pause and reset for those.
Set **Match** in the settings to the number of points you're playing to — 1, 3,
5 and up to 29. The time you set is *per point*, so a 5-point match at 2:00 a
point puts 10:00 on each clock, and the settings sheet shows you that sum.
point puts 10:00 on each clock, and the settings sheet shows you that sum — one
sum, or one for each player if you've set their times apart.
Each half reads the state from that player's own side of the board:
@@ -136,17 +137,33 @@ score down to fit.
## Settings
Under **Delay** are three presets — **2m / 12s**, **3m / 12s** and **3m / 15s**.
Tapping one jumps both timers there and lights it up. Change the time or delay by
hand and the highlight goes out; land back on a preset's numbers and it comes
back on.
Tapping one puts both the time and the delay there and lights it up. Change
either by hand and the highlight goes out; land back on a preset's numbers and it
comes back on.
**Different time per player** — the switch under the Time steppers — gives each
player their own time *and* their own delay. Turn it on and a second row appears
under both, labelled **You** for the bottom half of the phone and **Them** for
the far end, the same way round as the score sheet. Them starts as a copy of You;
change either from there. Turn it back off and both players go back to whatever
You says, so You is always the row that survives — set the shared time first and
split it afterwards, not the other way round.
The presets are gone while that switch is on. A preset is one pair of numbers and
there are two players to give it to, so there is nothing sensible for it to mean.
A handicap match is the point of the switch: give the stronger player less time,
or take one player's delay down to zero and leave the other's alone.
| Setting | Range | Default |
| --- | --- | --- |
| Match | 1 29 points, odd | 1 |
| Time | 0:15 99:00 per point | 3:00 |
| Delay | 0 60 s | 12 s |
| Time | 0:15 99:00 per point, per player | 3:00 |
| Delay | 0 60 s, per player | 12 s |
| Different time per player | on / off | off |
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
| Sound | on / off | on |
Sound isn't in the sheet — it's the speaker button in the bar, reachable at any
time, mid-match included.
Brass is the colour in the chess.com app, if you want the exact original look.
Changing match, time or delay resets the clocks — the score is left alone, so you
@@ -157,7 +174,7 @@ survive closing the tab, force-quitting the installed app and rebooting the
phone — the app also asks for persistent storage so they aren't evicted when the
device runs low on space (Chrome honours that request; Safari ignores it).
Reopening restores the score, both clocks, the move counts, whose turn it is and
how much delay was left. The one thing it can't restore is time passing while
how much delay was left — each player's own delay, if you've set them apart. The one thing it can't restore is time passing while
the app was closed, so it comes back paused rather than charging anyone for the
gap: a crash shouldn't decide a game.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 174 KiB

+202 -63
View File
@@ -369,8 +369,33 @@
border-top:1px solid var(--sheet-line);
}
.row:first-of-type{border-top:0}
.row[hidden]{display:none} /* .row's own display would otherwise win */
.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}
/* the Time copy puts its two sums on a line of their own when the players are
set apart; everything else here has no newline in it and is unaffected */
#p-time{white-space:pre-line}
/* Time and Delay each hold one stepper, or two when the players are set
separately. The second row is added rather than the first relabelled, so
the row a player already knows keeps its place and its numbers. */
.sides{display:flex;flex-direction:column;gap:10px}
.side{display:flex;align-items:center;justify-content:flex-end;gap:10px}
.side[hidden]{display:none} /* .side's own display would otherwise win */
/* pushed left against the stepper's right edge, so both rows' steppers line
up whatever the two labels measure */
.who{font-size:15px;font-weight:600;color:var(--sheet-dim);margin-right:auto}
/* nobody to tell apart while there is one row, and the label would only be
one more word to read */
.sheet:not(.split) .who{display:none}
/* a labelled stepper pair can't share a line with the heading at any width;
scoped to .split so the shared sheet keeps exactly the layout it had */
.sheet.split .row--wide,.sheet.split .row--delay{flex-direction:column;align-items:stretch}
/* the switch sits above the two rows it governs, so it reads as the thing
that decides their shape rather than as one more Time control */
.onoff-ctl{display:flex;align-items:center;gap:10px}
.onoff-ctl span{font-size:13px;color:var(--sheet-dim)}
/* 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 */
@@ -576,34 +601,75 @@
</div>
</div>
<div class="row">
<div>
<h2>Different time per player</h2>
<p>Sets the delay separately too.</p>
</div>
<div class="onoff-ctl">
<span>Off</span>
<button class="toggle" id="t-split" role="switch" aria-checked="false" aria-label="Different time per player"></button>
<span>On</span>
</div>
</div>
<div class="row row--wide">
<div>
<h2>Time</h2>
<p id="p-time">Reserve time per point.</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 class="sides">
<div class="side">
<span class="who">You</span>
<div class="stepper">
<button class="step" data-time-adj="-60" data-who="1"></button>
<span class="val"><span data-v="min" data-who="1">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60" data-who="1">+</button>
<button class="step" data-time-adj="-15" data-who="1" style="margin-left:8px"></button>
<span class="val"><span data-v="sec" data-who="1">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15" data-who="1">+</button>
</div>
</div>
<div class="side" data-them-row hidden>
<span class="who">Them</span>
<div class="stepper">
<button class="step" data-time-adj="-60" data-who="0"></button>
<span class="val"><span data-v="min" data-who="0">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60" data-who="0">+</button>
<button class="step" data-time-adj="-15" data-who="0" style="margin-left:8px"></button>
<span class="val"><span data-v="sec" data-who="0">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15" data-who="0">+</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="row row--delay">
<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 class="sides">
<div class="side">
<span class="who">You</span>
<div class="stepper">
<button class="step" data-delay-adj="-1" data-who="1"></button>
<span class="val"><span data-v="delay" data-who="1">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1" data-who="1">+</button>
</div>
</div>
<div class="side" data-them-row hidden>
<span class="who">Them</span>
<div class="stepper">
<button class="step" data-delay-adj="-1" data-who="0"></button>
<span class="val"><span data-v="delay" data-who="0">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1" data-who="0">+</button>
</div>
</div>
</div>
</div>
<div class="row row--presets">
<div class="row row--presets" id="row-presets">
<div>
<h2>Preset time limits</h2>
<p>Quickly pick a setting.</p>
@@ -623,16 +689,7 @@
<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 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">
@@ -691,8 +748,7 @@
</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>
<p class="hint" id="p-score-hint"></p>
</div>
<script>
@@ -722,24 +778,46 @@
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF", mute:.77 }
];
/* The limits the steppers enforce, kept here because the load path needs them
too: bg.base.them and bg.delay.them can arrive from a half-upgraded install,
and a corrupt reserve makes a clock that can never flag. */
var LIMIT = { base: [15000, 99 * 60000], delay: [0, 60000] };
function clampTimer(which, ms){
var l = LIMIT[which];
return isFinite(ms) ? Math.min(l[1], Math.max(l[0], ms)) : l[0];
}
/* base and delay are per side, indexed like st.reserve and the panels: 1 is
the bottom half ("you"), 0 the far end ("them"), the same way round as the
score sheet. They are arrays whether or not the setting is split — split is
a fact about the sheet, not about the clock, so nothing downstream of here
has to ask. Shared is simply the state where the two entries are equal.
bg.base and bg.delay keep meaning what they always did, which is side 1, so
an older build reading this storage still finds its own settings, and an
install that predates the split lands symmetric. */
var split = store.get("bg.split", "0") === "1";
var yBase = clampTimer("base", store.num("bg.base", 180000));
var yDelay = clampTimer("delay", store.num("bg.delay", 12000));
var cfg = {
base: store.num("bg.base", 180000), // reserve per point
delay: store.num("bg.delay", 12000),
base: [split ? clampTimer("base", store.num("bg.base.them", yBase)) : yBase, yBase],
delay: [split ? clampTimer("delay", store.num("bg.delay.them", yDelay)) : yDelay, yDelay],
split: split,
points: store.num("bg.points", 1), // match length, odd, 1..29
theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1"
};
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; }
// a match to N points is worth N times that side's per-point reserve
function matchTime(i){ return cfg.base[i] * cfg.points; }
/* ============ state ============ */
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
var st = {
phase: IDLE,
active: -1,
reserve: [matchTime(), matchTime()], // reserve banked at the start of the current turn
reserve: [matchTime(0), matchTime(1)], // reserve banked at the start of the current turn
score: [0, 0], // match points, by side
moves: [0, 0],
turnAt: 0, // performance.now() when the turn began
@@ -861,7 +939,7 @@
}
// 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;
return isLive(i) ? Math.max(0, elapsed() - cfg.delay[i]) : 0;
}
// the number the panel is showing, which is what the score sheet edits
function shownTime(i){
@@ -877,7 +955,7 @@
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();
st.reserve[0] === matchTime(0) && st.reserve[1] === matchTime(1);
}
/* ---- write only on change ----
@@ -909,10 +987,10 @@
var main = st.reserve[i], dLeft = 0;
if(live){
if(e < cfg.delay){
dLeft = cfg.delay - e;
if(e < cfg.delay[i]){
dLeft = cfg.delay[i] - e;
}else{
main = st.reserve[i] - (e - cfg.delay);
main = st.reserve[i] - (e - cfg.delay[i]);
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
}
}
@@ -931,11 +1009,11 @@
setText(el[i].them, st.score[1 - i]);
setText(el[i].target, cfg.points);
var showDelay = live && cfg.delay > 0 && dLeft > 0;
var showDelay = live && cfg.delay[i] > 0 && dLeft > 0;
el[i].delay.classList.toggle("on", showDelay); // no-op when already right
if(showDelay){
// the bar really does move every frame, but transform is compositor-only
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay).toFixed(4) + ")");
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay[i]).toFixed(4) + ")");
setText(el[i].dnum, Math.ceil(dLeft / 1000));
}
@@ -996,7 +1074,7 @@
// the same instant charges the outgoing turn and starts the incoming one,
// so nothing falls between the two and goes to neither player
var e = now - st.turnAt;
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay[side]));
st.moves[side]++;
st.active = 1 - side;
st.turnAt = now;
@@ -1052,7 +1130,7 @@
// clocks back to full for the match length; the score is not this function's business
function resetClocks(){
cancelHold();
st.reserve = [matchTime(), matchTime()];
st.reserve = [matchTime(0), matchTime(1)];
newGame();
saveGame();
}
@@ -1106,7 +1184,9 @@
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
// the delay that matters is the one belonging to whoever's turn it was;
// active is legitimately -1 here, which has no delay to compare against
st.ticked = st.active >= 0 && st.held > cfg.delay[st.active];
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
}
@@ -1237,43 +1317,103 @@
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}
// the settings readouts, by side — the score sheet already owns the obvious
// v-you-min / v-them-min ids, and a side index is what data- is for here
function readout(name, who){
return document.querySelector('[data-v="' + name + '"][data-who="' + who + '"]');
}
var vals = [0, 1].map(function(who){
return { min: readout("min", who), sec: readout("sec", who), delay: readout("delay", who) };
});
// "5 × 3:00 = 15:00" for one side
function sum(i){
return cfg.points + " × " + clock(cfg.base[i]) + " = " + clock(matchTime(i));
}
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);
for(var i = 0; i < 2; i++){
var s = Math.round(cfg.base[i] / 1000);
vals[i].min.textContent = Math.floor(s / 60);
vals[i].sec.textContent = ("0" + (s % 60)).slice(-2);
vals[i].delay.textContent = Math.round(cfg.delay[i] / 1000);
}
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.";
/* At a single point the match total is the per-point time, so there is no
sum to introduce and the colon would dangle. Split, the two sums are put
on their own line — together they are too long to sit after the label
without breaking mid-sum, which reads as one number split in half.
The newline needs white-space:pre-line on #p-time. */
document.getElementById("p-time").textContent =
cfg.points === 1 ? "Reserve time per point."
: cfg.split ? "Reserve time per point:\n" + sum(1) + " you, " + sum(0) + " them."
: "Reserve time per point: " + sum(1) + " each.";
paintSplit();
paintMatchChrome();
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();
}
/* The presets can only mean one thing for both players, so they go while the
two sides can disagree. Everything else here is the sheet rearranging
itself; the clock never asks whether the setting is split. */
function paintSplit(){
sheet.classList.toggle("split", cfg.split);
document.getElementById("t-split").setAttribute("aria-checked", cfg.split ? "true" : "false");
[].forEach.call(document.querySelectorAll("[data-them-row]"), function(n){
if(cfg.split) n.removeAttribute("hidden"); else n.setAttribute("hidden", "");
});
document.getElementById("row-presets").hidden = cfg.split;
}
/* 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 */
preset and comes back by itself when you step onto one. Side 1 is enough:
the row is only on show while both sides hold the same numbers. */
function paintPresets(){
[].forEach.call(document.querySelectorAll(".preset"), function(b){
var on = (+b.dataset.base === cfg.base && +b.dataset.presetDelay === cfg.delay);
var on = (+b.dataset.base === cfg.base[1] && +b.dataset.presetDelay === cfg.delay[1]);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
/* the one writer for both timers. Shared mode is kept as an equality rather
than a special case: write one side, write the other. */
function setTimer(which, who, ms){
cfg[which][who] = clampTimer(which, ms);
if(!cfg.split) cfg[which][1 - who] = cfg[which][who];
saveTimers();
paintSettings(); resetClocks();
}
function saveTimers(){
store.set("bg.base", String(cfg.base[1]));
store.set("bg.delay", String(cfg.delay[1]));
store.set("bg.base.them", String(cfg.base[0]));
store.set("bg.delay.them", String(cfg.delay[0]));
}
[].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));
// only reachable while shared, so both sides take the preset
var base = parseInt(b.dataset.base, 10), d = parseInt(b.dataset.presetDelay, 10);
cfg.base = [base, base];
cfg.delay = [d, d];
saveTimers();
paintSettings(); resetClocks();
});
});
document.getElementById("t-split").addEventListener("click", function(){
cfg.split = !cfg.split;
// the same line both ways: Them copies You going in, You wins coming out
cfg.base[0] = cfg.base[1];
cfg.delay[0] = cfg.delay[1];
store.set("bg.split", cfg.split ? "1" : "0");
saveTimers();
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
@@ -1287,18 +1427,14 @@
[].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(); resetClocks();
var who = parseInt(b.dataset.who, 10);
setTimer("base", who, cfg.base[who] + parseInt(b.dataset.timeAdj, 10) * 1000);
});
});
[].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(); resetClocks();
var who = parseInt(b.dataset.who, 10);
setTimer("delay", who, cfg.delay[who] + parseInt(b.dataset.delayAdj, 10) * 1000);
});
});
@@ -1308,7 +1444,6 @@
paintSettings();
if(on){ audio(); sndTick(); }
}
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
/* ============ sheets ============ */
@@ -1359,6 +1494,10 @@
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
});
// the sheet only opens on a match of 3 points or more, so there is always a
// target worth naming
document.getElementById("p-score-hint").textContent =
"Changing the score will start a new game within this match to " + cfg.points + ".";
}
function openScore(){